@funnelsgrove/runtime 0.1.2 → 0.1.4

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.
@@ -38,6 +38,7 @@ export declare const RUNTIME_ENV_KEYS: {
38
38
  readonly claimbeeMonthlyPriceId: readonly ["NEXT_PUBLIC_CLAIMBEE_MONTHLY_PRICE_ID", "CLAIMBEE_MONTHLY_PRICE_ID"];
39
39
  readonly claimbeeYearlyPriceId: readonly ["NEXT_PUBLIC_CLAIMBEE_YEARLY_PRICE_ID", "CLAIMBEE_YEARLY_PRICE_ID"];
40
40
  };
41
+ export declare const resolveRuntimeSdkApiBaseUrl: (value: string) => string;
41
42
  export type RuntimeEnvConfig = {
42
43
  readonly [Key in keyof typeof RUNTIME_ENV_KEYS]: string | undefined;
43
44
  };
@@ -60,6 +60,20 @@ export const RUNTIME_ENV_KEYS = {
60
60
  claimbeeMonthlyPriceId: ['NEXT_PUBLIC_CLAIMBEE_MONTHLY_PRICE_ID', 'CLAIMBEE_MONTHLY_PRICE_ID'],
61
61
  claimbeeYearlyPriceId: ['NEXT_PUBLIC_CLAIMBEE_YEARLY_PRICE_ID', 'CLAIMBEE_YEARLY_PRICE_ID'],
62
62
  };
63
+ const FUNNELSGROVE_CONTROL_PLANE_API_BASE_URL = 'https://api.funnelsgrove.com';
64
+ const FUNNELSGROVE_SDK_API_BASE_URL = 'https://sdk-api.funnelsgrove.com';
65
+ const trimTrailingSlash = (value) => {
66
+ return value.trim().replace(/\/+$/, '');
67
+ };
68
+ export const resolveRuntimeSdkApiBaseUrl = (value) => {
69
+ const normalized = trimTrailingSlash(value);
70
+ if (!normalized) {
71
+ return FUNNELSGROVE_SDK_API_BASE_URL;
72
+ }
73
+ return normalized === FUNNELSGROVE_CONTROL_PLANE_API_BASE_URL
74
+ ? FUNNELSGROVE_SDK_API_BASE_URL
75
+ : normalized;
76
+ };
63
77
  const RUNTIME_ENV_VALUES = {
64
78
  APP_DEALS_API_BASE_URL: readRuntimeEnv(() => process.env.APP_DEALS_API_BASE_URL),
65
79
  FUNNEL_ID: readRuntimeEnv(() => process.env.FUNNEL_ID),
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;
@@ -74,6 +75,12 @@ declare class ApiService {
74
75
  updateUser(user: AppUser, options?: {
75
76
  attribution?: FunnelUserAttribution;
76
77
  }): Promise<AppUser>;
78
+ syncUrlUserAttributes(input: {
79
+ user: AppUser;
80
+ attributes?: FunnelUserAnswers;
81
+ urlAttributes?: UrlUserAttributes | null;
82
+ attribution?: FunnelUserAttribution;
83
+ }): Promise<AppUser | null>;
77
84
  uploadTempPhoto(input: {
78
85
  file: File;
79
86
  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,24 @@ 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
+ if (!hasUrlUserAttributes(input.urlAttributes)) {
332
+ return null;
333
+ }
334
+ const nextUser = applyUrlUserAttributesToUser({
335
+ user: input.user,
336
+ attributes: input.attributes,
337
+ urlAttributes: input.urlAttributes,
338
+ });
339
+ const updatedUser = await this.updateUser(nextUser, {
340
+ attribution: input.attribution,
341
+ });
342
+ return applyUrlUserAttributesToUser({
343
+ user: updatedUser,
344
+ attributes: nextUser.attributes,
345
+ urlAttributes: input.urlAttributes,
346
+ });
347
+ }
329
348
  async uploadTempPhoto(input) {
330
349
  const userId = persistUserId(input.userId || this.getOrCreateClientUserId(), FUNNEL_ID);
331
350
  const publishableKey = getFunnelSdkPublishableKey();
@@ -1,4 +1,5 @@
1
1
  import { RUNTIME_PUBLIC_DEFAULTS, runtimePublicConfig } from './public-env.js';
2
+ import { resolveRuntimeSdkApiBaseUrl } from '../config/env.config.js';
2
3
  const trimTrailingSlash = (value) => {
3
4
  return value.replace(/\/+$/, '');
4
5
  };
@@ -16,7 +17,7 @@ const isPreviewFrameRuntime = () => {
16
17
  return false;
17
18
  }
18
19
  };
19
- export const APP_DEALS_API_BASE_URL = trimTrailingSlash(runtimePublicConfig.apiBaseUrl);
20
+ export const APP_DEALS_API_BASE_URL = trimTrailingSlash(resolveRuntimeSdkApiBaseUrl(runtimePublicConfig.apiBaseUrl));
20
21
  export const FUNNEL_SDK_PUBLISHABLE_KEY = runtimePublicConfig.funnelSdkPublishableKey;
21
22
  export const FUNNEL_ID = runtimePublicConfig.funnelId;
22
23
  export const FUNNEL_VERSION_ID = runtimePublicConfig.funnelVersionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",