@founderhq/journeys 0.4.0 → 0.4.2

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
@@ -208,6 +208,12 @@ Capture is enabled by default after access validation succeeds. Pass `capture={f
208
208
 
209
209
  `apiKey`, `journeyId`, and the FounderHQ API URL are inherited from the root props and are not repeated inside `capture`.
210
210
 
211
+ Capture events are batched and persisted locally with a TTL. The runtime flushes
212
+ immediately for completion events, and browser embeds also try an exit-safe
213
+ `sendBeacon`/`keepalive` flush on `visibilitychange` and `pagehide`. Beacon
214
+ delivery is treated as best-effort; FounderHQ uses event IDs server-side so a
215
+ later retry can safely duplicate a queued event.
216
+
211
217
  ## Config Shape
212
218
 
213
219
  A journey config is centered around:
@@ -323,7 +323,7 @@ declare type BlockPropsMap = {
323
323
  export { BlockPropsMap }
324
324
  export { BlockPropsMap as BlockPropsMap_alias_1 }
325
325
 
326
- export declare function BlockRenderer({ blocks, layout, answers, onNext, onBack, onGoToStep, scrollViewportRef, wrapInScrollViewport, }: BlockRendererProps): JSX.Element;
326
+ export declare function BlockRenderer({ blocks, layout, answers, onNext, onBack, onGoToStep, isLastStep, scrollViewportRef, wrapInScrollViewport, }: BlockRendererProps): JSX.Element;
327
327
 
328
328
  declare type BlockRendererProps = {
329
329
  blocks: BlockConfig[];
@@ -332,6 +332,7 @@ declare type BlockRendererProps = {
332
332
  onNext: () => void;
333
333
  onBack: () => void;
334
334
  onGoToStep?: (stepId: string) => void;
335
+ isLastStep?: boolean;
335
336
  scrollViewportRef?: Ref<HTMLDivElement>;
336
337
  wrapInScrollViewport?: boolean;
337
338
  };
@@ -355,6 +356,8 @@ declare type ButtonAction = {
355
356
  type: "link";
356
357
  url: string;
357
358
  external?: boolean;
359
+ /** Final-step links complete before navigating by default. Set false to opt out, or true to force this on non-final steps. */
360
+ completeBeforeNavigate?: boolean;
358
361
  } | {
359
362
  type: "purchase";
360
363
  planVariable?: string;
@@ -370,7 +373,7 @@ declare type ButtonAction = {
370
373
  export { ButtonAction }
371
374
  export { ButtonAction as ButtonAction_alias_1 }
372
375
 
373
- export declare function ButtonBlock({ label, action, variant, className, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, disabled, }: ButtonBlockComponentProps): JSX.Element;
376
+ export declare function ButtonBlock({ label, action, variant, className, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, disabled, isLastStep, }: ButtonBlockComponentProps): JSX.Element;
374
377
 
375
378
  declare type ButtonBlockComponentProps = ButtonBlockProps & {
376
379
  onNext?: () => void;
@@ -383,6 +386,7 @@ declare type ButtonBlockComponentProps = ButtonBlockProps & {
383
386
  onOpenDiscountCode?: () => void;
384
387
  /** Injected by BlockRenderer when sibling input blocks have unmet validation. */
385
388
  disabled?: boolean;
389
+ isLastStep?: boolean;
386
390
  };
387
391
 
388
392
  declare type ButtonBlockProps = {
@@ -572,13 +576,14 @@ declare type ColumnConfig = {
572
576
  export { ColumnConfig }
573
577
  export { ColumnConfig as ColumnConfig_alias_1 }
574
578
 
575
- export declare function ColumnsBlock({ columns, gap, responsive, className, answers, onNext, onBack, onGoToStep, }: ColumnsBlockComponentProps): JSX.Element;
579
+ export declare function ColumnsBlock({ columns, gap, responsive, className, answers, onNext, onBack, onGoToStep, isLastStep, }: ColumnsBlockComponentProps): JSX.Element;
576
580
 
577
581
  declare type ColumnsBlockComponentProps = ColumnsBlockProps & {
578
582
  answers?: JourneyAnswers;
579
583
  onNext?: () => void;
580
584
  onBack?: () => void;
581
585
  onGoToStep?: (stepId: string) => void;
586
+ isLastStep?: boolean;
582
587
  };
583
588
 
584
589
  declare type ColumnsBlockProps = {
@@ -979,7 +984,7 @@ declare type ImageBlockProps = {
979
984
  export { ImageBlockProps }
980
985
  export { ImageBlockProps as ImageBlockProps_alias_1 }
981
986
 
982
- export declare function InfoPageStep({ config, onNext }: StepComponentProps): JSX.Element | null;
987
+ export declare function InfoPageStep({ config, onNext, isLastStep, }: StepComponentProps): JSX.Element | null;
983
988
 
984
989
  export declare function Input({ className, type, ...props }: React_2.ComponentProps<"input">): React_2.JSX.Element;
985
990
 
@@ -1863,6 +1868,7 @@ declare type StepComponentProps = {
1863
1868
  answer: StepAnswer | undefined;
1864
1869
  onAnswer: (answer: StepAnswer) => void;
1865
1870
  onNext: () => void;
1871
+ isLastStep?: boolean;
1866
1872
  };
1867
1873
  export { StepComponentProps }
1868
1874
  export { StepComponentProps as StepComponentProps_alias_1 }
@@ -323,7 +323,7 @@ declare type BlockPropsMap = {
323
323
  export { BlockPropsMap }
324
324
  export { BlockPropsMap as BlockPropsMap_alias_1 }
325
325
 
326
- export declare function BlockRenderer({ blocks, layout, answers, onNext, onBack, onGoToStep, scrollViewportRef, wrapInScrollViewport, }: BlockRendererProps): JSX.Element;
326
+ export declare function BlockRenderer({ blocks, layout, answers, onNext, onBack, onGoToStep, isLastStep, scrollViewportRef, wrapInScrollViewport, }: BlockRendererProps): JSX.Element;
327
327
 
328
328
  declare type BlockRendererProps = {
329
329
  blocks: BlockConfig[];
@@ -332,6 +332,7 @@ declare type BlockRendererProps = {
332
332
  onNext: () => void;
333
333
  onBack: () => void;
334
334
  onGoToStep?: (stepId: string) => void;
335
+ isLastStep?: boolean;
335
336
  scrollViewportRef?: Ref<HTMLDivElement>;
336
337
  wrapInScrollViewport?: boolean;
337
338
  };
@@ -355,6 +356,8 @@ declare type ButtonAction = {
355
356
  type: "link";
356
357
  url: string;
357
358
  external?: boolean;
359
+ /** Final-step links complete before navigating by default. Set false to opt out, or true to force this on non-final steps. */
360
+ completeBeforeNavigate?: boolean;
358
361
  } | {
359
362
  type: "purchase";
360
363
  planVariable?: string;
@@ -370,7 +373,7 @@ declare type ButtonAction = {
370
373
  export { ButtonAction }
371
374
  export { ButtonAction as ButtonAction_alias_1 }
372
375
 
373
- export declare function ButtonBlock({ label, action, variant, className, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, disabled, }: ButtonBlockComponentProps): JSX.Element;
376
+ export declare function ButtonBlock({ label, action, variant, className, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, disabled, isLastStep, }: ButtonBlockComponentProps): JSX.Element;
374
377
 
375
378
  declare type ButtonBlockComponentProps = ButtonBlockProps & {
376
379
  onNext?: () => void;
@@ -383,6 +386,7 @@ declare type ButtonBlockComponentProps = ButtonBlockProps & {
383
386
  onOpenDiscountCode?: () => void;
384
387
  /** Injected by BlockRenderer when sibling input blocks have unmet validation. */
385
388
  disabled?: boolean;
389
+ isLastStep?: boolean;
386
390
  };
387
391
 
388
392
  declare type ButtonBlockProps = {
@@ -572,13 +576,14 @@ declare type ColumnConfig = {
572
576
  export { ColumnConfig }
573
577
  export { ColumnConfig as ColumnConfig_alias_1 }
574
578
 
575
- export declare function ColumnsBlock({ columns, gap, responsive, className, answers, onNext, onBack, onGoToStep, }: ColumnsBlockComponentProps): JSX.Element;
579
+ export declare function ColumnsBlock({ columns, gap, responsive, className, answers, onNext, onBack, onGoToStep, isLastStep, }: ColumnsBlockComponentProps): JSX.Element;
576
580
 
577
581
  declare type ColumnsBlockComponentProps = ColumnsBlockProps & {
578
582
  answers?: JourneyAnswers;
579
583
  onNext?: () => void;
580
584
  onBack?: () => void;
581
585
  onGoToStep?: (stepId: string) => void;
586
+ isLastStep?: boolean;
582
587
  };
583
588
 
584
589
  declare type ColumnsBlockProps = {
@@ -979,7 +984,7 @@ declare type ImageBlockProps = {
979
984
  export { ImageBlockProps }
980
985
  export { ImageBlockProps as ImageBlockProps_alias_1 }
981
986
 
982
- export declare function InfoPageStep({ config, onNext }: StepComponentProps): JSX.Element | null;
987
+ export declare function InfoPageStep({ config, onNext, isLastStep, }: StepComponentProps): JSX.Element | null;
983
988
 
984
989
  export declare function Input({ className, type, ...props }: React_2.ComponentProps<"input">): React_2.JSX.Element;
985
990
 
@@ -1863,6 +1868,7 @@ declare type StepComponentProps = {
1863
1868
  answer: StepAnswer | undefined;
1864
1869
  onAnswer: (answer: StepAnswer) => void;
1865
1870
  onNext: () => void;
1871
+ isLastStep?: boolean;
1866
1872
  };
1867
1873
  export { StepComponentProps }
1868
1874
  export { StepComponentProps as StepComponentProps_alias_1 }
package/dist/index.cjs CHANGED
@@ -2698,9 +2698,11 @@ function ButtonBlock({
2698
2698
  onGoToStep,
2699
2699
  onPurchase,
2700
2700
  onOpenDiscountCode,
2701
- disabled
2701
+ disabled,
2702
+ isLastStep
2702
2703
  }) {
2703
2704
  const handleClick = () => {
2705
+ var _a;
2704
2706
  if (!action) {
2705
2707
  onNext == null ? void 0 : onNext();
2706
2708
  return;
@@ -2720,6 +2722,9 @@ function ButtonBlock({
2720
2722
  }
2721
2723
  break;
2722
2724
  case "link":
2725
+ if ((_a = action.completeBeforeNavigate) != null ? _a : isLastStep) {
2726
+ onNext == null ? void 0 : onNext();
2727
+ }
2723
2728
  if (action.external) {
2724
2729
  window.open(action.url, "_blank", "noopener,noreferrer");
2725
2730
  } else {
@@ -4210,7 +4215,8 @@ function ColumnsBlock({
4210
4215
  answers,
4211
4216
  onNext,
4212
4217
  onBack,
4213
- onGoToStep
4218
+ onGoToStep,
4219
+ isLastStep
4214
4220
  }) {
4215
4221
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "jy-columns-container", children: /* @__PURE__ */ jsxRuntime.jsx(
4216
4222
  "div",
@@ -4236,6 +4242,7 @@ function ColumnsBlock({
4236
4242
  onBack: onBack != null ? onBack : (() => {
4237
4243
  }),
4238
4244
  onGoToStep,
4245
+ isLastStep,
4239
4246
  wrapInScrollViewport: false
4240
4247
  }
4241
4248
  )
@@ -7911,7 +7918,11 @@ function HiddenBlockSlot({ block }) {
7911
7918
  return /* @__PURE__ */ jsxRuntime.jsx(
7912
7919
  "div",
7913
7920
  {
7914
- className: cn(widthClass, resolvedMaxWidth ? "mx-auto" : void 0, block.className),
7921
+ className: cn(
7922
+ widthClass,
7923
+ resolvedMaxWidth ? "mx-auto" : void 0,
7924
+ block.className
7925
+ ),
7915
7926
  style: __spreadProps(__spreadValues(__spreadValues({}, resolvedMaxWidth ? { maxWidth: resolvedMaxWidth } : null), block.style), {
7916
7927
  visibility: "hidden"
7917
7928
  }),
@@ -8094,7 +8105,7 @@ function resolveAppliedDiscountForDialog(answers, discountVariable, planId) {
8094
8105
  if (!planId) return answer.planId ? void 0 : answer;
8095
8106
  return discountAppliesToPlan(answer, planId) ? answer : void 0;
8096
8107
  }
8097
- function renderBlock(block, i, visibleIndexRef, answers, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, inputsValid) {
8108
+ function renderBlock(block, i, visibleIndexRef, answers, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, inputsValid, isLastStep) {
8098
8109
  var _a, _b;
8099
8110
  const conditionMet = !block.condition || evaluateCondition(block.condition, answers);
8100
8111
  const hasExitAnim = ((_a = block.exitAnimation) == null ? void 0 : _a.preset) && block.exitAnimation.preset !== "none";
@@ -8121,8 +8132,9 @@ function renderBlock(block, i, visibleIndexRef, answers, onNext, onBack, onGoToS
8121
8132
  onGoToStep,
8122
8133
  onPurchase,
8123
8134
  onOpenDiscountCode,
8124
- disabled: buttonDisabledForValidity(resolvedProps, inputsValid)
8125
- }) : block.type === "columns" ? __spreadProps(__spreadValues({}, resolvedProps), { answers, onNext, onBack, onGoToStep }) : block.type === "gravity_bin" ? __spreadProps(__spreadValues({}, resolvedProps), { answers }) : resolvedProps;
8135
+ disabled: buttonDisabledForValidity(resolvedProps, inputsValid),
8136
+ isLastStep
8137
+ }) : block.type === "columns" ? __spreadProps(__spreadValues({}, resolvedProps), { answers, onNext, onBack, onGoToStep, isLastStep }) : block.type === "gravity_bin" ? __spreadProps(__spreadValues({}, resolvedProps), { answers }) : resolvedProps;
8126
8138
  const idx = visibleIndexRef.current;
8127
8139
  visibleIndexRef.current++;
8128
8140
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -8144,6 +8156,7 @@ function BlockRenderer({
8144
8156
  onNext,
8145
8157
  onBack,
8146
8158
  onGoToStep,
8159
+ isLastStep,
8147
8160
  scrollViewportRef,
8148
8161
  wrapInScrollViewport = true
8149
8162
  }) {
@@ -8229,7 +8242,8 @@ function BlockRenderer({
8229
8242
  onGoToStep,
8230
8243
  onPurchase,
8231
8244
  onOpenDiscountCode,
8232
- inputsValid
8245
+ inputsValid,
8246
+ isLastStep
8233
8247
  );
8234
8248
  };
8235
8249
  if (!wrapInScrollViewport && stickyBlocks.length === 0) {
@@ -8252,7 +8266,11 @@ function BlockRenderer({
8252
8266
  "div",
8253
8267
  {
8254
8268
  className: layoutClasses(layout),
8255
- style: { gap: `${gap}rem`, maxWidth: layout == null ? void 0 : layout.maxWidth, width: "100%" },
8269
+ style: {
8270
+ gap: `${gap}rem`,
8271
+ maxWidth: layout == null ? void 0 : layout.maxWidth,
8272
+ width: "100%"
8273
+ },
8256
8274
  children: contentBlocks.map((block, i) => renderOne(block, i))
8257
8275
  }
8258
8276
  )
@@ -8263,7 +8281,11 @@ function BlockRenderer({
8263
8281
  ) }) : null
8264
8282
  ] });
8265
8283
  }
8266
- function InfoPageStep({ config, onNext }) {
8284
+ function InfoPageStep({
8285
+ config,
8286
+ onNext,
8287
+ isLastStep
8288
+ }) {
8267
8289
  var _a;
8268
8290
  const { answers } = useJourneyState();
8269
8291
  const { goBack, goToStep } = useJourneyActions();
@@ -8271,9 +8293,7 @@ function InfoPageStep({ config, onNext }) {
8271
8293
  const sortedTimelineEvents = React.useMemo(
8272
8294
  () => {
8273
8295
  var _a2, _b;
8274
- return [...(_b = (_a2 = config.scrollTimeline) == null ? void 0 : _a2.events) != null ? _b : []].sort(
8275
- (a, b) => a.at - b.at
8276
- );
8296
+ return [...(_b = (_a2 = config.scrollTimeline) == null ? void 0 : _a2.events) != null ? _b : []].sort((a, b) => a.at - b.at);
8277
8297
  },
8278
8298
  [(_a = config.scrollTimeline) == null ? void 0 : _a.events]
8279
8299
  );
@@ -8453,6 +8473,7 @@ function InfoPageStep({ config, onNext }) {
8453
8473
  onNext,
8454
8474
  onBack: goBack,
8455
8475
  onGoToStep: goToStep,
8476
+ isLastStep,
8456
8477
  scrollViewportRef
8457
8478
  }
8458
8479
  );
@@ -9320,7 +9341,7 @@ var STEP_REGISTRY = {
9320
9341
  function StepRenderer({ config }) {
9321
9342
  var _a, _b;
9322
9343
  const { answers } = useJourneyState();
9323
- const { setAnswer, goNext } = useJourneyActions();
9344
+ const { setAnswer, goNext, isLastStep } = useJourneyActions();
9324
9345
  const resolvedConfig = React.useMemo(() => {
9325
9346
  var _a2, _b2, _c, _d, _e, _f, _g, _h, _i, _j, _k;
9326
9347
  const hasTemplates = ((_a2 = config.preface) == null ? void 0 : _a2.includes("${")) || ((_b2 = config.question) == null ? void 0 : _b2.includes("${")) || ((_c = config.description) == null ? void 0 : _c.includes("${")) || ((_d = config.buttonText) == null ? void 0 : _d.includes("${")) || ((_e = config.footerText) == null ? void 0 : _e.includes("${")) || ((_g = (_f = config.swipeLabels) == null ? void 0 : _f.yes) == null ? void 0 : _g.includes("${")) || ((_i = (_h = config.swipeLabels) == null ? void 0 : _h.no) == null ? void 0 : _i.includes("${")) || ((_j = config.swipeCards) != null ? _j : []).some(
@@ -9373,7 +9394,8 @@ function StepRenderer({ config }) {
9373
9394
  }
9374
9395
  setAnswer(answerKey, answer);
9375
9396
  },
9376
- onNext: () => goNext()
9397
+ onNext: () => goNext(),
9398
+ isLastStep: isLastStep()
9377
9399
  }
9378
9400
  );
9379
9401
  }
@@ -9671,20 +9693,15 @@ function JourneyShell({ className, theme } = {}) {
9671
9693
  ] });
9672
9694
  }
9673
9695
  var JOURNEY_LIBRARY_NAME = "@founderhq/journeys";
9674
- var JOURNEY_LIBRARY_VERSION = "0.4.0";
9696
+ var JOURNEY_LIBRARY_VERSION = "0.4.2";
9697
+ var DEFAULT_QUEUE_TTL_MS = 24 * 60 * 60 * 1e3;
9698
+ var MAX_QUEUED_CAPTURE_EVENTS = 100;
9699
+ var MAX_BEACON_BYTES = 60 * 1024;
9675
9700
  function randomId(prefix) {
9676
9701
  const cryptoRef = globalThis.crypto;
9677
9702
  if (cryptoRef == null ? void 0 : cryptoRef.randomUUID) return `${prefix}_${cryptoRef.randomUUID()}`;
9678
9703
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
9679
9704
  }
9680
- function readJson(storage, key, fallback) {
9681
- try {
9682
- const raw = storage.getItem(key);
9683
- return raw ? JSON.parse(raw) : fallback;
9684
- } catch (e) {
9685
- return fallback;
9686
- }
9687
- }
9688
9705
  function getStoredId(storage, key, prefix) {
9689
9706
  try {
9690
9707
  const existing = storage.getItem(key);
@@ -9696,6 +9713,64 @@ function getStoredId(storage, key, prefix) {
9696
9713
  return randomId(prefix);
9697
9714
  }
9698
9715
  }
9716
+ function safeBrowserStorage(name) {
9717
+ var _a;
9718
+ if (typeof window === "undefined") return null;
9719
+ try {
9720
+ return (_a = window[name]) != null ? _a : null;
9721
+ } catch (e) {
9722
+ return null;
9723
+ }
9724
+ }
9725
+ function isQueuedCaptureEvent(value) {
9726
+ if (!isRecord(value)) return false;
9727
+ const event = value.event;
9728
+ return isRecord(event) && typeof event.id === "string" && typeof event.type === "string" && typeof event.occurredAt === "string" && typeof value.attempts === "number" && typeof value.createdAt === "number";
9729
+ }
9730
+ function pruneQueuedEvents(queue, now = Date.now(), ttlMs = DEFAULT_QUEUE_TTL_MS) {
9731
+ const cutoff = now - ttlMs;
9732
+ return queue.filter((item) => item.createdAt >= cutoff);
9733
+ }
9734
+ function readPersistedCaptureQueue(storage, key) {
9735
+ if (!storage) return null;
9736
+ try {
9737
+ const raw = storage.getItem(key);
9738
+ if (!raw) return null;
9739
+ const parsed = JSON.parse(raw);
9740
+ if (typeof parsed.visitorId !== "string" || typeof parsed.clientSessionId !== "string") {
9741
+ return null;
9742
+ }
9743
+ const queue = Array.isArray(parsed.queue) ? pruneQueuedEvents(parsed.queue.filter(isQueuedCaptureEvent)) : [];
9744
+ return {
9745
+ visitorId: parsed.visitorId,
9746
+ clientSessionId: parsed.clientSessionId,
9747
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
9748
+ queue
9749
+ };
9750
+ } catch (e) {
9751
+ return null;
9752
+ }
9753
+ }
9754
+ function persistCaptureQueue(storage, runtime, queue) {
9755
+ if (!storage) return;
9756
+ try {
9757
+ const pruned = pruneQueuedEvents(queue).slice(-MAX_QUEUED_CAPTURE_EVENTS);
9758
+ if (pruned.length === 0) {
9759
+ storage.removeItem(runtime.queueKey);
9760
+ return;
9761
+ }
9762
+ storage.setItem(
9763
+ runtime.queueKey,
9764
+ JSON.stringify({
9765
+ visitorId: runtime.visitorId,
9766
+ clientSessionId: runtime.clientSessionId,
9767
+ updatedAt: Date.now(),
9768
+ queue: pruned
9769
+ })
9770
+ );
9771
+ } catch (e) {
9772
+ }
9773
+ }
9699
9774
  function isRecord(value) {
9700
9775
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
9701
9776
  }
@@ -9796,6 +9871,54 @@ function captureContext(capture, runtime) {
9796
9871
  screen: screenRef ? { width: screenRef.width, height: screenRef.height } : void 0
9797
9872
  }, customContext));
9798
9873
  }
9874
+ function captureUrl(capture) {
9875
+ var _a;
9876
+ const baseUrl = (_a = capture.baseUrl) != null ? _a : "https://getfounderhq.com";
9877
+ return `${baseUrl}/api/v1/journeys/${encodeURIComponent(
9878
+ capture.journeyId
9879
+ )}/capture`;
9880
+ }
9881
+ function byteLength(value) {
9882
+ try {
9883
+ return new TextEncoder().encode(value).byteLength;
9884
+ } catch (e) {
9885
+ return value.length;
9886
+ }
9887
+ }
9888
+ function safeSendBeacon() {
9889
+ if (typeof navigator === "undefined") return null;
9890
+ const beacon = navigator.sendBeacon;
9891
+ return typeof beacon === "function" ? beacon.bind(navigator) : null;
9892
+ }
9893
+ async function sendCaptureBatch(capture, runtime, batch, options = {}) {
9894
+ const body = JSON.stringify(__spreadProps(__spreadValues({}, options.preferBeacon ? { apiKey: capture.apiKey } : {}), {
9895
+ clientSessionId: runtime.clientSessionId,
9896
+ visitorId: runtime.visitorId,
9897
+ context: captureContext(capture, runtime),
9898
+ events: batch.map((item) => item.event)
9899
+ }));
9900
+ const url = captureUrl(capture);
9901
+ if (options.preferBeacon && byteLength(body) <= MAX_BEACON_BYTES) {
9902
+ const beacon = safeSendBeacon();
9903
+ if (beacon == null ? void 0 : beacon(url, body)) {
9904
+ return "queued";
9905
+ }
9906
+ }
9907
+ try {
9908
+ const response = await fetch(url, {
9909
+ method: "POST",
9910
+ headers: {
9911
+ Authorization: `Bearer ${capture.apiKey}`,
9912
+ "Content-Type": "application/json"
9913
+ },
9914
+ body,
9915
+ keepalive: batch.length <= 5 || options.preferBeacon === true
9916
+ });
9917
+ return response.ok ? "acknowledged" : "failed";
9918
+ } catch (e) {
9919
+ return "failed";
9920
+ }
9921
+ }
9799
9922
  function toJsonRecord(value) {
9800
9923
  return isRecord(value) ? value : {};
9801
9924
  }
@@ -9941,30 +10064,34 @@ function useJourneyCapture(params) {
9941
10064
  captureRef.current = params.capture;
9942
10065
  const persistQueue = React.useCallback(() => {
9943
10066
  const runtime = runtimeRef.current;
9944
- if (!runtime || typeof window === "undefined") return;
9945
- try {
9946
- sessionStorage.setItem(
9947
- runtime.queueKey,
9948
- JSON.stringify(queueRef.current)
9949
- );
9950
- } catch (e) {
9951
- }
10067
+ if (!runtime) return;
10068
+ persistCaptureQueue(
10069
+ safeBrowserStorage("localStorage"),
10070
+ runtime,
10071
+ queueRef.current
10072
+ );
9952
10073
  }, []);
9953
10074
  const ensureRuntime = React.useCallback((capture) => {
10075
+ var _a, _b;
9954
10076
  if (typeof window === "undefined") return null;
10077
+ const queueKey = `fhq_journey_queue:v2:${capture.apiKey.slice(
10078
+ 0,
10079
+ 16
10080
+ )}:${capture.journeyId}`;
9955
10081
  const existing = runtimeRef.current;
9956
- if ((existing == null ? void 0 : existing.journeyId) === capture.journeyId) return existing;
9957
- const visitorId = getStoredId(
9958
- localStorage,
9959
- "fhq_journey_visitor_id",
9960
- "fhqv"
9961
- );
9962
- const clientSessionId = getStoredId(
9963
- sessionStorage,
9964
- `fhq_journey_session:${capture.journeyId}`,
9965
- "fhqs"
9966
- );
9967
- const queueKey = `fhq_journey_queue:${capture.journeyId}:${clientSessionId}`;
10082
+ if ((existing == null ? void 0 : existing.journeyId) === capture.journeyId && existing.queueKey === queueKey) {
10083
+ return existing;
10084
+ }
10085
+ const local = safeBrowserStorage("localStorage");
10086
+ const session = safeBrowserStorage("sessionStorage");
10087
+ const persisted = readPersistedCaptureQueue(local, queueKey);
10088
+ const visitorId = (_a = persisted == null ? void 0 : persisted.visitorId) != null ? _a : local ? getStoredId(local, "fhq_journey_visitor_id", "fhqv") : randomId("fhqv");
10089
+ const sessionKey = `fhq_journey_session:${capture.journeyId}`;
10090
+ const clientSessionId = persisted && persisted.queue.length > 0 ? persisted.clientSessionId : session ? getStoredId(session, sessionKey, "fhqs") : randomId("fhqs");
10091
+ try {
10092
+ session == null ? void 0 : session.setItem(sessionKey, clientSessionId);
10093
+ } catch (e) {
10094
+ }
9968
10095
  const runtime = {
9969
10096
  journeyId: capture.journeyId,
9970
10097
  visitorId,
@@ -9972,51 +10099,46 @@ function useJourneyCapture(params) {
9972
10099
  queueKey
9973
10100
  };
9974
10101
  runtimeRef.current = runtime;
9975
- queueRef.current = readJson(
9976
- sessionStorage,
9977
- queueKey,
9978
- []
9979
- );
10102
+ queueRef.current = (_b = persisted == null ? void 0 : persisted.queue) != null ? _b : [];
9980
10103
  return runtime;
9981
10104
  }, []);
9982
- const flush = React.useCallback(async () => {
9983
- var _a, _b, _c;
10105
+ const flush = React.useCallback(async (options = {}) => {
10106
+ var _a, _b;
9984
10107
  const capture = captureRef.current;
9985
- if (!capture || !configRef.current || flushingRef.current) return;
10108
+ if (!capture || !configRef.current) return;
10109
+ if (!options.preferBeacon && flushingRef.current) return;
9986
10110
  const runtime = ensureRuntime(capture);
9987
10111
  if (!runtime || queueRef.current.length === 0) return;
9988
- flushingRef.current = true;
10112
+ queueRef.current = pruneQueuedEvents(queueRef.current);
9989
10113
  const batchSize = Math.max(1, (_a = capture.batchSize) != null ? _a : 10);
9990
10114
  const maxRetries = Math.max(1, (_b = capture.maxRetries) != null ? _b : 3);
9991
- const batch = queueRef.current.slice(0, batchSize);
9992
- const baseUrl = (_c = capture.baseUrl) != null ? _c : "https://getfounderhq.com";
10115
+ const batch = queueRef.current.slice(
10116
+ 0,
10117
+ options.preferBeacon ? Math.min(batchSize, 5) : batchSize
10118
+ );
10119
+ if (batch.length === 0) {
10120
+ persistQueue();
10121
+ return;
10122
+ }
10123
+ if (options.preferBeacon) {
10124
+ await sendCaptureBatch(capture, runtime, batch, options);
10125
+ persistQueue();
10126
+ return;
10127
+ }
10128
+ flushingRef.current = true;
9993
10129
  try {
9994
- const response = await fetch(
9995
- `${baseUrl}/api/v1/journeys/${encodeURIComponent(
9996
- capture.journeyId
9997
- )}/capture`,
9998
- {
9999
- method: "POST",
10000
- headers: {
10001
- Authorization: `Bearer ${capture.apiKey}`,
10002
- "Content-Type": "application/json"
10003
- },
10004
- body: JSON.stringify({
10005
- clientSessionId: runtime.clientSessionId,
10006
- visitorId: runtime.visitorId,
10007
- context: captureContext(capture, runtime),
10008
- events: batch.map((item) => item.event)
10009
- }),
10010
- keepalive: batch.length <= 5
10011
- }
10012
- );
10013
- if (!response.ok) throw new Error(`Capture failed: ${response.status}`);
10014
- queueRef.current = queueRef.current.slice(batch.length);
10015
- } catch (e) {
10016
- const failedIds = new Set(batch.map((item) => item.event.id));
10017
- queueRef.current = queueRef.current.map(
10018
- (item) => failedIds.has(item.event.id) ? __spreadProps(__spreadValues({}, item), { attempts: item.attempts + 1 }) : item
10019
- ).filter((item) => item.attempts < maxRetries);
10130
+ const result = await sendCaptureBatch(capture, runtime, batch);
10131
+ if (result === "acknowledged") {
10132
+ const sentIds = new Set(batch.map((item) => item.event.id));
10133
+ queueRef.current = queueRef.current.filter(
10134
+ (item) => !sentIds.has(item.event.id)
10135
+ );
10136
+ } else {
10137
+ const failedIds = new Set(batch.map((item) => item.event.id));
10138
+ queueRef.current = queueRef.current.map(
10139
+ (item) => failedIds.has(item.event.id) ? __spreadProps(__spreadValues({}, item), { attempts: item.attempts + 1 }) : item
10140
+ ).filter((item) => item.attempts < maxRetries);
10141
+ }
10020
10142
  } finally {
10021
10143
  persistQueue();
10022
10144
  flushingRef.current = false;
@@ -10032,15 +10154,18 @@ function useJourneyCapture(params) {
10032
10154
  Math.max(1e3, (_a = capture.flushIntervalMs) != null ? _a : 3e3)
10033
10155
  );
10034
10156
  const handleVisibility = () => {
10035
- if (document.visibilityState === "hidden") void flush();
10157
+ if (document.visibilityState === "hidden") {
10158
+ void flush({ preferBeacon: true });
10159
+ }
10036
10160
  };
10037
- window.addEventListener("beforeunload", persistQueue);
10161
+ const handlePageHide = () => void flush({ preferBeacon: true });
10038
10162
  document.addEventListener("visibilitychange", handleVisibility);
10163
+ window.addEventListener("pagehide", handlePageHide);
10039
10164
  void flush();
10040
10165
  return () => {
10041
10166
  window.clearInterval(interval);
10042
- window.removeEventListener("beforeunload", persistQueue);
10043
10167
  document.removeEventListener("visibilitychange", handleVisibility);
10168
+ window.removeEventListener("pagehide", handlePageHide);
10044
10169
  persistQueue();
10045
10170
  };
10046
10171
  }, [ensureRuntime, flush, persistQueue, params.capture]);
@@ -10056,11 +10181,17 @@ function useJourneyCapture(params) {
10056
10181
  sequenceRef.current += 1;
10057
10182
  queueRef.current.push({
10058
10183
  event: toCaptureEvent(config, event, sequenceRef.current, runtime),
10059
- attempts: 0
10184
+ attempts: 0,
10185
+ createdAt: Date.now()
10060
10186
  });
10187
+ queueRef.current = pruneQueuedEvents(queueRef.current).slice(
10188
+ -MAX_QUEUED_CAPTURE_EVENTS
10189
+ );
10061
10190
  persistQueue();
10062
10191
  const batchSize = Math.max(1, (_b = capture.batchSize) != null ? _b : 10);
10063
- if (queueRef.current.length >= batchSize) void flush();
10192
+ if (event.type === "complete" || queueRef.current.length >= batchSize) {
10193
+ void flush();
10194
+ }
10064
10195
  },
10065
10196
  [ensureRuntime, flush, persistQueue]
10066
10197
  );
package/dist/index.js CHANGED
@@ -2673,9 +2673,11 @@ function ButtonBlock({
2673
2673
  onGoToStep,
2674
2674
  onPurchase,
2675
2675
  onOpenDiscountCode,
2676
- disabled
2676
+ disabled,
2677
+ isLastStep
2677
2678
  }) {
2678
2679
  const handleClick = () => {
2680
+ var _a;
2679
2681
  if (!action) {
2680
2682
  onNext == null ? void 0 : onNext();
2681
2683
  return;
@@ -2695,6 +2697,9 @@ function ButtonBlock({
2695
2697
  }
2696
2698
  break;
2697
2699
  case "link":
2700
+ if ((_a = action.completeBeforeNavigate) != null ? _a : isLastStep) {
2701
+ onNext == null ? void 0 : onNext();
2702
+ }
2698
2703
  if (action.external) {
2699
2704
  window.open(action.url, "_blank", "noopener,noreferrer");
2700
2705
  } else {
@@ -4185,7 +4190,8 @@ function ColumnsBlock({
4185
4190
  answers,
4186
4191
  onNext,
4187
4192
  onBack,
4188
- onGoToStep
4193
+ onGoToStep,
4194
+ isLastStep
4189
4195
  }) {
4190
4196
  return /* @__PURE__ */ jsx("div", { className: "jy-columns-container", children: /* @__PURE__ */ jsx(
4191
4197
  "div",
@@ -4211,6 +4217,7 @@ function ColumnsBlock({
4211
4217
  onBack: onBack != null ? onBack : (() => {
4212
4218
  }),
4213
4219
  onGoToStep,
4220
+ isLastStep,
4214
4221
  wrapInScrollViewport: false
4215
4222
  }
4216
4223
  )
@@ -7886,7 +7893,11 @@ function HiddenBlockSlot({ block }) {
7886
7893
  return /* @__PURE__ */ jsx(
7887
7894
  "div",
7888
7895
  {
7889
- className: cn(widthClass, resolvedMaxWidth ? "mx-auto" : void 0, block.className),
7896
+ className: cn(
7897
+ widthClass,
7898
+ resolvedMaxWidth ? "mx-auto" : void 0,
7899
+ block.className
7900
+ ),
7890
7901
  style: __spreadProps(__spreadValues(__spreadValues({}, resolvedMaxWidth ? { maxWidth: resolvedMaxWidth } : null), block.style), {
7891
7902
  visibility: "hidden"
7892
7903
  }),
@@ -8069,7 +8080,7 @@ function resolveAppliedDiscountForDialog(answers, discountVariable, planId) {
8069
8080
  if (!planId) return answer.planId ? void 0 : answer;
8070
8081
  return discountAppliesToPlan(answer, planId) ? answer : void 0;
8071
8082
  }
8072
- function renderBlock(block, i, visibleIndexRef, answers, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, inputsValid) {
8083
+ function renderBlock(block, i, visibleIndexRef, answers, onNext, onBack, onGoToStep, onPurchase, onOpenDiscountCode, inputsValid, isLastStep) {
8073
8084
  var _a, _b;
8074
8085
  const conditionMet = !block.condition || evaluateCondition(block.condition, answers);
8075
8086
  const hasExitAnim = ((_a = block.exitAnimation) == null ? void 0 : _a.preset) && block.exitAnimation.preset !== "none";
@@ -8096,8 +8107,9 @@ function renderBlock(block, i, visibleIndexRef, answers, onNext, onBack, onGoToS
8096
8107
  onGoToStep,
8097
8108
  onPurchase,
8098
8109
  onOpenDiscountCode,
8099
- disabled: buttonDisabledForValidity(resolvedProps, inputsValid)
8100
- }) : block.type === "columns" ? __spreadProps(__spreadValues({}, resolvedProps), { answers, onNext, onBack, onGoToStep }) : block.type === "gravity_bin" ? __spreadProps(__spreadValues({}, resolvedProps), { answers }) : resolvedProps;
8110
+ disabled: buttonDisabledForValidity(resolvedProps, inputsValid),
8111
+ isLastStep
8112
+ }) : block.type === "columns" ? __spreadProps(__spreadValues({}, resolvedProps), { answers, onNext, onBack, onGoToStep, isLastStep }) : block.type === "gravity_bin" ? __spreadProps(__spreadValues({}, resolvedProps), { answers }) : resolvedProps;
8101
8113
  const idx = visibleIndexRef.current;
8102
8114
  visibleIndexRef.current++;
8103
8115
  return /* @__PURE__ */ jsx(
@@ -8119,6 +8131,7 @@ function BlockRenderer({
8119
8131
  onNext,
8120
8132
  onBack,
8121
8133
  onGoToStep,
8134
+ isLastStep,
8122
8135
  scrollViewportRef,
8123
8136
  wrapInScrollViewport = true
8124
8137
  }) {
@@ -8204,7 +8217,8 @@ function BlockRenderer({
8204
8217
  onGoToStep,
8205
8218
  onPurchase,
8206
8219
  onOpenDiscountCode,
8207
- inputsValid
8220
+ inputsValid,
8221
+ isLastStep
8208
8222
  );
8209
8223
  };
8210
8224
  if (!wrapInScrollViewport && stickyBlocks.length === 0) {
@@ -8227,7 +8241,11 @@ function BlockRenderer({
8227
8241
  "div",
8228
8242
  {
8229
8243
  className: layoutClasses(layout),
8230
- style: { gap: `${gap}rem`, maxWidth: layout == null ? void 0 : layout.maxWidth, width: "100%" },
8244
+ style: {
8245
+ gap: `${gap}rem`,
8246
+ maxWidth: layout == null ? void 0 : layout.maxWidth,
8247
+ width: "100%"
8248
+ },
8231
8249
  children: contentBlocks.map((block, i) => renderOne(block, i))
8232
8250
  }
8233
8251
  )
@@ -8238,7 +8256,11 @@ function BlockRenderer({
8238
8256
  ) }) : null
8239
8257
  ] });
8240
8258
  }
8241
- function InfoPageStep({ config, onNext }) {
8259
+ function InfoPageStep({
8260
+ config,
8261
+ onNext,
8262
+ isLastStep
8263
+ }) {
8242
8264
  var _a;
8243
8265
  const { answers } = useJourneyState();
8244
8266
  const { goBack, goToStep } = useJourneyActions();
@@ -8246,9 +8268,7 @@ function InfoPageStep({ config, onNext }) {
8246
8268
  const sortedTimelineEvents = useMemo(
8247
8269
  () => {
8248
8270
  var _a2, _b;
8249
- return [...(_b = (_a2 = config.scrollTimeline) == null ? void 0 : _a2.events) != null ? _b : []].sort(
8250
- (a, b) => a.at - b.at
8251
- );
8271
+ return [...(_b = (_a2 = config.scrollTimeline) == null ? void 0 : _a2.events) != null ? _b : []].sort((a, b) => a.at - b.at);
8252
8272
  },
8253
8273
  [(_a = config.scrollTimeline) == null ? void 0 : _a.events]
8254
8274
  );
@@ -8428,6 +8448,7 @@ function InfoPageStep({ config, onNext }) {
8428
8448
  onNext,
8429
8449
  onBack: goBack,
8430
8450
  onGoToStep: goToStep,
8451
+ isLastStep,
8431
8452
  scrollViewportRef
8432
8453
  }
8433
8454
  );
@@ -9295,7 +9316,7 @@ var STEP_REGISTRY = {
9295
9316
  function StepRenderer({ config }) {
9296
9317
  var _a, _b;
9297
9318
  const { answers } = useJourneyState();
9298
- const { setAnswer, goNext } = useJourneyActions();
9319
+ const { setAnswer, goNext, isLastStep } = useJourneyActions();
9299
9320
  const resolvedConfig = useMemo(() => {
9300
9321
  var _a2, _b2, _c, _d, _e, _f, _g, _h, _i, _j, _k;
9301
9322
  const hasTemplates = ((_a2 = config.preface) == null ? void 0 : _a2.includes("${")) || ((_b2 = config.question) == null ? void 0 : _b2.includes("${")) || ((_c = config.description) == null ? void 0 : _c.includes("${")) || ((_d = config.buttonText) == null ? void 0 : _d.includes("${")) || ((_e = config.footerText) == null ? void 0 : _e.includes("${")) || ((_g = (_f = config.swipeLabels) == null ? void 0 : _f.yes) == null ? void 0 : _g.includes("${")) || ((_i = (_h = config.swipeLabels) == null ? void 0 : _h.no) == null ? void 0 : _i.includes("${")) || ((_j = config.swipeCards) != null ? _j : []).some(
@@ -9348,7 +9369,8 @@ function StepRenderer({ config }) {
9348
9369
  }
9349
9370
  setAnswer(answerKey, answer);
9350
9371
  },
9351
- onNext: () => goNext()
9372
+ onNext: () => goNext(),
9373
+ isLastStep: isLastStep()
9352
9374
  }
9353
9375
  );
9354
9376
  }
@@ -9646,20 +9668,15 @@ function JourneyShell({ className, theme } = {}) {
9646
9668
  ] });
9647
9669
  }
9648
9670
  var JOURNEY_LIBRARY_NAME = "@founderhq/journeys";
9649
- var JOURNEY_LIBRARY_VERSION = "0.4.0";
9671
+ var JOURNEY_LIBRARY_VERSION = "0.4.2";
9672
+ var DEFAULT_QUEUE_TTL_MS = 24 * 60 * 60 * 1e3;
9673
+ var MAX_QUEUED_CAPTURE_EVENTS = 100;
9674
+ var MAX_BEACON_BYTES = 60 * 1024;
9650
9675
  function randomId(prefix) {
9651
9676
  const cryptoRef = globalThis.crypto;
9652
9677
  if (cryptoRef == null ? void 0 : cryptoRef.randomUUID) return `${prefix}_${cryptoRef.randomUUID()}`;
9653
9678
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
9654
9679
  }
9655
- function readJson(storage, key, fallback) {
9656
- try {
9657
- const raw = storage.getItem(key);
9658
- return raw ? JSON.parse(raw) : fallback;
9659
- } catch (e) {
9660
- return fallback;
9661
- }
9662
- }
9663
9680
  function getStoredId(storage, key, prefix) {
9664
9681
  try {
9665
9682
  const existing = storage.getItem(key);
@@ -9671,6 +9688,64 @@ function getStoredId(storage, key, prefix) {
9671
9688
  return randomId(prefix);
9672
9689
  }
9673
9690
  }
9691
+ function safeBrowserStorage(name) {
9692
+ var _a;
9693
+ if (typeof window === "undefined") return null;
9694
+ try {
9695
+ return (_a = window[name]) != null ? _a : null;
9696
+ } catch (e) {
9697
+ return null;
9698
+ }
9699
+ }
9700
+ function isQueuedCaptureEvent(value) {
9701
+ if (!isRecord(value)) return false;
9702
+ const event = value.event;
9703
+ return isRecord(event) && typeof event.id === "string" && typeof event.type === "string" && typeof event.occurredAt === "string" && typeof value.attempts === "number" && typeof value.createdAt === "number";
9704
+ }
9705
+ function pruneQueuedEvents(queue, now = Date.now(), ttlMs = DEFAULT_QUEUE_TTL_MS) {
9706
+ const cutoff = now - ttlMs;
9707
+ return queue.filter((item) => item.createdAt >= cutoff);
9708
+ }
9709
+ function readPersistedCaptureQueue(storage, key) {
9710
+ if (!storage) return null;
9711
+ try {
9712
+ const raw = storage.getItem(key);
9713
+ if (!raw) return null;
9714
+ const parsed = JSON.parse(raw);
9715
+ if (typeof parsed.visitorId !== "string" || typeof parsed.clientSessionId !== "string") {
9716
+ return null;
9717
+ }
9718
+ const queue = Array.isArray(parsed.queue) ? pruneQueuedEvents(parsed.queue.filter(isQueuedCaptureEvent)) : [];
9719
+ return {
9720
+ visitorId: parsed.visitorId,
9721
+ clientSessionId: parsed.clientSessionId,
9722
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
9723
+ queue
9724
+ };
9725
+ } catch (e) {
9726
+ return null;
9727
+ }
9728
+ }
9729
+ function persistCaptureQueue(storage, runtime, queue) {
9730
+ if (!storage) return;
9731
+ try {
9732
+ const pruned = pruneQueuedEvents(queue).slice(-MAX_QUEUED_CAPTURE_EVENTS);
9733
+ if (pruned.length === 0) {
9734
+ storage.removeItem(runtime.queueKey);
9735
+ return;
9736
+ }
9737
+ storage.setItem(
9738
+ runtime.queueKey,
9739
+ JSON.stringify({
9740
+ visitorId: runtime.visitorId,
9741
+ clientSessionId: runtime.clientSessionId,
9742
+ updatedAt: Date.now(),
9743
+ queue: pruned
9744
+ })
9745
+ );
9746
+ } catch (e) {
9747
+ }
9748
+ }
9674
9749
  function isRecord(value) {
9675
9750
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
9676
9751
  }
@@ -9771,6 +9846,54 @@ function captureContext(capture, runtime) {
9771
9846
  screen: screenRef ? { width: screenRef.width, height: screenRef.height } : void 0
9772
9847
  }, customContext));
9773
9848
  }
9849
+ function captureUrl(capture) {
9850
+ var _a;
9851
+ const baseUrl = (_a = capture.baseUrl) != null ? _a : "https://getfounderhq.com";
9852
+ return `${baseUrl}/api/v1/journeys/${encodeURIComponent(
9853
+ capture.journeyId
9854
+ )}/capture`;
9855
+ }
9856
+ function byteLength(value) {
9857
+ try {
9858
+ return new TextEncoder().encode(value).byteLength;
9859
+ } catch (e) {
9860
+ return value.length;
9861
+ }
9862
+ }
9863
+ function safeSendBeacon() {
9864
+ if (typeof navigator === "undefined") return null;
9865
+ const beacon = navigator.sendBeacon;
9866
+ return typeof beacon === "function" ? beacon.bind(navigator) : null;
9867
+ }
9868
+ async function sendCaptureBatch(capture, runtime, batch, options = {}) {
9869
+ const body = JSON.stringify(__spreadProps(__spreadValues({}, options.preferBeacon ? { apiKey: capture.apiKey } : {}), {
9870
+ clientSessionId: runtime.clientSessionId,
9871
+ visitorId: runtime.visitorId,
9872
+ context: captureContext(capture, runtime),
9873
+ events: batch.map((item) => item.event)
9874
+ }));
9875
+ const url = captureUrl(capture);
9876
+ if (options.preferBeacon && byteLength(body) <= MAX_BEACON_BYTES) {
9877
+ const beacon = safeSendBeacon();
9878
+ if (beacon == null ? void 0 : beacon(url, body)) {
9879
+ return "queued";
9880
+ }
9881
+ }
9882
+ try {
9883
+ const response = await fetch(url, {
9884
+ method: "POST",
9885
+ headers: {
9886
+ Authorization: `Bearer ${capture.apiKey}`,
9887
+ "Content-Type": "application/json"
9888
+ },
9889
+ body,
9890
+ keepalive: batch.length <= 5 || options.preferBeacon === true
9891
+ });
9892
+ return response.ok ? "acknowledged" : "failed";
9893
+ } catch (e) {
9894
+ return "failed";
9895
+ }
9896
+ }
9774
9897
  function toJsonRecord(value) {
9775
9898
  return isRecord(value) ? value : {};
9776
9899
  }
@@ -9916,30 +10039,34 @@ function useJourneyCapture(params) {
9916
10039
  captureRef.current = params.capture;
9917
10040
  const persistQueue = useCallback(() => {
9918
10041
  const runtime = runtimeRef.current;
9919
- if (!runtime || typeof window === "undefined") return;
9920
- try {
9921
- sessionStorage.setItem(
9922
- runtime.queueKey,
9923
- JSON.stringify(queueRef.current)
9924
- );
9925
- } catch (e) {
9926
- }
10042
+ if (!runtime) return;
10043
+ persistCaptureQueue(
10044
+ safeBrowserStorage("localStorage"),
10045
+ runtime,
10046
+ queueRef.current
10047
+ );
9927
10048
  }, []);
9928
10049
  const ensureRuntime = useCallback((capture) => {
10050
+ var _a, _b;
9929
10051
  if (typeof window === "undefined") return null;
10052
+ const queueKey = `fhq_journey_queue:v2:${capture.apiKey.slice(
10053
+ 0,
10054
+ 16
10055
+ )}:${capture.journeyId}`;
9930
10056
  const existing = runtimeRef.current;
9931
- if ((existing == null ? void 0 : existing.journeyId) === capture.journeyId) return existing;
9932
- const visitorId = getStoredId(
9933
- localStorage,
9934
- "fhq_journey_visitor_id",
9935
- "fhqv"
9936
- );
9937
- const clientSessionId = getStoredId(
9938
- sessionStorage,
9939
- `fhq_journey_session:${capture.journeyId}`,
9940
- "fhqs"
9941
- );
9942
- const queueKey = `fhq_journey_queue:${capture.journeyId}:${clientSessionId}`;
10057
+ if ((existing == null ? void 0 : existing.journeyId) === capture.journeyId && existing.queueKey === queueKey) {
10058
+ return existing;
10059
+ }
10060
+ const local = safeBrowserStorage("localStorage");
10061
+ const session = safeBrowserStorage("sessionStorage");
10062
+ const persisted = readPersistedCaptureQueue(local, queueKey);
10063
+ const visitorId = (_a = persisted == null ? void 0 : persisted.visitorId) != null ? _a : local ? getStoredId(local, "fhq_journey_visitor_id", "fhqv") : randomId("fhqv");
10064
+ const sessionKey = `fhq_journey_session:${capture.journeyId}`;
10065
+ const clientSessionId = persisted && persisted.queue.length > 0 ? persisted.clientSessionId : session ? getStoredId(session, sessionKey, "fhqs") : randomId("fhqs");
10066
+ try {
10067
+ session == null ? void 0 : session.setItem(sessionKey, clientSessionId);
10068
+ } catch (e) {
10069
+ }
9943
10070
  const runtime = {
9944
10071
  journeyId: capture.journeyId,
9945
10072
  visitorId,
@@ -9947,51 +10074,46 @@ function useJourneyCapture(params) {
9947
10074
  queueKey
9948
10075
  };
9949
10076
  runtimeRef.current = runtime;
9950
- queueRef.current = readJson(
9951
- sessionStorage,
9952
- queueKey,
9953
- []
9954
- );
10077
+ queueRef.current = (_b = persisted == null ? void 0 : persisted.queue) != null ? _b : [];
9955
10078
  return runtime;
9956
10079
  }, []);
9957
- const flush = useCallback(async () => {
9958
- var _a, _b, _c;
10080
+ const flush = useCallback(async (options = {}) => {
10081
+ var _a, _b;
9959
10082
  const capture = captureRef.current;
9960
- if (!capture || !configRef.current || flushingRef.current) return;
10083
+ if (!capture || !configRef.current) return;
10084
+ if (!options.preferBeacon && flushingRef.current) return;
9961
10085
  const runtime = ensureRuntime(capture);
9962
10086
  if (!runtime || queueRef.current.length === 0) return;
9963
- flushingRef.current = true;
10087
+ queueRef.current = pruneQueuedEvents(queueRef.current);
9964
10088
  const batchSize = Math.max(1, (_a = capture.batchSize) != null ? _a : 10);
9965
10089
  const maxRetries = Math.max(1, (_b = capture.maxRetries) != null ? _b : 3);
9966
- const batch = queueRef.current.slice(0, batchSize);
9967
- const baseUrl = (_c = capture.baseUrl) != null ? _c : "https://getfounderhq.com";
10090
+ const batch = queueRef.current.slice(
10091
+ 0,
10092
+ options.preferBeacon ? Math.min(batchSize, 5) : batchSize
10093
+ );
10094
+ if (batch.length === 0) {
10095
+ persistQueue();
10096
+ return;
10097
+ }
10098
+ if (options.preferBeacon) {
10099
+ await sendCaptureBatch(capture, runtime, batch, options);
10100
+ persistQueue();
10101
+ return;
10102
+ }
10103
+ flushingRef.current = true;
9968
10104
  try {
9969
- const response = await fetch(
9970
- `${baseUrl}/api/v1/journeys/${encodeURIComponent(
9971
- capture.journeyId
9972
- )}/capture`,
9973
- {
9974
- method: "POST",
9975
- headers: {
9976
- Authorization: `Bearer ${capture.apiKey}`,
9977
- "Content-Type": "application/json"
9978
- },
9979
- body: JSON.stringify({
9980
- clientSessionId: runtime.clientSessionId,
9981
- visitorId: runtime.visitorId,
9982
- context: captureContext(capture, runtime),
9983
- events: batch.map((item) => item.event)
9984
- }),
9985
- keepalive: batch.length <= 5
9986
- }
9987
- );
9988
- if (!response.ok) throw new Error(`Capture failed: ${response.status}`);
9989
- queueRef.current = queueRef.current.slice(batch.length);
9990
- } catch (e) {
9991
- const failedIds = new Set(batch.map((item) => item.event.id));
9992
- queueRef.current = queueRef.current.map(
9993
- (item) => failedIds.has(item.event.id) ? __spreadProps(__spreadValues({}, item), { attempts: item.attempts + 1 }) : item
9994
- ).filter((item) => item.attempts < maxRetries);
10105
+ const result = await sendCaptureBatch(capture, runtime, batch);
10106
+ if (result === "acknowledged") {
10107
+ const sentIds = new Set(batch.map((item) => item.event.id));
10108
+ queueRef.current = queueRef.current.filter(
10109
+ (item) => !sentIds.has(item.event.id)
10110
+ );
10111
+ } else {
10112
+ const failedIds = new Set(batch.map((item) => item.event.id));
10113
+ queueRef.current = queueRef.current.map(
10114
+ (item) => failedIds.has(item.event.id) ? __spreadProps(__spreadValues({}, item), { attempts: item.attempts + 1 }) : item
10115
+ ).filter((item) => item.attempts < maxRetries);
10116
+ }
9995
10117
  } finally {
9996
10118
  persistQueue();
9997
10119
  flushingRef.current = false;
@@ -10007,15 +10129,18 @@ function useJourneyCapture(params) {
10007
10129
  Math.max(1e3, (_a = capture.flushIntervalMs) != null ? _a : 3e3)
10008
10130
  );
10009
10131
  const handleVisibility = () => {
10010
- if (document.visibilityState === "hidden") void flush();
10132
+ if (document.visibilityState === "hidden") {
10133
+ void flush({ preferBeacon: true });
10134
+ }
10011
10135
  };
10012
- window.addEventListener("beforeunload", persistQueue);
10136
+ const handlePageHide = () => void flush({ preferBeacon: true });
10013
10137
  document.addEventListener("visibilitychange", handleVisibility);
10138
+ window.addEventListener("pagehide", handlePageHide);
10014
10139
  void flush();
10015
10140
  return () => {
10016
10141
  window.clearInterval(interval);
10017
- window.removeEventListener("beforeunload", persistQueue);
10018
10142
  document.removeEventListener("visibilitychange", handleVisibility);
10143
+ window.removeEventListener("pagehide", handlePageHide);
10019
10144
  persistQueue();
10020
10145
  };
10021
10146
  }, [ensureRuntime, flush, persistQueue, params.capture]);
@@ -10031,11 +10156,17 @@ function useJourneyCapture(params) {
10031
10156
  sequenceRef.current += 1;
10032
10157
  queueRef.current.push({
10033
10158
  event: toCaptureEvent(config, event, sequenceRef.current, runtime),
10034
- attempts: 0
10159
+ attempts: 0,
10160
+ createdAt: Date.now()
10035
10161
  });
10162
+ queueRef.current = pruneQueuedEvents(queueRef.current).slice(
10163
+ -MAX_QUEUED_CAPTURE_EVENTS
10164
+ );
10036
10165
  persistQueue();
10037
10166
  const batchSize = Math.max(1, (_b = capture.batchSize) != null ? _b : 10);
10038
- if (queueRef.current.length >= batchSize) void flush();
10167
+ if (event.type === "complete" || queueRef.current.length >= batchSize) {
10168
+ void flush();
10169
+ }
10039
10170
  },
10040
10171
  [ensureRuntime, flush, persistQueue]
10041
10172
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@founderhq/journeys",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Config-driven interactive journey/questionnaire engine for React",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",