@funnelsgrove/runtime 0.1.3 → 0.1.5

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/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export * from './runtime/preview-bridge.js';
16
16
  export * from './runtime/preview-definition-overrides.js';
17
17
  export * from './runtime/route-resolver.js';
18
18
  export * from './runtime/subscription-handoff.js';
19
+ export * from './runtime/url-user-attributes.js';
19
20
  export * from './services/api.service.js';
20
21
  export * from './services/funnel-state.service.js';
21
22
  export * from './services/logger.js';
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export * from './runtime/preview-bridge.js';
16
16
  export * from './runtime/preview-definition-overrides.js';
17
17
  export * from './runtime/route-resolver.js';
18
18
  export * from './runtime/subscription-handoff.js';
19
+ export * from './runtime/url-user-attributes.js';
19
20
  export * from './services/api.service.js';
20
21
  export * from './services/funnel-state.service.js';
21
22
  export * from './services/logger.js';
@@ -0,0 +1,17 @@
1
+ import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
2
+ export type UrlUserAttributes = {
3
+ email: string | null;
4
+ };
5
+ export type UrlUserAttributeUser = {
6
+ email: string;
7
+ attributes?: FunnelUserAnswers;
8
+ document?: Record<string, unknown>;
9
+ };
10
+ export declare function normalizeUrlUserEmail(value: string | null | undefined): string | null;
11
+ export declare function resolveUrlUserAttributes(search?: string): UrlUserAttributes;
12
+ export declare function hasUrlUserAttributes(urlAttributes: UrlUserAttributes | null | undefined): boolean;
13
+ export declare function applyUrlUserAttributesToUser<TUser extends UrlUserAttributeUser>(input: {
14
+ user: TUser;
15
+ attributes?: FunnelUserAnswers;
16
+ urlAttributes?: UrlUserAttributes | null;
17
+ }): TUser;
@@ -0,0 +1,38 @@
1
+ const EMAIL_QUERY_KEY = 'email';
2
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3
+ export function normalizeUrlUserEmail(value) {
4
+ const email = value === null || value === void 0 ? void 0 : value.trim();
5
+ if (!email || !EMAIL_PATTERN.test(email)) {
6
+ return null;
7
+ }
8
+ return email;
9
+ }
10
+ export function resolveUrlUserAttributes(search) {
11
+ if (typeof window === 'undefined' && typeof search !== 'string') {
12
+ return {
13
+ email: null,
14
+ };
15
+ }
16
+ try {
17
+ const params = new URLSearchParams(search !== null && search !== void 0 ? search : window.location.search);
18
+ return {
19
+ email: normalizeUrlUserEmail(params.get(EMAIL_QUERY_KEY)),
20
+ };
21
+ }
22
+ catch (_a) {
23
+ return {
24
+ email: null,
25
+ };
26
+ }
27
+ }
28
+ export function hasUrlUserAttributes(urlAttributes) {
29
+ return Boolean(urlAttributes === null || urlAttributes === void 0 ? void 0 : urlAttributes.email);
30
+ }
31
+ export function applyUrlUserAttributesToUser(input) {
32
+ var _a, _b, _c, _d;
33
+ const email = (_a = input.urlAttributes) === null || _a === void 0 ? void 0 : _a.email;
34
+ if (!email) {
35
+ return input.user;
36
+ }
37
+ return Object.assign(Object.assign({}, input.user), { email, attributes: Object.assign(Object.assign({}, ((_b = input.attributes) !== null && _b !== void 0 ? _b : {})), ((_c = input.user.attributes) !== null && _c !== void 0 ? _c : {})), document: (_d = input.user.document) !== null && _d !== void 0 ? _d : {} });
38
+ }
@@ -12,6 +12,7 @@ import { collectCurrentFunnelAttribution } from './funnel-attribution.js';
12
12
  import { bootstrapPostHog, getPostHog, identifyPostHog, isPostHogReady, resolveExperimentVariant, } from './posthog-flags.js';
13
13
  import { isEditorEnabled, useRuntimeMode } from '../services/runtime-mode.service.js';
14
14
  import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
15
+ const FIRST_STEP_ENGAGEMENT_THRESHOLD_MS = 1000;
15
16
  const buildFeatureFlagProperties = (featureFlags) => {
16
17
  return Object.fromEntries(Object.entries(featureFlags).map(([key, value]) => [`$feature/${key}`, value]));
17
18
  };
@@ -108,6 +109,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
108
109
  const postHogFeatureFlagsRef = useRef({});
109
110
  const prevStepIdRef = useRef(null);
110
111
  const stepStartedAtByIdRef = useRef({});
112
+ const engagedStepIdsRef = useRef(new Set());
111
113
  const currentUserIdRef = useRef(user.id);
112
114
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
113
115
  const activeStepMeta = stepById[safeActiveStepId];
@@ -312,6 +314,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
312
314
  choices,
313
315
  };
314
316
  const startedAt = stepStartedAtByIdRef.current[meta.id] || record.completedAt;
317
+ const durationMs = Math.max(0, new Date(record.completedAt).getTime() - new Date(startedAt).getTime());
315
318
  dispatchWindowCustomEvent('funnel:step-completed', record);
316
319
  if (!isPreviewRuntime) {
317
320
  apiService.trackStepCompleted({
@@ -322,7 +325,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
322
325
  endedAt: record.completedAt,
323
326
  selected: record.choices,
324
327
  });
325
- capturePostHogStepEvent('step_completed', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: record.stepId, step_name: record.stepName, started_at: startedAt, ended_at: record.completedAt, selected: record.choices }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
328
+ capturePostHogStepEvent('step_completed', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: record.stepId, step_name: record.stepName, started_at: startedAt, ended_at: record.completedAt, duration_ms: Number.isFinite(durationMs) ? durationMs : undefined, selected: record.choices }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
326
329
  }
327
330
  setUser((prev) => {
328
331
  var _a, _b;
@@ -363,22 +366,53 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
363
366
  recordStepCompletion(prevId);
364
367
  }
365
368
  const startedAt = new Date().toISOString();
369
+ const stepName = (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId;
366
370
  stepStartedAtByIdRef.current[safeActiveStepId] = startedAt;
367
371
  if (!isPreviewRuntime) {
368
372
  apiService.trackStepStarted({
369
373
  userId: currentUserIdRef.current,
370
374
  stepId: safeActiveStepId,
371
- stepName: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId,
375
+ stepName,
372
376
  startedAt,
373
377
  });
374
- capturePostHogStepEvent('step_started', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: safeActiveStepId, step_name: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId, started_at: startedAt }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
378
+ capturePostHogStepEvent('step_started', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: safeActiveStepId, step_name: stepName, started_at: startedAt }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
375
379
  }
376
380
  attributesAtStepStart.current = Object.assign({}, attributesRef.current);
377
381
  prevStepIdRef.current = safeActiveStepId;
382
+ if (isPreviewRuntime ||
383
+ safeActiveStepId !== defaultStepId ||
384
+ engagedStepIdsRef.current.has(safeActiveStepId)) {
385
+ return;
386
+ }
387
+ const engagedStepId = safeActiveStepId;
388
+ const engagedStartedAt = startedAt;
389
+ const engagedStepName = stepName;
390
+ const timer = window.setTimeout(() => {
391
+ if (prevStepIdRef.current !== engagedStepId || engagedStepIdsRef.current.has(engagedStepId)) {
392
+ return;
393
+ }
394
+ engagedStepIdsRef.current.add(engagedStepId);
395
+ const engagedAt = new Date().toISOString();
396
+ apiService.trackFunnelEvent({
397
+ userId: currentUserIdRef.current,
398
+ eventType: 'step_engaged',
399
+ stepId: engagedStepId,
400
+ stepName: engagedStepName,
401
+ startedAt: engagedStartedAt,
402
+ endedAt: engagedAt,
403
+ metadata: {
404
+ durationMs: FIRST_STEP_ENGAGEMENT_THRESHOLD_MS,
405
+ engagementThresholdMs: FIRST_STEP_ENGAGEMENT_THRESHOLD_MS,
406
+ },
407
+ });
408
+ capturePostHogStepEvent('step_engaged', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: engagedStepId, step_name: engagedStepName, started_at: engagedStartedAt, ended_at: engagedAt, duration_ms: FIRST_STEP_ENGAGEMENT_THRESHOLD_MS, engagement_threshold_ms: FIRST_STEP_ENGAGEMENT_THRESHOLD_MS }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
409
+ }, FIRST_STEP_ENGAGEMENT_THRESHOLD_MS);
410
+ return () => window.clearTimeout(timer);
378
411
  }, [
379
412
  activeStepId,
380
413
  activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name,
381
414
  activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title,
415
+ defaultStepId,
382
416
  isPreviewRuntime,
383
417
  recordStepCompletion,
384
418
  renderSuspended,
@@ -1,5 +1,6 @@
1
1
  import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
2
2
  import { type FunnelUserAttribution } from '../runtime/funnel-attribution.js';
3
+ import { type UrlUserAttributes } from '../runtime/url-user-attributes.js';
3
4
  export type AppUser = {
4
5
  id: string;
5
6
  name: string;
@@ -7,6 +8,9 @@ export type AppUser = {
7
8
  attributes: FunnelUserAnswers;
8
9
  document: Record<string, unknown>;
9
10
  };
11
+ type AppUserInput = Omit<AppUser, 'document'> & {
12
+ document?: Record<string, unknown>;
13
+ };
10
14
  export type FunnelEvent = {
11
15
  userId: string;
12
16
  eventType: string;
@@ -74,6 +78,12 @@ declare class ApiService {
74
78
  updateUser(user: AppUser, options?: {
75
79
  attribution?: FunnelUserAttribution;
76
80
  }): Promise<AppUser>;
81
+ syncUrlUserAttributes(input: {
82
+ user: AppUserInput;
83
+ attributes?: FunnelUserAnswers;
84
+ urlAttributes?: UrlUserAttributes | null;
85
+ attribution?: FunnelUserAttribution;
86
+ }): Promise<AppUser | null>;
77
87
  uploadTempPhoto(input: {
78
88
  file: File;
79
89
  userId?: string;
@@ -1,5 +1,6 @@
1
1
  import { buildMainApiUrl, buildSdkHeaders, FUNNEL_ID, FUNNEL_VERSION_ID, getFunnelSdkPublishableKey, } from './runtime-api.config.js';
2
2
  import { mergeFunnelUserAttribution, } from '../runtime/funnel-attribution.js';
3
+ import { applyUrlUserAttributesToUser, hasUrlUserAttributes, } from '../runtime/url-user-attributes.js';
3
4
  const DEFAULT_USER_ID_STORAGE_KEY = 'funnel:user-id';
4
5
  const LOCATION_USER_ID_QUERY_KEYS = ['user_id'];
5
6
  const LOCATION_STRIPE_CUSTOMER_ID_QUERY_KEYS = ['stripe_customer_id'];
@@ -326,6 +327,25 @@ class ApiService {
326
327
  fallbackDocument: withMergedAttributionDocument(user.document, options === null || options === void 0 ? void 0 : options.attribution),
327
328
  });
328
329
  }
330
+ async syncUrlUserAttributes(input) {
331
+ var _a;
332
+ if (!hasUrlUserAttributes(input.urlAttributes)) {
333
+ return null;
334
+ }
335
+ const nextUser = applyUrlUserAttributesToUser({
336
+ user: input.user,
337
+ attributes: input.attributes,
338
+ urlAttributes: input.urlAttributes,
339
+ });
340
+ const updatedUser = await this.updateUser(Object.assign(Object.assign({}, nextUser), { document: (_a = nextUser.document) !== null && _a !== void 0 ? _a : {} }), {
341
+ attribution: input.attribution,
342
+ });
343
+ return applyUrlUserAttributesToUser({
344
+ user: updatedUser,
345
+ attributes: nextUser.attributes,
346
+ urlAttributes: input.urlAttributes,
347
+ });
348
+ }
329
349
  async uploadTempPhoto(input) {
330
350
  const userId = persistUserId(input.userId || this.getOrCreateClientUserId(), FUNNEL_ID);
331
351
  const publishableKey = getFunnelSdkPublishableKey();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",