@funnelsgrove/runtime 0.1.26 → 0.1.28

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.
@@ -6,6 +6,8 @@ export declare const BUILDER_PREVIEW_DEFINITION_PATCH = "builder.preview.definit
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
8
  export declare const BUILDER_PREVIEW_THEME_CHANGED = "builder.preview.themeChanged";
9
+ export declare const BUILDER_PREVIEW_EDIT_MODE_CHANGED = "builder.preview.editModeChanged";
10
+ export declare const BUILDER_PREVIEW_TEXT_EDITED = "builder.preview.textEdited";
9
11
  export type BuilderPreviewReadyMessage = {
10
12
  type: typeof BUILDER_PREVIEW_READY;
11
13
  stepId: string;
@@ -80,3 +82,15 @@ export type BuilderPreviewThemeChangedMessage = {
80
82
  type: typeof BUILDER_PREVIEW_THEME_CHANGED;
81
83
  cssVariables: Record<string, string>;
82
84
  };
85
+ export type BuilderPreviewEditModeChangedMessage = {
86
+ type: typeof BUILDER_PREVIEW_EDIT_MODE_CHANGED;
87
+ enabled: boolean;
88
+ };
89
+ export type BuilderPreviewTextEditedMessage = {
90
+ type: typeof BUILDER_PREVIEW_TEXT_EDITED;
91
+ stepId: string;
92
+ tag: string;
93
+ index: number;
94
+ value: string;
95
+ previousValue: string;
96
+ };
@@ -6,3 +6,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
8
  export const BUILDER_PREVIEW_THEME_CHANGED = 'builder.preview.themeChanged';
9
+ export const BUILDER_PREVIEW_EDIT_MODE_CHANGED = 'builder.preview.editModeChanged';
10
+ export const BUILDER_PREVIEW_TEXT_EDITED = 'builder.preview.textEdited';
@@ -7,10 +7,12 @@ export type FunnelExperimentDefinition = {
7
7
  launchDate: string;
8
8
  control: {
9
9
  stepId: string;
10
+ label?: string;
10
11
  trafficPercent: number;
11
12
  };
12
13
  variant: {
13
14
  stepId: string;
15
+ label?: string;
14
16
  trafficPercent: number;
15
17
  };
16
18
  };
@@ -13,13 +13,7 @@ export const toManifestExperiments = (experiments) => experiments
13
13
  experimentId: experiment.id,
14
14
  stepId: experiment.control.stepId,
15
15
  variants: [
16
- {
17
- variantKey: 'control',
18
- routeToStepId: experiment.control.stepId,
19
- },
20
- {
21
- variantKey: 'variant_b',
22
- routeToStepId: experiment.variant.stepId,
23
- },
16
+ Object.assign(Object.assign({ variantKey: 'control' }, (experiment.control.label ? { label: experiment.control.label } : {})), { trafficPercent: experiment.control.trafficPercent, routeToStepId: experiment.control.stepId }),
17
+ Object.assign(Object.assign({ variantKey: 'variant_b' }, (experiment.variant.label ? { label: experiment.variant.label } : {})), { trafficPercent: experiment.variant.trafficPercent, routeToStepId: experiment.variant.stepId }),
24
18
  ],
25
19
  }));
@@ -9,6 +9,7 @@ export type FunnelManifestStep = {
9
9
  kind?: FunnelStepKind;
10
10
  name?: string;
11
11
  title: string;
12
+ tags?: readonly string[];
12
13
  assetIds?: readonly string[];
13
14
  };
14
15
  export type FunnelManifestImageAsset = {
@@ -28,8 +29,19 @@ export type FunnelManifestEdge = {
28
29
  toStepId: string;
29
30
  conditionId?: string;
30
31
  };
32
+ export type FunnelManifestBranch = {
33
+ id: string;
34
+ name: string;
35
+ sourceStepId: string;
36
+ conditionId?: string;
37
+ label?: string;
38
+ tags?: readonly string[];
39
+ stepIds: readonly string[];
40
+ };
31
41
  export type FunnelManifestExperimentVariant = {
32
42
  variantKey: string;
43
+ label?: string;
44
+ trafficPercent?: number;
33
45
  routeToStepId: string;
34
46
  };
35
47
  export type FunnelManifestExperiment = {
@@ -51,6 +63,7 @@ export type FunnelManifest = {
51
63
  steps: readonly FunnelManifestStep[];
52
64
  entryPoints?: readonly FunnelManifestEntryPoint[];
53
65
  edgesByStepId: Partial<Record<string, readonly FunnelManifestEdge[]>>;
66
+ branches?: readonly FunnelManifestBranch[];
54
67
  experiments: readonly FunnelManifestExperiment[];
55
68
  };
56
69
  export type FunnelStepComponentKey = string;
@@ -46,6 +46,22 @@ export const validateFunnelManifest = (manifest) => {
46
46
  assertValidReference(`edge from "${stepId}"`, edge.toStepId, validStepIds, errors);
47
47
  }
48
48
  }
49
+ pushDuplicateErrors('branch ids', (manifest.branches || []).map((branch) => branch.id), errors);
50
+ for (const branch of manifest.branches || []) {
51
+ if (!isDefinedString(branch.id)) {
52
+ errors.push('branch id is required');
53
+ }
54
+ if (!isDefinedString(branch.name)) {
55
+ errors.push(`branch "${branch.id || ''}" name is required`);
56
+ }
57
+ assertValidReference(`branch "${branch.id}" source`, branch.sourceStepId, validStepIds, errors);
58
+ if (branch.stepIds.length === 0) {
59
+ errors.push(`branch "${branch.id}" must own at least one step`);
60
+ }
61
+ for (const stepId of branch.stepIds) {
62
+ assertValidReference(`branch "${branch.id}" step`, stepId, validStepIds, errors);
63
+ }
64
+ }
49
65
  for (const experiment of manifest.experiments) {
50
66
  assertValidReference(`experiment "${experiment.experimentId}"`, experiment.stepId, validStepIds, errors);
51
67
  for (const variant of experiment.variants) {
@@ -44,6 +44,9 @@ type PreviewBridgeMessage = {
44
44
  } | {
45
45
  kind: 'themeChanged';
46
46
  cssVariables: Record<string, string>;
47
+ } | {
48
+ kind: 'editModeChanged';
49
+ enabled: boolean;
47
50
  } | {
48
51
  kind: 'goToStep';
49
52
  stepId: string;
@@ -52,6 +55,7 @@ export declare const parsePreviewQuickEditPatch: (value: unknown) => PreviewQuic
52
55
  export declare const parsePreviewBridgeMessage: (value: unknown) => PreviewBridgeMessage | null;
53
56
  export declare const applyPreviewThemeVariables: (cssVariables: Record<string, string>) => void;
54
57
  export declare const applyPreviewQuickEditPatch: (patch: PreviewQuickEditPatch) => void;
58
+ export declare const setPreviewInlineEditMode: (enabled: boolean, getActiveStepId: () => string) => void;
55
59
  export declare function emitPreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
56
60
  export declare function usePreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
57
61
  export type PreviewBridgeOptions = {
@@ -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_THEME_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_EDIT_MODE_CHANGED, BUILDER_PREVIEW_GO_TO_STEP, BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED, BUILDER_PREVIEW_READY, BUILDER_PREVIEW_RUNTIME_MODE_CHANGED, BUILDER_PREVIEW_TEXT_EDITED, 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';
@@ -160,6 +160,12 @@ export const parsePreviewBridgeMessage = (value) => {
160
160
  const cssVariables = parseThemeCssVariables(value.cssVariables);
161
161
  return cssVariables ? { kind: 'themeChanged', cssVariables } : null;
162
162
  }
163
+ if (messageType === BUILDER_PREVIEW_EDIT_MODE_CHANGED) {
164
+ return {
165
+ kind: 'editModeChanged',
166
+ enabled: value.enabled === true,
167
+ };
168
+ }
163
169
  if (messageType === BUILDER_PREVIEW_GO_TO_STEP) {
164
170
  const stepId = typeof value.stepId === 'string' ? value.stepId.trim() : '';
165
171
  return stepId ? { kind: 'goToStep', stepId } : null;
@@ -249,6 +255,136 @@ export const applyPreviewQuickEditPatch = (patch) => {
249
255
  }
250
256
  }
251
257
  };
258
+ const INLINE_EDIT_STYLE_ID = 'builder-preview-inline-edit-style';
259
+ const INLINE_EDITABLE_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, span, button, a, li, label, blockquote';
260
+ let inlineEditCleanup = null;
261
+ const isInlineEditableElement = (element) => {
262
+ var _a;
263
+ if (!(element instanceof HTMLElement) || !element.matches(INLINE_EDITABLE_SELECTOR)) {
264
+ return false;
265
+ }
266
+ return element.children.length === 0 && Boolean((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim());
267
+ };
268
+ const emitPreviewTextEdited = (input) => {
269
+ if (typeof window === 'undefined' || window.parent === window) {
270
+ return;
271
+ }
272
+ const stageRoot = document.querySelector('.step-stage-transition');
273
+ if (!(stageRoot instanceof HTMLElement)) {
274
+ return;
275
+ }
276
+ const tag = input.element.tagName.toLowerCase();
277
+ const index = Array.from(stageRoot.querySelectorAll(tag)).indexOf(input.element) + 1;
278
+ if (index < 1) {
279
+ return;
280
+ }
281
+ window.parent.postMessage({
282
+ type: BUILDER_PREVIEW_TEXT_EDITED,
283
+ stepId: input.stepId,
284
+ tag,
285
+ index,
286
+ value: input.value,
287
+ previousValue: input.previousValue,
288
+ }, '*');
289
+ };
290
+ const enablePreviewInlineEditMode = (getActiveStepId) => {
291
+ const style = document.createElement('style');
292
+ style.id = INLINE_EDIT_STYLE_ID;
293
+ style.textContent = [
294
+ `.step-stage-transition :is(${INLINE_EDITABLE_SELECTOR}):hover { outline: 1.5px dashed rgba(59, 130, 246, 0.85); outline-offset: 2px; cursor: text; }`,
295
+ '.step-stage-transition [contenteditable="true"] { outline: 1.5px solid rgba(59, 130, 246, 0.95); outline-offset: 2px; cursor: text; }',
296
+ ].join('\n');
297
+ document.head.appendChild(style);
298
+ let session = null;
299
+ const endSession = (commit) => {
300
+ if (!session) {
301
+ return;
302
+ }
303
+ const { element, previousValue, cancelled } = session;
304
+ session = null;
305
+ element.removeAttribute('contenteditable');
306
+ const value = element.textContent || '';
307
+ if (!commit || cancelled) {
308
+ element.textContent = previousValue;
309
+ return;
310
+ }
311
+ if (value !== previousValue) {
312
+ emitPreviewTextEdited({
313
+ stepId: getActiveStepId(),
314
+ element,
315
+ previousValue,
316
+ value,
317
+ });
318
+ }
319
+ };
320
+ const handleClick = (event) => {
321
+ const target = event.target;
322
+ if (!(target instanceof Element)) {
323
+ return;
324
+ }
325
+ const stageRoot = document.querySelector('.step-stage-transition');
326
+ if (!(stageRoot instanceof HTMLElement) || !stageRoot.contains(target)) {
327
+ return;
328
+ }
329
+ // Edit mode is modal: clicks select text to edit instead of driving the
330
+ // funnel (button taps, navigation).
331
+ event.preventDefault();
332
+ event.stopPropagation();
333
+ if ((session === null || session === void 0 ? void 0 : session.element) === target) {
334
+ return;
335
+ }
336
+ endSession(true);
337
+ if (!isInlineEditableElement(target)) {
338
+ return;
339
+ }
340
+ session = {
341
+ element: target,
342
+ previousValue: target.textContent || '',
343
+ cancelled: false,
344
+ };
345
+ target.setAttribute('contenteditable', 'true');
346
+ target.focus();
347
+ };
348
+ const handleKeyDown = (event) => {
349
+ if (!session) {
350
+ return;
351
+ }
352
+ if (event.key === 'Enter') {
353
+ event.preventDefault();
354
+ endSession(true);
355
+ return;
356
+ }
357
+ if (event.key === 'Escape') {
358
+ event.preventDefault();
359
+ session.cancelled = true;
360
+ endSession(false);
361
+ }
362
+ };
363
+ const handleFocusOut = () => {
364
+ endSession(true);
365
+ };
366
+ document.addEventListener('click', handleClick, true);
367
+ document.addEventListener('keydown', handleKeyDown, true);
368
+ document.addEventListener('focusout', handleFocusOut, true);
369
+ return () => {
370
+ var _a;
371
+ endSession(true);
372
+ document.removeEventListener('click', handleClick, true);
373
+ document.removeEventListener('keydown', handleKeyDown, true);
374
+ document.removeEventListener('focusout', handleFocusOut, true);
375
+ (_a = document.getElementById(INLINE_EDIT_STYLE_ID)) === null || _a === void 0 ? void 0 : _a.remove();
376
+ };
377
+ };
378
+ export const setPreviewInlineEditMode = (enabled, getActiveStepId) => {
379
+ if (typeof document === 'undefined') {
380
+ return;
381
+ }
382
+ inlineEditCleanup === null || inlineEditCleanup === void 0 ? void 0 : inlineEditCleanup();
383
+ inlineEditCleanup = null;
384
+ if (enabled) {
385
+ inlineEditCleanup = enablePreviewInlineEditMode(getActiveStepId);
386
+ }
387
+ };
252
388
  function emitPreviewActiveStepChanged(stepId) {
253
389
  if (typeof window === 'undefined' || window.parent === window) {
254
390
  return;
@@ -288,9 +424,16 @@ export function usePreviewVariableValues(stepId, values) {
288
424
  }
289
425
  export function usePreviewBridge({ activeStepId, onGoToStep, resolveRenderableStepId, setRuntimeMode, shouldLockToInitialStep, }) {
290
426
  const previewReadySentRef = useRef(false);
427
+ const activeStepIdRef = useRef(activeStepId);
291
428
  useEffect(() => {
429
+ activeStepIdRef.current = activeStepId;
292
430
  emitPreviewActiveStepChanged(activeStepId);
293
431
  }, [activeStepId]);
432
+ useEffect(() => {
433
+ return () => {
434
+ setPreviewInlineEditMode(false, () => '');
435
+ };
436
+ }, []);
294
437
  useEffect(() => {
295
438
  if (!isPreviewFrameRuntime() || previewReadySentRef.current) {
296
439
  return;
@@ -323,6 +466,10 @@ export function usePreviewBridge({ activeStepId, onGoToStep, resolveRenderableSt
323
466
  applyPreviewThemeVariables(action.cssVariables);
324
467
  return;
325
468
  }
469
+ if (action.kind === 'editModeChanged') {
470
+ setPreviewInlineEditMode(action.enabled, () => activeStepIdRef.current);
471
+ return;
472
+ }
326
473
  if (action.kind === 'runtimeModeChanged') {
327
474
  setRuntimeMode(action.mode);
328
475
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",