@funnelsgrove/runtime 0.1.17 → 0.1.19

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.
@@ -5,6 +5,7 @@ export declare const BUILDER_PREVIEW_RUNTIME_MODE_CHANGED = "builder.preview.run
5
5
  export declare const BUILDER_PREVIEW_DEFINITION_PATCH = "builder.preview.definitionPatch";
6
6
  export declare const BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED = "builder.preview.variableValuesChanged";
7
7
  export declare const BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED = "builder.preview.paywallPlansChanged";
8
+ export declare const BUILDER_PREVIEW_THEME_CHANGED = "builder.preview.themeChanged";
8
9
  export type BuilderPreviewReadyMessage = {
9
10
  type: typeof BUILDER_PREVIEW_READY;
10
11
  stepId: string;
@@ -75,3 +76,7 @@ export type BuilderPreviewPaywallPlansChangedMessage = {
75
76
  stepId: string;
76
77
  plans: BuilderPreviewPaywallPlan[];
77
78
  };
79
+ export type BuilderPreviewThemeChangedMessage = {
80
+ type: typeof BUILDER_PREVIEW_THEME_CHANGED;
81
+ cssVariables: Record<string, string>;
82
+ };
@@ -5,3 +5,4 @@ export const BUILDER_PREVIEW_RUNTIME_MODE_CHANGED = 'builder.preview.runtimeMode
5
5
  export const BUILDER_PREVIEW_DEFINITION_PATCH = 'builder.preview.definitionPatch';
6
6
  export const BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED = 'builder.preview.variableValuesChanged';
7
7
  export const BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED = 'builder.preview.paywallPlansChanged';
8
+ export const BUILDER_PREVIEW_THEME_CHANGED = 'builder.preview.themeChanged';
@@ -41,12 +41,16 @@ type PreviewBridgeMessage = {
41
41
  kind: 'paywallPlansChanged';
42
42
  stepId: string;
43
43
  plans: BuilderPreviewPaywallPlan[];
44
+ } | {
45
+ kind: 'themeChanged';
46
+ cssVariables: Record<string, string>;
44
47
  } | {
45
48
  kind: 'goToStep';
46
49
  stepId: string;
47
50
  };
48
51
  export declare const parsePreviewQuickEditPatch: (value: unknown) => PreviewQuickEditPatch | null;
49
52
  export declare const parsePreviewBridgeMessage: (value: unknown) => PreviewBridgeMessage | null;
53
+ export declare const applyPreviewThemeVariables: (cssVariables: Record<string, string>) => void;
50
54
  export declare const applyPreviewQuickEditPatch: (patch: PreviewQuickEditPatch) => void;
51
55
  export declare function emitPreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
52
56
  export declare function usePreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { useEffect, useRef } from 'react';
3
- import { BUILDER_PREVIEW_ACTIVE_STEP_CHANGED, BUILDER_PREVIEW_DEFINITION_PATCH, BUILDER_PREVIEW_GO_TO_STEP, BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED, BUILDER_PREVIEW_READY, BUILDER_PREVIEW_RUNTIME_MODE_CHANGED, BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED, } from '../config/builder-preview.protocol.js';
3
+ import { BUILDER_PREVIEW_ACTIVE_STEP_CHANGED, BUILDER_PREVIEW_DEFINITION_PATCH, BUILDER_PREVIEW_GO_TO_STEP, BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED, BUILDER_PREVIEW_READY, BUILDER_PREVIEW_RUNTIME_MODE_CHANGED, BUILDER_PREVIEW_THEME_CHANGED, BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED, } from '../config/builder-preview.protocol.js';
4
4
  import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
5
5
  import { logger } from '../services/logger.js';
6
6
  import { applyPreviewDefinitionPatch, applyPreviewPaywallPlansPatch } from './preview-definition-overrides.js';
@@ -107,6 +107,13 @@ export const parsePreviewQuickEditPatch = (value) => {
107
107
  }
108
108
  return null;
109
109
  };
110
+ const parseThemeCssVariables = (value) => {
111
+ if (!isRecord(value)) {
112
+ return null;
113
+ }
114
+ const cssVariables = Object.fromEntries(Object.entries(value).filter((entry) => entry[0].startsWith('--') && typeof entry[1] === 'string'));
115
+ return Object.keys(cssVariables).length > 0 ? cssVariables : null;
116
+ };
110
117
  export const parsePreviewBridgeMessage = (value) => {
111
118
  if (!isRecord(value)) {
112
119
  return null;
@@ -149,12 +156,33 @@ export const parsePreviewBridgeMessage = (value) => {
149
156
  }
150
157
  : null;
151
158
  }
159
+ if (messageType === BUILDER_PREVIEW_THEME_CHANGED) {
160
+ const cssVariables = parseThemeCssVariables(value.cssVariables);
161
+ return cssVariables ? { kind: 'themeChanged', cssVariables } : null;
162
+ }
152
163
  if (messageType === BUILDER_PREVIEW_GO_TO_STEP) {
153
164
  const stepId = typeof value.stepId === 'string' ? value.stepId.trim() : '';
154
165
  return stepId ? { kind: 'goToStep', stepId } : null;
155
166
  }
156
167
  return null;
157
168
  };
169
+ export const applyPreviewThemeVariables = (cssVariables) => {
170
+ if (typeof document === 'undefined') {
171
+ return;
172
+ }
173
+ const targets = [
174
+ document.documentElement,
175
+ document.body,
176
+ ...Array.from(document.querySelectorAll('.page-root')),
177
+ ].filter((target) => Boolean(target));
178
+ for (const target of targets) {
179
+ for (const [key, value] of Object.entries(cssVariables)) {
180
+ if (key.startsWith('--')) {
181
+ target.style.setProperty(key, value);
182
+ }
183
+ }
184
+ }
185
+ };
158
186
  export const applyPreviewQuickEditPatch = (patch) => {
159
187
  if (typeof document === 'undefined') {
160
188
  return;
@@ -271,9 +299,6 @@ export function usePreviewBridge({ activeStepId, onGoToStep, resolveRenderableSt
271
299
  emitPreviewReady(activeStepId);
272
300
  }, [activeStepId]);
273
301
  useEffect(() => {
274
- if (shouldLockToInitialStep) {
275
- return;
276
- }
277
302
  const handlePreviewMessage = (event) => {
278
303
  const action = parsePreviewBridgeMessage(event.data);
279
304
  if (!action) {
@@ -294,10 +319,17 @@ export function usePreviewBridge({ activeStepId, onGoToStep, resolveRenderableSt
294
319
  applyPreviewPaywallPlansPatch(action.stepId, action.plans);
295
320
  return;
296
321
  }
322
+ if (action.kind === 'themeChanged') {
323
+ applyPreviewThemeVariables(action.cssVariables);
324
+ return;
325
+ }
297
326
  if (action.kind === 'runtimeModeChanged') {
298
327
  setRuntimeMode(action.mode);
299
328
  return;
300
329
  }
330
+ if (shouldLockToInitialStep) {
331
+ return;
332
+ }
301
333
  const targetStepId = resolveRenderableStepId(action.stepId);
302
334
  if (!targetStepId) {
303
335
  logger.warn({
@@ -9,7 +9,16 @@ export type FunnelFlowControllerExperiment<StepId extends string> = FunnelManife
9
9
  })[];
10
10
  };
11
11
  type StepComponentRegistry = Record<string, unknown>;
12
+ type FunnelFlowAnalyticsAdapter = {
13
+ trackFirstStepViewed?: (input: {
14
+ stepId: string;
15
+ stepName: string;
16
+ occurredAt: string;
17
+ featureFlags?: Record<string, string>;
18
+ }) => string | null;
19
+ };
12
20
  type UseFunnelFlowControllerInput<StepId extends string> = {
21
+ analytics?: FunnelFlowAnalyticsAdapter;
13
22
  initialStepId?: StepId;
14
23
  lockToInitialStep?: boolean;
15
24
  defaultStepId: StepId;
@@ -63,5 +72,5 @@ export declare function computeRenderSuspended(input: {
63
72
  }>;
64
73
  isPreviewRuntime?: boolean;
65
74
  }): boolean;
66
- export declare function useFunnelFlowController<StepId extends string>({ initialStepId, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
75
+ export declare function useFunnelFlowController<StepId extends string>({ analytics, initialStepId, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
67
76
  export {};
@@ -54,7 +54,7 @@ export function computeRenderSuspended(input) {
54
54
  }
55
55
  return input.experiments.some((experiment) => experiment.stepId === input.activeStepId);
56
56
  }
57
- export function useFunnelFlowController({ initialStepId, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
57
+ export function useFunnelFlowController({ analytics, initialStepId, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
58
58
  var _a, _b;
59
59
  const [runtimeMode, setRuntimeMode] = useRuntimeMode();
60
60
  const [editorModeEnabled, setEditorModeEnabled] = useState(false);
@@ -106,6 +106,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
106
106
  const attributesRef = useRef(attributes);
107
107
  const postHogFeatureFlagsRef = useRef({});
108
108
  const prevStepIdRef = useRef(null);
109
+ const firstStepViewedTrackedRef = useRef(false);
109
110
  const stepStartedAtByIdRef = useRef({});
110
111
  const engagedStepIdsRef = useRef(new Set());
111
112
  const currentUserIdRef = useRef(user.id);
@@ -340,7 +341,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
340
341
  endedAt: record.completedAt,
341
342
  selected: record.choices,
342
343
  });
343
- 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)));
344
+ 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, environment: runtimeMode, 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)));
344
345
  }
345
346
  setUser((prev) => {
346
347
  var _a, _b;
@@ -368,6 +369,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
368
369
  recordStepCompletion(stepId, choices);
369
370
  }, [recordStepCompletion]);
370
371
  useEffect(() => {
372
+ var _a;
371
373
  if (safeActiveStepId !== activeStepId) {
372
374
  logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
373
375
  setActiveStepId(safeActiveStepId);
@@ -390,7 +392,16 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
390
392
  stepName,
391
393
  startedAt,
392
394
  });
393
- 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: renderedStepId, step_name: stepName, started_at: startedAt }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
395
+ 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, environment: runtimeMode, step_id: renderedStepId, step_name: stepName, started_at: startedAt }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
396
+ if (!firstStepViewedTrackedRef.current && safeActiveStepId === defaultStepId) {
397
+ firstStepViewedTrackedRef.current = true;
398
+ (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _a === void 0 ? void 0 : _a.call(analytics, {
399
+ stepId: renderedStepId,
400
+ stepName,
401
+ occurredAt: startedAt,
402
+ featureFlags: postHogFeatureFlagsRef.current,
403
+ });
404
+ }
394
405
  }
395
406
  attributesAtStepStart.current = Object.assign({}, attributesRef.current);
396
407
  prevStepIdRef.current = renderedStepId;
@@ -420,11 +431,12 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
420
431
  engagementThresholdMs: FIRST_STEP_ENGAGEMENT_THRESHOLD_MS,
421
432
  },
422
433
  });
423
- 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)));
434
+ 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, environment: runtimeMode, 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)));
424
435
  }, FIRST_STEP_ENGAGEMENT_THRESHOLD_MS);
425
436
  return () => window.clearTimeout(timer);
426
437
  }, [
427
438
  activeStepId,
439
+ analytics,
428
440
  defaultStepId,
429
441
  isPreviewRuntime,
430
442
  recordStepCompletion,
@@ -51,6 +51,13 @@ export type ManageSubscriptionsResponse = {
51
51
  billingInterval?: string | null;
52
52
  billingIntervalCount?: number | null;
53
53
  discountCouponIds?: string[];
54
+ retentionDiscount?: {
55
+ couponId: string;
56
+ discountPercent: number;
57
+ amountCents: number;
58
+ discountedAmountCents: number;
59
+ originalAmountCents: number;
60
+ };
54
61
  environment: 'test' | 'live';
55
62
  }>;
56
63
  eventsCount: number;
@@ -2,9 +2,58 @@ import { buildMainApiUrl, buildSdkHeaders, FUNNEL_ID, FUNNEL_VERSION_ID, getFunn
2
2
  import { mergeFunnelUserAttribution, } from '../runtime/funnel-attribution.js';
3
3
  import { applyUrlUserAttributesToUser, hasUrlUserProfileAttributes, resolveUrlUserAttributes, } from '../runtime/url-user-attributes.js';
4
4
  const DEFAULT_USER_ID_STORAGE_KEY = 'funnel:user-id';
5
+ const DEFAULT_FUNNEL_DOMAIN_SIGNAL = 'funnelsgrove';
5
6
  const canUseDom = () => {
6
7
  return typeof window !== 'undefined';
7
8
  };
9
+ const normalizeRuntimeEnvironment = (value) => {
10
+ if (typeof value !== 'string') {
11
+ return null;
12
+ }
13
+ const normalized = value.trim().toLowerCase();
14
+ if (normalized === 'test') {
15
+ return 'test';
16
+ }
17
+ if (normalized === 'live' || normalized === 'prod' || normalized === 'production') {
18
+ return 'live';
19
+ }
20
+ return null;
21
+ };
22
+ const readCookieValue = (name) => {
23
+ var _a, _b;
24
+ if (!canUseDom() || typeof document === 'undefined') {
25
+ return null;
26
+ }
27
+ return (((_b = (_a = document.cookie) === null || _a === void 0 ? void 0 : _a.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))) === null || _b === void 0 ? void 0 : _b.slice(name.length + 1)) || null);
28
+ };
29
+ const resolveRuntimeAnalyticsEnvironment = () => {
30
+ var _a, _b, _c, _d;
31
+ if (!canUseDom()) {
32
+ return 'live';
33
+ }
34
+ try {
35
+ const queryMode = normalizeRuntimeEnvironment(new URLSearchParams(((_a = window.location) === null || _a === void 0 ? void 0 : _a.search) || '').get('mode'));
36
+ if (queryMode) {
37
+ return queryMode;
38
+ }
39
+ const cookieMode = normalizeRuntimeEnvironment(readCookieValue('funnel-mode'));
40
+ if (cookieMode) {
41
+ return cookieMode;
42
+ }
43
+ const hostname = ((_c = (_b = window.location) === null || _b === void 0 ? void 0 : _b.hostname) === null || _c === void 0 ? void 0 : _c.trim().toLowerCase()) || '';
44
+ if (hostname.includes(DEFAULT_FUNNEL_DOMAIN_SIGNAL)) {
45
+ return 'test';
46
+ }
47
+ const storageMode = normalizeRuntimeEnvironment((_d = window.localStorage) === null || _d === void 0 ? void 0 : _d.getItem('funnel-mode'));
48
+ if (storageMode) {
49
+ return storageMode;
50
+ }
51
+ }
52
+ catch (_e) {
53
+ return 'live';
54
+ }
55
+ return 'live';
56
+ };
8
57
  const isRecord = (value) => {
9
58
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
10
59
  };
@@ -80,7 +129,7 @@ const buildSdkEvent = (event) => {
80
129
  startedAt: event.startedAt || null,
81
130
  endedAt: event.endedAt || null,
82
131
  selected: event.selected || {},
83
- metadata: event.metadata || {},
132
+ metadata: Object.assign({ environment: resolveRuntimeAnalyticsEnvironment() }, (event.metadata || {})),
84
133
  };
85
134
  };
86
135
  const withMergedAttributionDocument = (document, attribution) => {
@@ -236,6 +285,7 @@ class ApiService {
236
285
  fullName: inputName,
237
286
  metadata: {
238
287
  source: 'funnel-session',
288
+ environment: resolveRuntimeAnalyticsEnvironment(),
239
289
  },
240
290
  attribution: input === null || input === void 0 ? void 0 : input.attribution,
241
291
  }),
@@ -285,6 +335,7 @@ class ApiService {
285
335
  },
286
336
  metadata: {
287
337
  source: 'funnel',
338
+ environment: resolveRuntimeAnalyticsEnvironment(),
288
339
  },
289
340
  attribution: options === null || options === void 0 ? void 0 : options.attribution,
290
341
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",