@revenuecat/purchases-ui-js 4.7.6 → 4.8.1

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.
@@ -175,9 +175,6 @@
175
175
  action: {
176
176
  type: "workflow",
177
177
  },
178
- triggers: {
179
- on_press: "workflow-action-id",
180
- },
181
178
  stack: {
182
179
  ...stackProps(5),
183
180
  background_color: {
@@ -67,12 +67,6 @@
67
67
  componentName: props.name,
68
68
  componentValue: "navigate_back",
69
69
  };
70
- case "navigate_to_page":
71
- return {
72
- componentType: "button",
73
- componentName: props.name,
74
- componentValue: "navigate_to_page",
75
- };
76
70
  case "close_workflow":
77
71
  return {
78
72
  componentType: "button",
@@ -149,7 +143,9 @@
149
143
 
150
144
  const onclick = () => {
151
145
  if (isDisabled) return;
152
- const actionId = props.triggers?.on_press;
146
+ // For "workflow" actions, pass the component's own id so Workflow.svelte
147
+ // can look it up in the step's triggers array (component ID → action ID).
148
+ const actionId = action.type === "workflow" ? props.id : undefined;
153
149
  if (action.type === "navigate_to" && action.destination === "sheet") {
154
150
  if (action.sheet != null) {
155
151
  emitComponentInteraction(getSheetOpenInteractionData(action));
@@ -173,8 +169,6 @@
173
169
  return false;
174
170
  case "navigate_back":
175
171
  return !hideBackButtons;
176
- case "navigate_to_page":
177
- return true;
178
172
  case "close_workflow":
179
173
  return true;
180
174
  case "navigate_to":
@@ -364,7 +364,7 @@
364
364
  title: "Components/Carousel",
365
365
  component: Carousel,
366
366
  decorators: [
367
- viewportDecorator(400, 300, 0),
367
+ viewportDecorator(350, 300, 0),
368
368
  componentDecorator(),
369
369
  localizationDecorator({
370
370
  defaultLocale,
@@ -355,6 +355,7 @@
355
355
  display: flex;
356
356
  flex-direction: column;
357
357
  flex-shrink: 0;
358
+ align-self: stretch;
358
359
  }
359
360
 
360
361
  .carousel-clip {
@@ -401,6 +402,8 @@
401
402
  transition-delay: 2500ms;
402
403
  transform: translateX(0);
403
404
 
405
+ touch-action: pan-y;
406
+
404
407
  cursor: grab;
405
408
 
406
409
  &:active {
@@ -25,6 +25,7 @@
25
25
  import { waitForAnimations } from "storybook/internal/preview-api";
26
26
  import { viewportDecorator } from "../../stories/viewport-decorator";
27
27
  import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
28
+ import { FIXED_HEIGHT_STACK_PAYWALL } from "./fixtures/fixed-height-stack-paywall";
28
29
  import { OVERRIDE_PAYWALL } from "./fixtures/override-paywall";
29
30
  import { SHEET_PAYWALL } from "./fixtures/sheet-paywall";
30
31
  import { SHEET_PAYWALL_VIDEO_STACKING } from "./fixtures/sheet-video-stacking-paywall";
@@ -74,6 +75,20 @@
74
75
  }}
75
76
  />
76
77
 
78
+ <!--
79
+ Regression story for fixed-height stacks being squeezed by the flex algorithm.
80
+ The root stack uses `space_between` and children's fixed heights sum to more than
81
+ the 400px viewport, so the empty spacer would collapse without `flex-shrink: 0`.
82
+ Chromatic diffs this story to block regressions of Stack.svelte's flex-shrink rule.
83
+ -->
84
+ <Story
85
+ name="Fixed-height stack (regression)"
86
+ decorators={[viewportDecorator(320, 400, 0)]}
87
+ args={{
88
+ paywallData: FIXED_HEIGHT_STACK_PAYWALL,
89
+ }}
90
+ />
91
+
77
92
  <Story
78
93
  name="Override paywall"
79
94
  args={{
@@ -71,11 +71,6 @@
71
71
  preferredColorMode?: ColorMode;
72
72
  onPurchaseClicked?: (selectedPackageId: string, actionId: string) => void;
73
73
  onBackClicked?: () => void;
74
- /**
75
- * Called when a `navigate_to_page` button is pressed and there is no
76
- * active nav_host context to handle it internally.
77
- */
78
- onNavigateToPage?: (pageId: string) => void;
79
74
  /** Called when a `close_workflow` button is pressed to dismiss the paywall/workflow. */
80
75
  onClose?: () => void;
81
76
  onVisitCustomerCenterClicked?: () => void;
@@ -135,7 +130,6 @@
135
130
  preferredColorMode,
136
131
  onPurchaseClicked,
137
132
  onBackClicked,
138
- onNavigateToPage,
139
133
  onClose,
140
134
  onVisitCustomerCenterClicked,
141
135
  onRestorePurchasesClicked,
@@ -248,9 +242,6 @@
248
242
  case "navigate_back":
249
243
  onBackClicked?.();
250
244
  return;
251
- case "navigate_to_page":
252
- onNavigateToPage?.(action.page_id);
253
- return;
254
245
  case "close_workflow":
255
246
  onClose?.();
256
247
  return;
@@ -28,11 +28,6 @@ interface Props {
28
28
  preferredColorMode?: ColorMode;
29
29
  onPurchaseClicked?: (selectedPackageId: string, actionId: string) => void;
30
30
  onBackClicked?: () => void;
31
- /**
32
- * Called when a `navigate_to_page` button is pressed and there is no
33
- * active nav_host context to handle it internally.
34
- */
35
- onNavigateToPage?: (pageId: string) => void;
36
31
  /** Called when a `close_workflow` button is pressed to dismiss the paywall/workflow. */
37
32
  onClose?: () => void;
38
33
  onVisitCustomerCenterClicked?: () => void;
@@ -0,0 +1,2 @@
1
+ import type { PaywallData } from "../../../types/paywall";
2
+ export declare const FIXED_HEIGHT_STACK_PAYWALL: PaywallData;
@@ -0,0 +1,87 @@
1
+ // Regression fixture: fixed-height empty stack collapses under flex pressure.
2
+ // Root `space_between` with children summing above viewport height. Pair with
3
+ // `viewportDecorator` in the story so the parent is bounded; otherwise the
4
+ // paywall grows to content height and no shrinkage happens.
5
+ const solidStack = (id, height, hex) => ({
6
+ type: "stack",
7
+ id,
8
+ name: id,
9
+ components: [],
10
+ dimension: {
11
+ type: "vertical",
12
+ alignment: "center",
13
+ distribution: "center",
14
+ },
15
+ size: {
16
+ width: { type: "fill" },
17
+ height: { type: "fixed", value: height },
18
+ },
19
+ background: null,
20
+ background_color: { light: { type: "hex", value: hex } },
21
+ badge: null,
22
+ border: null,
23
+ margin: { bottom: 0, leading: 0, top: 0, trailing: 0 },
24
+ padding: { bottom: 0, leading: 0, top: 0, trailing: 0 },
25
+ shadow: null,
26
+ shape: {
27
+ type: "rectangle",
28
+ corners: {
29
+ bottom_leading: 0,
30
+ bottom_trailing: 0,
31
+ top_leading: 0,
32
+ top_trailing: 0,
33
+ },
34
+ },
35
+ spacing: 0,
36
+ });
37
+ export const FIXED_HEIGHT_STACK_PAYWALL = {
38
+ id: "fixed_height_stack_paywall",
39
+ default_locale: "en_US",
40
+ components_localizations: { en_US: {} },
41
+ components_config: {
42
+ base: {
43
+ background: {
44
+ type: "color",
45
+ value: { light: { type: "hex", value: "#ffffff" } },
46
+ },
47
+ stack: {
48
+ type: "stack",
49
+ id: "root",
50
+ name: "Root",
51
+ components: [
52
+ solidStack("hero", 180, "#ff5f5f"),
53
+ solidStack("empty_spacer", 50, "#11d483"),
54
+ solidStack("card", 200, "#6366f1"),
55
+ solidStack("cta", 48, "#000000"),
56
+ ],
57
+ dimension: {
58
+ type: "vertical",
59
+ alignment: "center",
60
+ distribution: "space_between",
61
+ },
62
+ size: {
63
+ width: { type: "fill" },
64
+ height: { type: "fill" },
65
+ },
66
+ background: null,
67
+ background_color: null,
68
+ badge: null,
69
+ border: null,
70
+ margin: { bottom: 0, leading: 0, top: 0, trailing: 0 },
71
+ padding: { bottom: 16, leading: 16, top: 16, trailing: 16 },
72
+ shadow: null,
73
+ shape: {
74
+ type: "rectangle",
75
+ corners: {
76
+ bottom_leading: 0,
77
+ bottom_trailing: 0,
78
+ top_leading: 0,
79
+ top_trailing: 0,
80
+ },
81
+ },
82
+ spacing: 12,
83
+ },
84
+ sticky_footer: null,
85
+ },
86
+ },
87
+ };
@@ -32,7 +32,13 @@
32
32
  getHoverStateProps,
33
33
  } from "../../utils/style-utils";
34
34
  import type { Snippet } from "svelte";
35
- import { mapBadge, mapDimension, mapLayerAlignment } from "./stack-utils";
35
+ import {
36
+ mapBadge,
37
+ mapDimension,
38
+ mapFixedHeightBounds,
39
+ mapFixedWidthBounds,
40
+ mapLayerAlignment,
41
+ } from "./stack-utils";
36
42
 
37
43
  interface MiscProps {
38
44
  onclick?: () => void;
@@ -89,6 +95,8 @@
89
95
  position: "relative",
90
96
  width: mapSize(size.width),
91
97
  height: mapSize(size.height),
98
+ ...mapFixedWidthBounds(size.width),
99
+ ...mapFixedHeightBounds(size.height),
92
100
  ...mapDimension(dimension),
93
101
  gap: px(spacing),
94
102
  margin: mapSpacing(margin),
@@ -1,6 +1,9 @@
1
+ import type { Size } from "../../types";
1
2
  import type { Dimension, ZAlignment } from "../../types/alignment";
2
3
  import type { Badge } from "../../types/components/stack";
3
4
  import { type CSS } from "../../utils/base-utils";
5
+ export declare function mapFixedWidthBounds(width: Size): CSS;
6
+ export declare function mapFixedHeightBounds(height: Size): CSS;
4
7
  export declare function mapDimension(dimension: Dimension): Record<string, string>;
5
8
  type JustifyAlign = {
6
9
  justifyContent: string;
@@ -14,6 +14,20 @@ const DISTRIBUTION_MAP = Object.freeze({
14
14
  center: "center",
15
15
  end: "flex-end",
16
16
  });
17
+ export function mapFixedWidthBounds(width) {
18
+ if (width.type !== "fixed") {
19
+ return {};
20
+ }
21
+ const value = px(width.value);
22
+ return { "min-width": value, "max-width": value };
23
+ }
24
+ export function mapFixedHeightBounds(height) {
25
+ if (height.type !== "fixed") {
26
+ return {};
27
+ }
28
+ const value = px(height.value);
29
+ return { "min-height": value, "max-height": value };
30
+ }
17
31
  export function mapDimension(dimension) {
18
32
  if (dimension.type === "zlayer") {
19
33
  return {};
@@ -21,7 +21,6 @@
21
21
  actionId: string,
22
22
  ) => void | Promise<void>;
23
23
  onBackClicked?: () => void;
24
- onNavigateToPage?: (pageId: string) => void;
25
24
  onClose?: () => void;
26
25
  containerId?: string;
27
26
  maxContentWidth?: string;
@@ -55,7 +54,6 @@
55
54
  onComponentInteraction,
56
55
  onPurchaseClicked,
57
56
  onBackClicked,
58
- onNavigateToPage,
59
57
  onClose,
60
58
  containerId = "screen-container",
61
59
  maxContentWidth,
@@ -94,7 +92,6 @@
94
92
  {onCompleteWorkflowNavigate}
95
93
  onVisitCustomerCenterClicked={() => onVisitCustomerCenterClicked?.()}
96
94
  {onBackClicked}
97
- {onNavigateToPage}
98
95
  {onClose}
99
96
  onRestorePurchasesClicked={() => onRestorePurchasesClicked?.()}
100
97
  onActionTriggered={(actionId: string) => {
@@ -16,7 +16,6 @@ interface Props {
16
16
  onComponentInteraction?: OnComponentInteraction;
17
17
  onPurchaseClicked?: (packageId: string, actionId: string) => void | Promise<void>;
18
18
  onBackClicked?: () => void;
19
- onNavigateToPage?: (pageId: string) => void;
20
19
  onClose?: () => void;
21
20
  containerId?: string;
22
21
  maxContentWidth?: string;
@@ -8,7 +8,25 @@
8
8
  import type { WalletButtonRender } from "../../types/wallet";
9
9
  import type { UIConfig } from "../../types/ui-config";
10
10
  import type { ReservedAttribute } from "../../types/components/input-text";
11
- import type { WorkflowNavData } from "../../types/workflow-nav";
11
+ import type {
12
+ WorkflowNavData,
13
+ WorkflowStepChangeEvent,
14
+ } from "../../types/workflow-nav";
15
+ import type {
16
+ WorkflowStepTriggerAction,
17
+ WorkflowStep,
18
+ } from "../../types/workflow";
19
+
20
+ type TriggerActionValue = WorkflowStep["trigger_actions"][string];
21
+
22
+ function isStepTriggerAction(
23
+ a: TriggerActionValue,
24
+ ): a is WorkflowStepTriggerAction {
25
+ return (
26
+ a.type === "step" &&
27
+ typeof (a as WorkflowStepTriggerAction).step_id === "string"
28
+ );
29
+ }
12
30
 
13
31
  interface Props {
14
32
  workflow: WorkflowNavData;
@@ -44,6 +62,12 @@
44
62
  onComponentInteraction?: OnComponentInteraction;
45
63
  onClose?: () => void;
46
64
  onExitBack?: () => void;
65
+ /**
66
+ * Called whenever the current workflow step changes — on initial load,
67
+ * forward navigation, back navigation, and when leaving a step (close,
68
+ * purchase, error). Used by the host to fire workflow lifecycle events.
69
+ */
70
+ onStepChanged?: (event: WorkflowStepChangeEvent) => void;
47
71
  }
48
72
 
49
73
  const {
@@ -68,50 +92,203 @@
68
92
  onComponentInteraction,
69
93
  onClose,
70
94
  onExitBack,
95
+ onStepChanged,
71
96
  }: Props = $props();
72
97
 
73
98
  // ── Nav stack ─────────────────────────────────────────────────────────────
74
- // The stack always has at least the initial page. The current page is the
75
- // last entry. navigate_to_page pushes, navigate_back pops.
99
+ // Each entry pairs the screen ID (used to look up the page to render) with
100
+ // the step ID (used to resolve trigger_actions on button press). The current
101
+ // page is always the last entry; workflow actions push, navigate_back pops.
76
102
  //
77
103
  // We key the reset off workflow.initial_page_id (a stable string) rather
78
104
  // than workflow object identity, so hosts that recompute the prop object on
79
105
  // unrelated reactive updates don't accidentally snap the user back to the
80
106
  // first page mid-flow.
81
- let navStack = $state<string[]>([]);
107
+ interface NavEntry {
108
+ pageId: string;
109
+ stepId: string;
110
+ }
111
+ let navStack = $state<NavEntry[]>([]);
82
112
  let trackedInitialPageId = $state<string>();
83
113
 
84
- const currentPageId = $derived(
85
- navStack[navStack.length - 1] ?? workflow.initial_page_id,
114
+ const current = $derived(
115
+ navStack[navStack.length - 1] ?? {
116
+ pageId: workflow.initial_page_id,
117
+ stepId: workflow.initial_step_id,
118
+ },
86
119
  );
87
- const currentScreen = $derived(workflow.pages[currentPageId]);
120
+ const currentScreen = $derived(workflow.pages[current.pageId]);
88
121
 
89
122
  $effect.pre(() => {
90
123
  if (trackedInitialPageId !== workflow.initial_page_id) {
91
124
  trackedInitialPageId = workflow.initial_page_id;
92
- navStack = [workflow.initial_page_id];
125
+ navStack = [
126
+ {
127
+ pageId: workflow.initial_page_id,
128
+ stepId: workflow.initial_step_id,
129
+ },
130
+ ];
131
+ emitStepChange({
132
+ workflowId: workflow.workflowId,
133
+ stepId: workflow.initial_step_id,
134
+ fromStepId: undefined,
135
+ toStepId: undefined,
136
+ reason: "start",
137
+ ...stepFlags(workflow.initial_step_id),
138
+ });
93
139
  }
94
140
  });
95
141
 
96
- function handleNavigateToPage(pageId: string) {
97
- if (!workflow.pages[pageId]) {
98
- console.warn(`[Workflow] navigate_to_page: unknown page_id "${pageId}"`);
142
+ function stepFlags(stepId: string): {
143
+ isFirstStep: boolean;
144
+ isLastStep: boolean;
145
+ } {
146
+ const step = workflow.steps[stepId];
147
+ return {
148
+ isFirstStep: stepId === workflow.initial_step_id,
149
+ // A step is "last" when it has no step-type trigger actions — i.e. no
150
+ // client-resolvable forward navigation. Condition-based actions fall back
151
+ // to onActionTriggered and are not counted here.
152
+ isLastStep:
153
+ step !== undefined &&
154
+ !Object.values(step.trigger_actions).some((a) =>
155
+ isStepTriggerAction(a),
156
+ ),
157
+ };
158
+ }
159
+
160
+ function emitStepChange(event: WorkflowStepChangeEvent) {
161
+ onStepChanged?.(event);
162
+ }
163
+
164
+ /**
165
+ * Resolves a "workflow"-type button action and navigates to the next screen.
166
+ *
167
+ * Flow: component ID → step.triggers[].action_id → trigger_actions[action_id]
168
+ * → step_id → step.screen_id → push onto nav stack.
169
+ */
170
+ function handleActionTriggered(actionId: string) {
171
+ const currentStep = workflow.steps[current.stepId];
172
+ if (!currentStep) {
173
+ console.warn(
174
+ `[Workflow] onActionTriggered: no current step found for step_id "${current.stepId}"`,
175
+ );
176
+ return;
177
+ }
178
+
179
+ // Resolve the component ID to an action ID via the step's triggers array.
180
+ const resolvedActionId = currentStep.triggers.find(
181
+ (t) => t.component_id === actionId,
182
+ )?.action_id;
183
+
184
+ if (!resolvedActionId) {
185
+ console.warn(
186
+ `[Workflow] onActionTriggered: no trigger entry found for component "${actionId}" on step "${current.stepId}"`,
187
+ );
188
+ onActionTriggered?.(actionId);
189
+ return;
190
+ }
191
+
192
+ const triggerAction = currentStep.trigger_actions[resolvedActionId];
193
+ if (!triggerAction) {
194
+ console.warn(
195
+ `[Workflow] onActionTriggered: no trigger_action found for action_id "${resolvedActionId}" on step "${current.stepId}"`,
196
+ );
197
+ onActionTriggered?.(actionId);
198
+ return;
199
+ }
200
+
201
+ if (!isStepTriggerAction(triggerAction)) {
202
+ // Condition-based actions require a full workflow engine — fall back to
203
+ // the host's onActionTriggered for anything we can't resolve client-side.
204
+ console.warn(
205
+ `[Workflow] onActionTriggered: trigger_action is not a simple step action, falling back to onActionTriggered`,
206
+ );
207
+ onActionTriggered?.(actionId);
208
+ return;
209
+ }
210
+
211
+ const nextStep = workflow.steps[triggerAction.step_id];
212
+ if (!nextStep) {
213
+ console.warn(
214
+ `[Workflow] onActionTriggered: step_id "${triggerAction.step_id}" not found in workflow steps`,
215
+ );
99
216
  return;
100
217
  }
101
- navStack = [...navStack, pageId];
218
+
219
+ const nextPageId = nextStep.screen_id;
220
+ if (!nextPageId) {
221
+ console.warn(
222
+ `[Workflow] onActionTriggered: step "${triggerAction.step_id}" has no screen_id (logic/experiment step?)`,
223
+ );
224
+ return;
225
+ }
226
+
227
+ if (!workflow.pages[nextPageId]) {
228
+ console.warn(
229
+ `[Workflow] onActionTriggered: screen_id "${nextPageId}" not found in workflow pages`,
230
+ );
231
+ return;
232
+ }
233
+
234
+ const fromStepId = current.stepId;
235
+ const toStepId = triggerAction.step_id;
236
+ emitStepChange({
237
+ workflowId: workflow.workflowId,
238
+ stepId: fromStepId,
239
+ fromStepId: undefined,
240
+ toStepId,
241
+ reason: "completed",
242
+ ...stepFlags(fromStepId),
243
+ });
244
+ navStack = [...navStack, { pageId: nextPageId, stepId: toStepId }];
245
+ emitStepChange({
246
+ workflowId: workflow.workflowId,
247
+ stepId: toStepId,
248
+ fromStepId,
249
+ toStepId: undefined,
250
+ reason: "forward",
251
+ ...stepFlags(toStepId),
252
+ });
102
253
  }
103
254
 
104
255
  function handleBackClicked() {
105
256
  if (navStack.length > 1) {
257
+ const fromStepId = current.stepId;
258
+ const toStepId = navStack[navStack.length - 2].stepId;
259
+ emitStepChange({
260
+ workflowId: workflow.workflowId,
261
+ stepId: fromStepId,
262
+ fromStepId: undefined,
263
+ toStepId,
264
+ reason: "completed",
265
+ ...stepFlags(fromStepId),
266
+ });
106
267
  navStack = navStack.slice(0, -1);
268
+ emitStepChange({
269
+ workflowId: workflow.workflowId,
270
+ stepId: toStepId,
271
+ fromStepId,
272
+ toStepId: undefined,
273
+ reason: "back",
274
+ ...stepFlags(toStepId),
275
+ });
107
276
  } else {
108
277
  // Already at root — bubble up to the host.
278
+ emitStepChange({
279
+ workflowId: workflow.workflowId,
280
+ stepId: current.stepId,
281
+ fromStepId: undefined,
282
+ toStepId: undefined,
283
+ reason: "completed",
284
+ ...stepFlags(current.stepId),
285
+ });
109
286
  onExitBack?.();
110
287
  }
111
288
  }
112
289
  </script>
113
290
 
114
- {#key currentPageId}
291
+ {#key current.pageId}
115
292
  <Screen
116
293
  paywallComponents={currentScreen}
117
294
  {uiConfig}
@@ -124,7 +301,7 @@
124
301
  {walletButtonRender}
125
302
  {safeAreaFallbackColor}
126
303
  {onPurchaseClicked}
127
- {onActionTriggered}
304
+ onActionTriggered={handleActionTriggered}
128
305
  {onInputChanged}
129
306
  {onReservedAttributeChanged}
130
307
  {onCompleteWorkflowNavigate}
@@ -132,8 +309,17 @@
132
309
  {onRestorePurchasesClicked}
133
310
  {onVisitCustomerCenterClicked}
134
311
  {onComponentInteraction}
135
- onNavigateToPage={handleNavigateToPage}
136
312
  onBackClicked={handleBackClicked}
137
- {onClose}
313
+ onClose={() => {
314
+ emitStepChange({
315
+ workflowId: workflow.workflowId,
316
+ stepId: current.stepId,
317
+ fromStepId: undefined,
318
+ toStepId: undefined,
319
+ reason: "completed",
320
+ ...stepFlags(current.stepId),
321
+ });
322
+ onClose?.();
323
+ }}
138
324
  />
139
325
  {/key}
@@ -6,7 +6,7 @@ import type { VariableDictionary } from "../../types/variables";
6
6
  import type { WalletButtonRender } from "../../types/wallet";
7
7
  import type { UIConfig } from "../../types/ui-config";
8
8
  import type { ReservedAttribute } from "../../types/components/input-text";
9
- import type { WorkflowNavData } from "../../types/workflow-nav";
9
+ import type { WorkflowNavData, WorkflowStepChangeEvent } from "../../types/workflow-nav";
10
10
  interface Props {
11
11
  workflow: WorkflowNavData;
12
12
  uiConfig: UIConfig;
@@ -29,6 +29,12 @@ interface Props {
29
29
  onComponentInteraction?: OnComponentInteraction;
30
30
  onClose?: () => void;
31
31
  onExitBack?: () => void;
32
+ /**
33
+ * Called whenever the current workflow step changes — on initial load,
34
+ * forward navigation, back navigation, and when leaving a step (close,
35
+ * purchase, error). Used by the host to fire workflow lifecycle events.
36
+ */
37
+ onStepChanged?: (event: WorkflowStepChangeEvent) => void;
32
38
  }
33
39
  declare const Workflow: import("svelte").Component<Props, {}, "">;
34
40
  type Workflow = ReturnType<typeof Workflow>;
@@ -1,12 +1,10 @@
1
1
  import type { WorkflowNavData } from "../../../types/workflow-nav";
2
2
  /**
3
- * Two-page workflow fixture that exercises navigate_to_page, navigate_back,
4
- * and close_workflow button actions via the Workflow component.
3
+ * Two-page workflow fixture. The "Next" button uses a "workflow" action, whose
4
+ * destination is resolved client-side via the step's trigger_actions map.
5
5
  *
6
6
  * Page layout:
7
- * "rcpaywall_welcome" — welcome screen with a "Next →" navigate_to_page button
7
+ * "rcpaywall_welcome" — welcome screen with a "Next →" button
8
8
  * "rcpaywall_details" — details screen with "← Back" and "✕ Close" buttons
9
- *
10
- * Page IDs match a paywall rc_public_id as they would in a real SDK response.
11
9
  */
12
10
  export declare const TWO_PAGE_WORKFLOW: WorkflowNavData;
@@ -1,15 +1,49 @@
1
1
  /**
2
- * Two-page workflow fixture that exercises navigate_to_page, navigate_back,
3
- * and close_workflow button actions via the Workflow component.
2
+ * Two-page workflow fixture. The "Next" button uses a "workflow" action, whose
3
+ * destination is resolved client-side via the step's trigger_actions map.
4
4
  *
5
5
  * Page layout:
6
- * "rcpaywall_welcome" — welcome screen with a "Next →" navigate_to_page button
6
+ * "rcpaywall_welcome" — welcome screen with a "Next →" button
7
7
  * "rcpaywall_details" — details screen with "← Back" and "✕ Close" buttons
8
- *
9
- * Page IDs match a paywall rc_public_id as they would in a real SDK response.
10
8
  */
11
9
  export const TWO_PAGE_WORKFLOW = {
10
+ workflowId: "wf_two_page_fixture",
12
11
  initial_page_id: "rcpaywall_welcome",
12
+ initial_step_id: "step_welcome",
13
+ steps: {
14
+ step_welcome: {
15
+ id: "step_welcome",
16
+ screen_id: "rcpaywall_welcome",
17
+ type: "screen",
18
+ param_values: {},
19
+ trigger_actions: {
20
+ // "next_btn" is the action_id referenced by the triggers array entry
21
+ // for the "next_btn" button component. It resolves to step_details,
22
+ // whose screen_id is rcpaywall_details.
23
+ next_btn: { type: "step", step_id: "step_details" },
24
+ },
25
+ triggers: [
26
+ {
27
+ action_id: "next_btn",
28
+ component_id: "next_btn",
29
+ type: "on_press",
30
+ name: "Next button",
31
+ },
32
+ ],
33
+ outputs: {},
34
+ metadata: null,
35
+ },
36
+ step_details: {
37
+ id: "step_details",
38
+ screen_id: "rcpaywall_details",
39
+ type: "screen",
40
+ param_values: {},
41
+ trigger_actions: {},
42
+ triggers: [],
43
+ outputs: {},
44
+ metadata: null,
45
+ },
46
+ },
13
47
  pages: {
14
48
  rcpaywall_welcome: {
15
49
  id: "rcpaywall_welcome",
@@ -58,7 +92,6 @@ export const TWO_PAGE_WORKFLOW = {
58
92
  shadow: null,
59
93
  shape: null,
60
94
  components: [
61
- // ── Title ────────────────────────────────────────────────
62
95
  {
63
96
  type: "text",
64
97
  id: "welcome_title",
@@ -79,7 +112,6 @@ export const TWO_PAGE_WORKFLOW = {
79
112
  dark: { type: "hex", value: "transparent" },
80
113
  },
81
114
  },
82
- // ── Body ─────────────────────────────────────────────────
83
115
  {
84
116
  type: "text",
85
117
  id: "welcome_body",
@@ -100,15 +132,11 @@ export const TWO_PAGE_WORKFLOW = {
100
132
  dark: { type: "hex", value: "transparent" },
101
133
  },
102
134
  },
103
- // ── navigate_to_page button ───────────────────────────
104
135
  {
105
136
  type: "button",
106
137
  id: "next_btn",
107
138
  name: "Next",
108
- action: {
109
- type: "navigate_to_page",
110
- page_id: "rcpaywall_details",
111
- },
139
+ action: { type: "workflow" },
112
140
  stack: {
113
141
  type: "stack",
114
142
  id: "next_btn_stack",
@@ -121,7 +149,12 @@ export const TWO_PAGE_WORKFLOW = {
121
149
  },
122
150
  spacing: 0,
123
151
  margin: { top: 0, bottom: 0, leading: 24, trailing: 24 },
124
- padding: { top: 16, bottom: 16, leading: 24, trailing: 24 },
152
+ padding: {
153
+ top: 16,
154
+ bottom: 16,
155
+ leading: 24,
156
+ trailing: 24,
157
+ },
125
158
  background: {
126
159
  type: "color",
127
160
  value: {
@@ -187,8 +220,8 @@ export const TWO_PAGE_WORKFLOW = {
187
220
  background: {
188
221
  type: "color",
189
222
  value: {
190
- light: { type: "hex", value: "#fff4f0ff" },
191
- dark: { type: "hex", value: "#2e1a1aff" },
223
+ light: { type: "hex", value: "#f0fff4ff" },
224
+ dark: { type: "hex", value: "#1a2e1aff" },
192
225
  },
193
226
  },
194
227
  stack: {
@@ -211,7 +244,6 @@ export const TWO_PAGE_WORKFLOW = {
211
244
  shadow: null,
212
245
  shape: null,
213
246
  components: [
214
- // ── Title ────────────────────────────────────────────────
215
247
  {
216
248
  type: "text",
217
249
  id: "details_title",
@@ -224,15 +256,14 @@ export const TWO_PAGE_WORKFLOW = {
224
256
  margin: { top: 80, bottom: 16, leading: 24, trailing: 24 },
225
257
  padding: { top: 0, bottom: 0, leading: 0, trailing: 0 },
226
258
  color: {
227
- light: { type: "hex", value: "#2e1a1aff" },
228
- dark: { type: "hex", value: "#fff4f0ff" },
259
+ light: { type: "hex", value: "#1a2e1aff" },
260
+ dark: { type: "hex", value: "#f0fff4ff" },
229
261
  },
230
262
  background_color: {
231
263
  light: { type: "hex", value: "transparent" },
232
264
  dark: { type: "hex", value: "transparent" },
233
265
  },
234
266
  },
235
- // ── Body ─────────────────────────────────────────────────
236
267
  {
237
268
  type: "text",
238
269
  id: "details_body",
@@ -245,15 +276,14 @@ export const TWO_PAGE_WORKFLOW = {
245
276
  margin: { top: 0, bottom: 48, leading: 24, trailing: 24 },
246
277
  padding: { top: 0, bottom: 0, leading: 0, trailing: 0 },
247
278
  color: {
248
- light: { type: "hex", value: "#775555ff" },
249
- dark: { type: "hex", value: "#ccaaaaff" },
279
+ light: { type: "hex", value: "#337733ff" },
280
+ dark: { type: "hex", value: "#88cc88ff" },
250
281
  },
251
282
  background_color: {
252
283
  light: { type: "hex", value: "transparent" },
253
284
  dark: { type: "hex", value: "transparent" },
254
285
  },
255
286
  },
256
- // ── navigate_back button ──────────────────────────────
257
287
  {
258
288
  type: "button",
259
289
  id: "back_btn",
@@ -271,7 +301,12 @@ export const TWO_PAGE_WORKFLOW = {
271
301
  },
272
302
  spacing: 0,
273
303
  margin: { top: 0, bottom: 12, leading: 24, trailing: 24 },
274
- padding: { top: 16, bottom: 16, leading: 24, trailing: 24 },
304
+ padding: {
305
+ top: 16,
306
+ bottom: 16,
307
+ leading: 24,
308
+ trailing: 24,
309
+ },
275
310
  background: {
276
311
  type: "color",
277
312
  value: {
@@ -314,7 +349,6 @@ export const TWO_PAGE_WORKFLOW = {
314
349
  ],
315
350
  },
316
351
  },
317
- // ── close button ──────────────────────────────────────
318
352
  {
319
353
  type: "button",
320
354
  id: "close_btn",
@@ -332,7 +366,12 @@ export const TWO_PAGE_WORKFLOW = {
332
366
  },
333
367
  spacing: 0,
334
368
  margin: { top: 0, bottom: 40, leading: 24, trailing: 24 },
335
- padding: { top: 16, bottom: 16, leading: 24, trailing: 24 },
369
+ padding: {
370
+ top: 16,
371
+ bottom: 16,
372
+ leading: 24,
373
+ trailing: 24,
374
+ },
336
375
  background: {
337
376
  type: "color",
338
377
  value: {
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export { default as Workflow } from "./components/workflows/Workflow.svelte";
17
17
  export { default as Video } from "./components/video/Video.svelte";
18
18
  export * from "./types";
19
19
  export { type PaywallData } from "./types/paywall";
20
- export { type WorkflowNavData, workflowDataToNavData, } from "./types/workflow-nav";
20
+ export { type WorkflowNavData, type WorkflowStepChangeEvent, workflowDataToNavData, } from "./types/workflow-nav";
21
21
  export { type WorkflowData, type WorkflowStep } from "./types/workflow";
22
22
  export { type InitialInputSelections } from "./stores/inputValidation";
23
23
  export { type UIConfig } from "./types/ui-config";
@@ -11,10 +11,6 @@ interface RestorePurchasesAction {
11
11
  interface NavigateBackAction {
12
12
  type: "navigate_back";
13
13
  }
14
- interface NavigateToPageAction {
15
- type: "navigate_to_page";
16
- page_id: string;
17
- }
18
14
  interface CloseAction {
19
15
  type: "close_workflow";
20
16
  }
@@ -60,15 +56,12 @@ interface CompleteWorkflowAction {
60
56
  };
61
57
  url_query_params?: CompleteWorkflowUrlQueryParams;
62
58
  }
63
- export type Action = WorkflowAction | RestorePurchasesAction | NavigateBackAction | NavigateToPageAction | CloseAction | NavigateToAction | NavigateToSheetAction | NavigateToWebPurchase | NavigateToUrlAction | CompleteWorkflowAction;
59
+ export type Action = WorkflowAction | RestorePurchasesAction | NavigateBackAction | CloseAction | NavigateToAction | NavigateToSheetAction | NavigateToWebPurchase | NavigateToUrlAction | CompleteWorkflowAction;
64
60
  export interface ButtonProps extends BaseComponent {
65
61
  type: "button";
66
62
  action: Action;
67
63
  stack: StackProps;
68
64
  transition?: null;
69
- triggers?: {
70
- on_press?: string;
71
- };
72
65
  overrides?: Overrides<ButtonProps>;
73
66
  }
74
67
  export {};
@@ -16,7 +16,7 @@ interface PackageTransition {
16
16
  destinationPackageId: string;
17
17
  defaultPackageId?: string;
18
18
  }
19
- type ButtonInteractionValue = "workflow" | "navigate_to_url" | "navigate_back" | "navigate_to_page" | "close_workflow" | "restore_purchases" | "navigate_to_customer_center" | "screen_redirect" | "navigate_to_privacy_policy" | "navigate_to_terms" | "navigate_to_sheet" | "navigate_to_offer_code" | "navigate_to_web_paywall_link";
19
+ type ButtonInteractionValue = "workflow" | "navigate_to_url" | "navigate_back" | "close_workflow" | "restore_purchases" | "navigate_to_customer_center" | "screen_redirect" | "navigate_to_privacy_policy" | "navigate_to_terms" | "navigate_to_sheet" | "navigate_to_offer_code" | "navigate_to_web_paywall_link";
20
20
  export type TabInteractionData = ComponentInteractionBase<"tab"> & IndexedTransition;
21
21
  export type SwitchInteractionData = ComponentInteractionBase<"switch", "on" | "off">;
22
22
  export type CarouselInteractionData = ComponentInteractionBase<"carousel"> & IndexedTransition;
@@ -1,27 +1,50 @@
1
- import type { WorkflowData, WorkflowScreen } from "./workflow";
1
+ import type { WorkflowData, WorkflowScreen, WorkflowStep } from "./workflow";
2
+ export interface WorkflowStepChangeEvent {
3
+ workflowId: string;
4
+ stepId: string;
5
+ fromStepId: string | undefined;
6
+ toStepId: string | undefined;
7
+ isFirstStep: boolean;
8
+ isLastStep: boolean;
9
+ /**
10
+ * Why this event was emitted:
11
+ * - `"start"` / `"forward"` / `"back"` — step is being entered (fire step_started)
12
+ * - `"completed"` — step is being left (fire step_completed); toStepId is set
13
+ * for forward nav, undefined when the workflow is dismissed/closed.
14
+ */
15
+ reason: "start" | "forward" | "back" | "completed";
16
+ }
2
17
  /**
3
18
  * A self-contained multi-page workflow payload for use with the Workflow
4
19
  * component. This mirrors the shape of rc-workflows' WorkflowData but only
5
- * includes the fields needed for client-side page navigation — it does NOT
6
- * include step trigger_actions or experiment logic, which remain rc-workflows
7
- * concerns.
20
+ * includes the fields needed for client-side page navigation.
8
21
  */
9
22
  export interface WorkflowNavData {
23
+ /** The workflow's unique identifier. */
24
+ workflowId: string;
10
25
  /** ID of the first page to display. Must be a key in `pages`. */
11
26
  initial_page_id: string;
12
27
  /**
13
- * All pages in this workflow keyed by page ID (the screen's rc_public_id).
14
- * `navigate_to_page` actions reference these IDs directly.
28
+ * All pages in this workflow keyed by screen ID.
29
+ * Workflow step actions resolve to these IDs via `steps[stepId].screen_id`.
15
30
  */
16
31
  pages: Record<string, WorkflowScreen>;
32
+ /**
33
+ * All steps in this workflow keyed by step ID.
34
+ * Used to resolve `workflow`-type button actions: the button's component ID
35
+ * is looked up in the current step's `triggers` array to get an action_id,
36
+ * which maps to a `trigger_actions` entry pointing to the next step_id,
37
+ * whose screen_id is then used to navigate to the correct page.
38
+ */
39
+ steps: Record<string, WorkflowStep>;
40
+ /** The step ID of the first step, corresponding to `initial_page_id`. */
41
+ initial_step_id: string;
17
42
  }
18
43
  /**
19
44
  * Map a full `WorkflowData` SDK response to the `WorkflowNavData` shape
20
45
  * consumed by the `Workflow` component.
21
46
  *
22
- * - `pages` is taken directly from `WorkflowData.screens` (already keyed by
23
- * screen/paywall rc_public_id, which is what `navigate_to_page` page_ids
24
- * reference).
47
+ * - `pages` is taken directly from `WorkflowData.screens`, keyed by screen ID.
25
48
  * - `initial_page_id` is resolved by looking up the screen_id of the initial
26
49
  * step, since `initial_step_id` is a step ID, not a screen ID.
27
50
  *
@@ -2,9 +2,7 @@
2
2
  * Map a full `WorkflowData` SDK response to the `WorkflowNavData` shape
3
3
  * consumed by the `Workflow` component.
4
4
  *
5
- * - `pages` is taken directly from `WorkflowData.screens` (already keyed by
6
- * screen/paywall rc_public_id, which is what `navigate_to_page` page_ids
7
- * reference).
5
+ * - `pages` is taken directly from `WorkflowData.screens`, keyed by screen ID.
8
6
  * - `initial_page_id` is resolved by looking up the screen_id of the initial
9
7
  * step, since `initial_step_id` is a step ID, not a screen ID.
10
8
  *
@@ -26,9 +24,12 @@ export function workflowDataToNavData(data) {
26
24
  return null;
27
25
  }
28
26
  return {
27
+ workflowId: data.id,
29
28
  initial_page_id: initialPageId,
30
- // Shared by reference intentionally — screens are never mutated after
29
+ initial_step_id: data.initial_step_id,
30
+ // Shared by reference intentionally — screens/steps are never mutated after
31
31
  // WorkflowData is received from the SDK, so a copy would be wasteful.
32
32
  pages: data.screens,
33
+ steps: data.steps,
33
34
  };
34
35
  }
@@ -13,6 +13,27 @@ export interface WorkflowScreen extends PaywallData {
13
13
  revision: number;
14
14
  template_name: string;
15
15
  }
16
+ /**
17
+ * A trigger action that navigates directly to another step.
18
+ */
19
+ export interface WorkflowStepTriggerAction {
20
+ type: "step";
21
+ step_id: string;
22
+ }
23
+ /**
24
+ * An entry in a step's `triggers` array that maps a component interaction
25
+ * (e.g. button press) to a named action_id, which in turn maps to a
26
+ * `trigger_actions` entry.
27
+ */
28
+ export interface WorkflowStepTrigger {
29
+ /** The action ID to look up in `trigger_actions`. */
30
+ action_id: string;
31
+ /** The component (e.g. button) id that fires this trigger. */
32
+ component_id: string;
33
+ /** The interaction type, e.g. "on_press". */
34
+ type: string;
35
+ name?: string;
36
+ }
16
37
  /**
17
38
  * A single step in a workflow, referencing a screen by ID.
18
39
  * Aligns with the SDK response shape from khepri.
@@ -22,8 +43,19 @@ export interface WorkflowStep {
22
43
  screen_id?: string;
23
44
  type: string;
24
45
  param_values: Record<string, unknown>;
25
- trigger_actions: Record<string, unknown>;
26
- triggers: Record<string, unknown>;
46
+ /**
47
+ * Maps action IDs (from `triggers[].action_id`) to the next step to navigate
48
+ * to. Currently only `type: "step"` is supported for client-side resolution;
49
+ * condition-based actions are ignored.
50
+ */
51
+ trigger_actions: Record<string, WorkflowStepTriggerAction | Record<string, unknown>>;
52
+ /**
53
+ * Array of trigger entries mapping component interactions to action IDs.
54
+ * Resolve a button press by matching its component `id` against
55
+ * `component_id`, then use the entry's `action_id` to look up
56
+ * `trigger_actions`.
57
+ */
58
+ triggers: WorkflowStepTrigger[];
27
59
  outputs: Record<string, unknown>;
28
60
  metadata: Record<string, unknown> | null;
29
61
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@revenuecat/purchases-ui-js",
3
3
  "description": "Web components for Paywalls. Powered by RevenueCat",
4
4
  "private": false,
5
- "version": "4.7.6",
5
+ "version": "4.8.1",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },