@revenuecat/purchases-ui-js 4.8.18 → 4.8.20

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.
@@ -12,6 +12,7 @@
12
12
  } from "../../utils/base-utils";
13
13
  import { resolveOverrideProperties } from "../../utils/style-utils";
14
14
  import ClipPath from "./ClipPath.svelte";
15
+ import { viewBoxSize } from "./view-box-size";
15
16
  import Overlay from "./Overlay.svelte";
16
17
 
17
18
  const props: ImageProps = $props();
@@ -87,13 +88,24 @@
87
88
  padding: mapSpacing(padding),
88
89
  "line-height": 0,
89
90
  "flex-shrink": size.width.type === "fixed" ? 0 : 1,
91
+ // min-height: 0 so a seeded viewBox cannot become a flex min-content
92
+ // square that eats the rest of a space-between stack (body copy).
93
+ ...(size.width.type === "fixed"
94
+ ? {}
95
+ : { "min-width": 0, "min-height": 0 }),
90
96
  }),
91
97
  );
92
98
 
93
99
  let svgRect = $state<DOMRect | null>(null);
94
100
 
95
101
  const [svgWidth, svgHeight] = $derived.by(() => {
96
- return [svgRect?.width ?? 0, svgRect?.height ?? 0];
102
+ // Hug-content on either axis: wait for contentRect. Seeding fill-width ×
103
+ // fit-height from wrapperWidth * aspect locks a column-width square.
104
+ if (size.width.type === "fit" || size.height.type === "fit") {
105
+ return [svgRect?.width ?? 0, svgRect?.height ?? 0];
106
+ }
107
+
108
+ return viewBoxSize(svgRect, wrapperWidth, height);
97
109
  });
98
110
 
99
111
  const viewBox = $derived.by(() => {
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import { readable, writable } from "svelte/store";
3
+ import { setColorModeContext } from "../../stores/color-mode";
4
+ import { setPaywallContext } from "../../stores/paywall";
5
+ import Image from "./Image.svelte";
6
+ import type { ImageProps } from "../../types/components/image";
7
+
8
+ const props: ImageProps = $props();
9
+
10
+ setColorModeContext();
11
+ setPaywallContext({
12
+ selectedPackageId: writable(undefined),
13
+ variablesPerPackage: readable(undefined),
14
+ baseVariables: readable(undefined),
15
+ infoPerPackage: readable(undefined),
16
+ onPurchase: () => {},
17
+ emitComponentInteraction: () => {},
18
+ onButtonAction: () => {},
19
+ uiConfig: { app: { fonts: {} } } as never,
20
+ hideBackButtons: false,
21
+ });
22
+ </script>
23
+
24
+ <Image {...props} />
@@ -0,0 +1,4 @@
1
+ import type { ImageProps } from "../../types/components/image";
2
+ declare const ImageTestWrapper: import("svelte").Component<ImageProps, {}, "">;
3
+ type ImageTestWrapper = ReturnType<typeof ImageTestWrapper>;
4
+ export default ImageTestWrapper;
@@ -0,0 +1,14 @@
1
+ /** First strictly positive dimension, else 0. Never invent a 1px axis — that becomes a 1:1 viewBox and blows up flex layout. */
2
+ export declare function viewBoxDimension(...values: (number | undefined)[]): number;
3
+ /**
4
+ * Prefer measured contentRect when both axes are known; otherwise seed width/height
5
+ * together from layout (wrapper size / computed height). Seed only when BOTH fallbacks
6
+ * are positive — a single `1` placeholder gives the SVG a real aspect ratio (SSR
7
+ * `height: NaNpx` + `viewBox="0 0 1 1"` → column-width square, body copy squeezed).
8
+ * Do not use natural media dimensions here — that breaks fit-content images whose
9
+ * box is still 0px wide.
10
+ */
11
+ export declare function viewBoxSize(measured: {
12
+ width?: number;
13
+ height?: number;
14
+ } | null | undefined, fallbackWidth: number | undefined, fallbackHeight: number | undefined): [number, number];
@@ -0,0 +1,30 @@
1
+ /** First strictly positive dimension, else 0. Never invent a 1px axis — that becomes a 1:1 viewBox and blows up flex layout. */
2
+ export function viewBoxDimension(...values) {
3
+ for (const value of values) {
4
+ if (value != null && value > 0) {
5
+ return value;
6
+ }
7
+ }
8
+ return 0;
9
+ }
10
+ /**
11
+ * Prefer measured contentRect when both axes are known; otherwise seed width/height
12
+ * together from layout (wrapper size / computed height). Seed only when BOTH fallbacks
13
+ * are positive — a single `1` placeholder gives the SVG a real aspect ratio (SSR
14
+ * `height: NaNpx` + `viewBox="0 0 1 1"` → column-width square, body copy squeezed).
15
+ * Do not use natural media dimensions here — that breaks fit-content images whose
16
+ * box is still 0px wide.
17
+ */
18
+ export function viewBoxSize(measured, fallbackWidth, fallbackHeight) {
19
+ const measuredWidth = measured?.width ?? 0;
20
+ const measuredHeight = measured?.height ?? 0;
21
+ if (measuredWidth > 0 && measuredHeight > 0) {
22
+ return [measuredWidth, measuredHeight];
23
+ }
24
+ const width = viewBoxDimension(fallbackWidth);
25
+ const height = viewBoxDimension(fallbackHeight);
26
+ if (width > 0 && height > 0) {
27
+ return [width, height];
28
+ }
29
+ return [0, 0];
30
+ }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import Node from "../paywall/Node.svelte";
3
+ import { asBridgeInputArray } from "../web-view/build-paywall-context";
3
4
  import {
4
5
  createInputChoiceContext,
5
6
  setInputChoiceContext,
@@ -8,6 +9,7 @@
8
9
  getInputValidationContext,
9
10
  getInitialInputSelectionsContext,
10
11
  } from "../../stores/inputValidation";
12
+ import { getPaywallContext } from "../../stores/paywall";
11
13
  import type { InputMultipleChoiceProps } from "../../types/components/options";
12
14
  import { onDestroy } from "svelte";
13
15
 
@@ -26,6 +28,18 @@
26
28
  );
27
29
  setInputChoiceContext(inputChoiceContext);
28
30
 
31
+ // Keep this field a string[] so handleInputChanged toggles option_id
32
+ // instead of replacing the whole selection (including on deselect).
33
+ // Optional: stories and isolated mounts have no paywall host.
34
+ try {
35
+ getPaywallContext().bridgeInputs?.update((current) => ({
36
+ ...current,
37
+ [field_id]: asBridgeInputArray(current[field_id] ?? initialSelectedIds),
38
+ }));
39
+ } catch {
40
+ // Paywall context not found
41
+ }
42
+
29
43
  const validationContext = getInputValidationContext();
30
44
  if (validationContext) {
31
45
  const unsubscribe = inputChoiceContext.isSatisfied.subscribe(
@@ -43,7 +43,10 @@
43
43
  import { STICKY_OVERLAY_Z_INDEX } from "../../utils/constants";
44
44
  import { applyDocumentBackground } from "../../utils/document-background";
45
45
  import { registerFonts } from "../../utils/font-utils";
46
- import { findSelectedPackageId } from "../../utils/style-utils";
46
+ import {
47
+ collectPackageIdsInDisplayOrder,
48
+ findSelectedPackageId,
49
+ } from "../../utils/style-utils";
47
50
  import { onMount } from "svelte";
48
51
  import { derived, readable, writable } from "svelte/store";
49
52
  import Stack from "../stack/Stack.svelte";
@@ -56,6 +59,17 @@
56
59
  } from "../../stores/packageInfo";
57
60
  import type { WalletButtonRender } from "../../types/wallet";
58
61
  import type { ReservedAttribute } from "../../types/components/input-text";
62
+ import type {
63
+ PaywallOffering,
64
+ PaywallPackage,
65
+ PaywallWorkflow,
66
+ } from "@revenuecat/workflow-web-components-sdk";
67
+ import {
68
+ applyBridgeInputChange,
69
+ buildBridgeInputs,
70
+ typedCustomVariables,
71
+ type BridgeInputs,
72
+ } from "../web-view/build-paywall-context";
59
73
 
60
74
  /**
61
75
  * Props are captured once at mount and are not reactive to subsequent changes.
@@ -130,6 +144,11 @@
130
144
  * Hosts (e.g. workflow runtimes) pass their workflow-level fallback here.
131
145
  */
132
146
  safeAreaFallbackColor?: ColorScheme | null;
147
+ offering?: PaywallOffering | null;
148
+ packages?: PaywallPackage[];
149
+ workflow?: PaywallWorkflow;
150
+ isPreview?: boolean;
151
+ inputs?: BridgeInputs;
133
152
  }
134
153
 
135
154
  const {
@@ -159,6 +178,11 @@
159
178
  initialInputSelections = {},
160
179
  customVariables = {},
161
180
  safeAreaFallbackColor,
181
+ offering = null,
182
+ packages = [],
183
+ workflow,
184
+ isPreview = false,
185
+ inputs = {},
162
186
  }: Props = $props();
163
187
 
164
188
  const getColorMode = setColorModeContext(() => preferredColorMode);
@@ -305,22 +329,59 @@
305
329
  }
306
330
  };
307
331
 
332
+ // Merge this component's own `customVariables` prop with the dashboard
333
+ // defaults. On the standalone path, this is the only source of custom
334
+ // variable values. On the workflow path, the host has already merged its
335
+ // `customVariables` into `globalVariables` — that merge must win here, or
336
+ // this component's defaults-only merge (since `customVariables` is never
337
+ // passed down by the workflow) silently overwrites the host's overrides
338
+ // for any variable that also has a declared dashboard default.
308
339
  const mergedCustomVars = mergeCustomVariables(customVariables, uiConfig);
340
+ const typedCustom = typedCustomVariables(customVariables, uiConfig);
341
+ const packageIdsInDisplayOrder = collectPackageIdsInDisplayOrder(base);
342
+ const bridgeInputs = writable(
343
+ buildBridgeInputs({
344
+ inputs,
345
+ initialInputSelections,
346
+ globalVariables,
347
+ }),
348
+ );
349
+
350
+ const handleInputChanged = (
351
+ fieldId: string,
352
+ value: string,
353
+ actionId?: string,
354
+ ) => {
355
+ // InputOption emits the clicked option_id, not the full field. Toggle
356
+ // when the field is already a string[] (multi-choice); replace otherwise.
357
+ bridgeInputs.update((current) =>
358
+ applyBridgeInputChange(current, fieldId, value),
359
+ );
360
+ onInputChanged?.(fieldId, value, actionId);
361
+ };
309
362
 
310
363
  setPaywallContext({
311
364
  defaultPackageId,
312
365
  selectedPackageId,
313
366
  variablesPerPackage: readable(variablesPerPackage),
314
367
  baseVariables: readable({
315
- ...globalVariables,
316
368
  ...mergedCustomVars,
369
+ ...globalVariables,
317
370
  }),
318
371
  infoPerPackage: readable(infoPerPackage),
372
+ offering,
373
+ contextPackages: packages,
374
+ packageIdsInDisplayOrder,
375
+ workflow,
376
+ isPreview,
377
+ selectedLocale: selectedLocale ?? default_locale,
378
+ typedCustom,
379
+ bridgeInputs,
319
380
  onPurchase,
320
381
  emitComponentInteraction,
321
382
  onNavigateToUrl: onNavigateToUrlClicked,
322
383
  onButtonAction,
323
- onInputChanged,
384
+ onInputChanged: handleInputChanged,
324
385
  onSensitiveInputChanged,
325
386
  onReservedAttributeChanged,
326
387
  walletButtonRender,
@@ -341,8 +402,8 @@
341
402
  const variables: VariablesStore = derived(selectedPackageId, (packageId) => {
342
403
  const packageVars = variablesPerPackage[packageId || ""];
343
404
  return {
344
- ...globalVariables,
345
405
  ...mergedCustomVars,
406
+ ...globalVariables,
346
407
  ...packageVars,
347
408
  };
348
409
  });
@@ -8,6 +8,8 @@ import type { UIConfig } from "../../types/ui-config";
8
8
  import { type CustomVariables, type PackageInfo, type VariableDictionary } from "../../types/variables";
9
9
  import type { WalletButtonRender } from "../../types/wallet";
10
10
  import type { ReservedAttribute } from "../../types/components/input-text";
11
+ import type { PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
12
+ import { type BridgeInputs } from "../web-view/build-paywall-context";
11
13
  /**
12
14
  * Props are captured once at mount and are not reactive to subsequent changes.
13
15
  * The paywall should be remounted to reflect new prop values.
@@ -72,6 +74,11 @@ interface Props {
72
74
  * Hosts (e.g. workflow runtimes) pass their workflow-level fallback here.
73
75
  */
74
76
  safeAreaFallbackColor?: ColorScheme | null;
77
+ offering?: PaywallOffering | null;
78
+ packages?: PaywallPackage[];
79
+ workflow?: PaywallWorkflow;
80
+ isPreview?: boolean;
81
+ inputs?: BridgeInputs;
75
82
  }
76
83
  declare const Paywall: import("svelte").Component<Props, {}, "">;
77
84
  type Paywall = ReturnType<typeof Paywall>;
@@ -19,6 +19,7 @@
19
19
  subscribeDecorativeVideoAutoplayAttempts,
20
20
  } from "../../utils/video-inline-playback";
21
21
  import ClipPath from "../image/ClipPath.svelte";
22
+ import { viewBoxSize } from "../image/view-box-size";
22
23
  import Overlay from "../image/Overlay.svelte";
23
24
 
24
25
  const props: VideoProps = $props();
@@ -157,13 +158,20 @@
157
158
  padding: mapSpacing(padding),
158
159
  "line-height": 0,
159
160
  "flex-shrink": size.width.type === "fixed" ? 0 : 1,
161
+ ...(size.width.type === "fixed"
162
+ ? {}
163
+ : { "min-width": 0, "min-height": 0 }),
160
164
  }),
161
165
  );
162
166
 
163
167
  let svgRect = $state<DOMRect | null>(null);
164
168
 
165
169
  const [svgWidth, svgHeight] = $derived.by(() => {
166
- return [svgRect?.width ?? 0, svgRect?.height ?? 0];
170
+ if (size.width.type === "fit" || size.height.type === "fit") {
171
+ return [svgRect?.width ?? 0, svgRect?.height ?? 0];
172
+ }
173
+
174
+ return viewBoxSize(svgRect, wrapperWidth, height);
167
175
  });
168
176
 
169
177
  const viewBox = $derived.by(() => {
@@ -11,6 +11,14 @@
11
11
  RESIZE_MESSAGE,
12
12
  } from "./web-view-sdk";
13
13
  import type { ResizePayload, WebViewHostBridge } from "./web-view-sdk";
14
+ import { getPaywallContext } from "../../stores/paywall";
15
+ import { getOptionalPackageIdContext } from "../../stores/packageId";
16
+ import { getColorModeContext } from "../../stores/color-mode";
17
+ import { fromStore } from "svelte/store";
18
+ import {
19
+ buildPaywallContext,
20
+ contextFingerprint,
21
+ } from "./build-paywall-context";
14
22
 
15
23
  // Placeholder for a `fit` axis until the content reports its size — a cross-origin
16
24
  // iframe exposes no intrinsic size, so `fit` would otherwise collapse to nothing.
@@ -27,6 +35,32 @@
27
35
 
28
36
  const props: WebViewProps = $props();
29
37
  const getParentStackDimension = getStackDimensionContext();
38
+ const paywall = getPaywallContext();
39
+ const enclosingPackageIdStore = getOptionalPackageIdContext();
40
+ const getColorMode = getColorModeContext();
41
+ const selectedPackageId = fromStore(paywall.selectedPackageId);
42
+ const enclosingPackageId = enclosingPackageIdStore
43
+ ? fromStore(enclosingPackageIdStore)
44
+ : null;
45
+ const liveInputs = paywall.bridgeInputs
46
+ ? fromStore(paywall.bridgeInputs)
47
+ : null;
48
+ const darkMode = $derived(getColorMode() === "dark");
49
+
50
+ const paywallContextSnapshot = () =>
51
+ buildPaywallContext({
52
+ custom: paywall.typedCustom,
53
+ offering: paywall.offering,
54
+ packages: paywall.contextPackages,
55
+ packageIdsInDisplayOrder: paywall.packageIdsInDisplayOrder,
56
+ selectedPackageId: selectedPackageId.current,
57
+ enclosingPackageId: enclosingPackageId?.current,
58
+ inputs: liveInputs?.current,
59
+ workflow: paywall.workflow,
60
+ locale: paywall.selectedLocale,
61
+ darkMode,
62
+ isPreview: paywall.isPreview,
63
+ });
30
64
 
31
65
  // Gate the origin guard on a post-mount flag (not `typeof window`) so SSR and the
32
66
  // first client render match; otherwise a cross-origin URL mismatches on hydration.
@@ -106,6 +140,8 @@
106
140
  // to the plain iframe.
107
141
  let iframeEl = $state<HTMLIFrameElement | undefined>(undefined);
108
142
  let previousSafeUrl: string | null | undefined;
143
+ let liveBridge = $state<WebViewHostBridge | undefined>(undefined);
144
+ let lastFingerprint = $state<string | undefined>(undefined);
109
145
 
110
146
  $effect(() => {
111
147
  const url = safeUrl;
@@ -119,7 +155,9 @@
119
155
  // Read every prop the bridge is configured with synchronously: Svelte only
120
156
  // tracks reads in the effect body, not inside the async `.then` below, so
121
157
  // capturing them here re-runs the effect (rebuilding the bridge) when the
122
- // component id or `fit` axes change in place.
158
+ // component id or `fit` axes change in place. Do not read package selection
159
+ // or other snapshot fields here — those update via `setContext` without
160
+ // tearing down the iframe.
123
161
  const componentId = props.id;
124
162
  const protocolVersion = props.protocol_version;
125
163
  const widthFit = props.size.width.type === "fit";
@@ -133,12 +171,16 @@
133
171
  .then((createHostBridge) => {
134
172
  if (disposed) return;
135
173
  try {
174
+ const seed = paywallContextSnapshot();
136
175
  bridge = createHostBridge({
137
176
  iframe,
138
177
  allowedOrigin: new URL(url).origin,
139
178
  componentId,
140
179
  protocolVersion,
180
+ context: seed,
141
181
  });
182
+ liveBridge = bridge;
183
+ lastFingerprint = contextFingerprint(seed);
142
184
  // Tell the content which axes are `fit` so it hides its own scrollbar there
143
185
  // (it would otherwise flash one while we catch up to a new size).
144
186
  if (widthFit || heightFit) {
@@ -166,8 +208,23 @@
166
208
  return () => {
167
209
  disposed = true;
168
210
  bridge?.destroy();
211
+ if (liveBridge === bridge) {
212
+ liveBridge = undefined;
213
+ }
169
214
  };
170
215
  });
216
+
217
+ // Later snapshots replace the whole context. Compare without `updated_at` so
218
+ // the init seed is not also posted as a `context` message.
219
+ $effect(() => {
220
+ const next = paywallContextSnapshot();
221
+ const currentBridge = liveBridge;
222
+ if (!currentBridge?.setContext) return;
223
+ const fingerprint = contextFingerprint(next);
224
+ if (fingerprint === lastFingerprint) return;
225
+ lastFingerprint = fingerprint;
226
+ currentBridge.setContext(next);
227
+ });
171
228
  </script>
172
229
 
173
230
  {#if isVisible && safeUrl && canEmbed}
@@ -7,22 +7,36 @@
7
7
  type StackDimension,
8
8
  } from "../stack/stack-dimension-context";
9
9
  import type { WebViewProps } from "../../types/components/web-view";
10
- import { readable, writable } from "svelte/store";
10
+ import type {
11
+ PaywallOffering,
12
+ PaywallPackage,
13
+ } from "@revenuecat/workflow-web-components-sdk";
14
+ import { readable, writable, type Writable } from "svelte/store";
11
15
  import WebView from "./WebView.svelte";
12
16
 
13
17
  const {
14
18
  parentStackDimension,
19
+ selectedPackageId = writable(undefined),
20
+ contextPackages,
21
+ offering,
15
22
  ...props
16
- }: WebViewProps & { parentStackDimension?: StackDimension } = $props();
23
+ }: WebViewProps & {
24
+ parentStackDimension?: StackDimension;
25
+ selectedPackageId?: Writable<string | undefined>;
26
+ contextPackages?: PaywallPackage[];
27
+ offering?: PaywallOffering | null;
28
+ } = $props();
17
29
 
18
30
  setStackDimensionContext(() => parentStackDimension);
19
31
 
20
32
  setPaywallContext({
21
33
  defaultPackageId: undefined,
22
- selectedPackageId: writable(undefined),
34
+ selectedPackageId,
23
35
  variablesPerPackage: readable(undefined),
24
36
  baseVariables: readable(undefined),
25
37
  infoPerPackage: readable(undefined),
38
+ offering,
39
+ contextPackages,
26
40
  onPurchase: () => {},
27
41
  emitComponentInteraction: () => {},
28
42
  onButtonAction: () => {},
@@ -1,7 +1,12 @@
1
1
  import { type StackDimension } from "../stack/stack-dimension-context";
2
2
  import type { WebViewProps } from "../../types/components/web-view";
3
+ import type { PaywallOffering, PaywallPackage } from "@revenuecat/workflow-web-components-sdk";
4
+ import { type Writable } from "svelte/store";
3
5
  type $$ComponentProps = WebViewProps & {
4
6
  parentStackDimension?: StackDimension;
7
+ selectedPackageId?: Writable<string | undefined>;
8
+ contextPackages?: PaywallPackage[];
9
+ offering?: PaywallOffering | null;
5
10
  };
6
11
  declare const WebViewTestWrapper: import("svelte").Component<$$ComponentProps, {}, "">;
7
12
  type WebViewTestWrapper = ReturnType<typeof WebViewTestWrapper>;
@@ -0,0 +1,45 @@
1
+ import type { InitialInputSelections } from "../../stores/inputValidation";
2
+ import type { UIConfig } from "../../types/ui-config";
3
+ import type { CustomVariables, VariableDictionary } from "../../types/variables";
4
+ import type { PaywallContext as BridgePaywallContext, PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
5
+ export type BridgeCustomValues = Record<string, string | number | boolean>;
6
+ export type BridgeInputs = Record<string, string | string[] | null>;
7
+ export interface BuildPaywallContextParams {
8
+ /** Pre-merged custom values. When set, `customVariables` / `uiConfig` are ignored. */
9
+ custom?: BridgeCustomValues;
10
+ customVariables?: CustomVariables;
11
+ uiConfig?: UIConfig;
12
+ offering?: PaywallOffering | null;
13
+ packages?: PaywallPackage[];
14
+ packageIdsInDisplayOrder?: string[];
15
+ selectedPackageId?: string;
16
+ enclosingPackageId?: string;
17
+ inputs?: BridgeInputs;
18
+ initialInputSelections?: InitialInputSelections;
19
+ globalVariables?: VariableDictionary;
20
+ workflow?: PaywallWorkflow;
21
+ locale?: string;
22
+ darkMode?: boolean;
23
+ isPreview?: boolean;
24
+ updatedAt?: number;
25
+ }
26
+ /** Merge dashboard custom-variable defaults with typed runtime overrides. */
27
+ export declare function typedCustomVariables(customVariables: CustomVariables | undefined, uiConfig: UIConfig | undefined): BridgeCustomValues;
28
+ /**
29
+ * Bare field ids. Host `inputs` win, then current-screen selections, then
30
+ * `input.*` keys from global variables (stringly, from earlier steps).
31
+ */
32
+ export declare function buildBridgeInputs({ inputs, initialInputSelections, globalVariables, }: Pick<BuildPaywallContextParams, "inputs" | "initialInputSelections" | "globalVariables">): BridgeInputs;
33
+ /** Normalize a stored input to a choice-field `string[]`. */
34
+ export declare function asBridgeInputArray(value: string | string[] | null | undefined): string[];
35
+ /**
36
+ * Apply an `onInputChanged` payload to `bridgeInputs`.
37
+ * Choice options emit a single `option_id`: toggle when the field is already
38
+ * a `string[]` (multi-choice), otherwise replace (text / single-choice).
39
+ */
40
+ export declare function applyBridgeInputChange(current: BridgeInputs, fieldId: string, value: string): BridgeInputs;
41
+ /** Paywall-tree order, dropping offering packages that never appear on the paywall. */
42
+ export declare function orderPackagesForPaywall(packages: PaywallPackage[], packageIdsInDisplayOrder: string[]): PaywallPackage[];
43
+ export declare function buildPaywallContext(params: BuildPaywallContextParams): BridgePaywallContext;
44
+ /** Snapshot identity ignoring `updated_at`, so init is not also sent as `context`. */
45
+ export declare function contextFingerprint(context: BridgePaywallContext): string;
@@ -0,0 +1,104 @@
1
+ /** Merge dashboard custom-variable defaults with typed runtime overrides. */
2
+ export function typedCustomVariables(customVariables, uiConfig) {
3
+ const custom = {};
4
+ for (const [name, spec] of Object.entries(uiConfig?.custom_variables ?? {})) {
5
+ custom[name] = spec.default_value;
6
+ }
7
+ for (const [name, value] of Object.entries(customVariables ?? {})) {
8
+ custom[name] = value.value;
9
+ }
10
+ return custom;
11
+ }
12
+ /**
13
+ * Bare field ids. Host `inputs` win, then current-screen selections, then
14
+ * `input.*` keys from global variables (stringly, from earlier steps).
15
+ */
16
+ export function buildBridgeInputs({ inputs, initialInputSelections, globalVariables, }) {
17
+ const result = {};
18
+ for (const [key, value] of Object.entries(globalVariables ?? {})) {
19
+ if (!key.startsWith("input.") || value === undefined)
20
+ continue;
21
+ result[key.slice("input.".length)] = value;
22
+ }
23
+ for (const [fieldId, selectedIds] of Object.entries(initialInputSelections ?? {})) {
24
+ // Length 0 is unanswered: omit so applyBridgeInputChange replaces
25
+ // (single-choice). Do not write [] here; multi-choice seeds [] on mount.
26
+ if (selectedIds.length === 0)
27
+ continue;
28
+ result[fieldId] =
29
+ selectedIds.length === 1 ? (selectedIds[0] ?? null) : selectedIds;
30
+ }
31
+ return { ...result, ...inputs };
32
+ }
33
+ /** Normalize a stored input to a choice-field `string[]`. */
34
+ export function asBridgeInputArray(value) {
35
+ if (Array.isArray(value))
36
+ return [...value];
37
+ if (value == null || value === "")
38
+ return [];
39
+ return [value];
40
+ }
41
+ /**
42
+ * Apply an `onInputChanged` payload to `bridgeInputs`.
43
+ * Choice options emit a single `option_id`: toggle when the field is already
44
+ * a `string[]` (multi-choice), otherwise replace (text / single-choice).
45
+ */
46
+ export function applyBridgeInputChange(current, fieldId, value) {
47
+ const existing = current[fieldId];
48
+ if (Array.isArray(existing)) {
49
+ const next = existing.includes(value)
50
+ ? existing.filter((id) => id !== value)
51
+ : [...existing, value];
52
+ return { ...current, [fieldId]: next };
53
+ }
54
+ return { ...current, [fieldId]: value };
55
+ }
56
+ /** Paywall-tree order, dropping offering packages that never appear on the paywall. */
57
+ export function orderPackagesForPaywall(packages, packageIdsInDisplayOrder) {
58
+ if (packageIdsInDisplayOrder.length === 0) {
59
+ return packages;
60
+ }
61
+ const byId = new Map(packages.map((pkg) => [pkg.identifier, pkg]));
62
+ const ordered = [];
63
+ const seen = new Set();
64
+ for (const id of packageIdsInDisplayOrder) {
65
+ if (seen.has(id))
66
+ continue;
67
+ seen.add(id);
68
+ ordered.push(byId.get(id) ?? { identifier: id, display_name: id, products: [] });
69
+ }
70
+ return ordered;
71
+ }
72
+ export function buildPaywallContext(params) {
73
+ const packages = orderPackagesForPaywall(params.packages ?? [], params.packageIdsInDisplayOrder ?? []);
74
+ const selectedPackage = packages.find((pkg) => pkg.identifier === params.selectedPackageId) ?? null;
75
+ const placementId = params.enclosingPackageId ?? params.selectedPackageId;
76
+ const placementPackage = packages.find((pkg) => pkg.identifier === placementId) ?? null;
77
+ const context = {
78
+ custom: params.custom !== undefined
79
+ ? params.custom
80
+ : typedCustomVariables(params.customVariables, params.uiConfig),
81
+ offering: params.offering ?? null,
82
+ packages,
83
+ package: placementPackage,
84
+ selected_package: selectedPackage,
85
+ inputs: buildBridgeInputs(params),
86
+ device_meta: {
87
+ is_preview: params.isPreview ?? false,
88
+ locale: params.locale ?? "",
89
+ dark_mode: params.darkMode ?? false,
90
+ updated_at: params.updatedAt ?? Date.now(),
91
+ },
92
+ };
93
+ if (params.workflow !== undefined) {
94
+ context.workflow = params.workflow;
95
+ }
96
+ return context;
97
+ }
98
+ /** Snapshot identity ignoring `updated_at`, so init is not also sent as `context`. */
99
+ export function contextFingerprint(context) {
100
+ return JSON.stringify({
101
+ ...context,
102
+ device_meta: { ...context.device_meta, updated_at: 0 },
103
+ });
104
+ }
@@ -6,17 +6,18 @@
6
6
  * ESM sibling at `/v<major>/rc-host.js` remains for other consumers. Types and
7
7
  * message constants come from `@revenuecat/workflow-web-components-sdk`.
8
8
  */
9
- import type { Bridge, HostBridgeConfig } from "@revenuecat/workflow-web-components-sdk";
9
+ import type { HostBridge, HostBridgeConfig } from "@revenuecat/workflow-web-components-sdk";
10
10
  export { FIT_MESSAGE, RESIZE_MESSAGE, } from "@revenuecat/workflow-web-components-sdk";
11
11
  export type { ResizePayload } from "@revenuecat/workflow-web-components-sdk";
12
12
  /** Base origin the versioned SDK builds are served from. */
13
13
  export declare const SDK_BASE_URL = "https://sdk.revenuecat-static.com";
14
14
  /**
15
- * The subset of the SDK bridge purchases-ui-js uses: subscribe to a message, run
16
- * a callback once the channel opens, fire-and-forget, and tear down.
15
+ * The subset of the SDK host bridge purchases-ui-js uses: subscribe to a
16
+ * message, run a callback once the channel opens, fire-and-forget, push a
17
+ * later paywall-context snapshot, and tear down.
17
18
  */
18
- export type WebViewHostBridge = Pick<Bridge, "on" | "onReady" | "send" | "destroy">;
19
- export type CreateHostBridge = (config: HostBridgeConfig) => Bridge;
19
+ export type WebViewHostBridge = Pick<HostBridge, "on" | "onReady" | "send" | "destroy" | "setContext">;
20
+ export type CreateHostBridge = (config: HostBridgeConfig) => HostBridge;
20
21
  /** Test factories may return only the bridge surface this package uses. */
21
22
  export type CreateHostBridgeForTests = (config: HostBridgeConfig) => WebViewHostBridge;
22
23
  declare global {
@@ -5,10 +5,20 @@
5
5
  import type { InitialInputSelections } from "../../stores/inputValidation";
6
6
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
7
7
  import type { WorkflowScreen } from "../../types/workflow";
8
- import type { PackageInfo, VariableDictionary } from "../../types/variables";
8
+ import type {
9
+ PackageInfo,
10
+ VariableDictionary,
11
+ CustomVariables,
12
+ } from "../../types/variables";
9
13
  import type { WalletButtonRender } from "../../types/wallet";
10
14
  import type { UIConfig } from "../../types/ui-config";
11
15
  import type { ReservedAttribute } from "../../types/components/input-text";
16
+ import type {
17
+ PaywallOffering,
18
+ PaywallPackage,
19
+ PaywallWorkflow,
20
+ } from "@revenuecat/workflow-web-components-sdk";
21
+ import type { BridgeInputs } from "../web-view/build-paywall-context";
12
22
  interface Props {
13
23
  paywallComponents: WorkflowScreen | null | undefined;
14
24
  selectedLocale?: string;
@@ -50,6 +60,12 @@
50
60
  walletButtonRender?: WalletButtonRender;
51
61
  safeAreaFallbackColor?: ColorScheme | null;
52
62
  hideBackButtons?: boolean;
63
+ offering?: PaywallOffering | null;
64
+ packages?: PaywallPackage[];
65
+ workflow?: PaywallWorkflow;
66
+ isPreview?: boolean;
67
+ inputs?: BridgeInputs;
68
+ customVariables?: CustomVariables;
53
69
  }
54
70
  const {
55
71
  paywallComponents,
@@ -76,6 +92,12 @@
76
92
  walletButtonRender,
77
93
  safeAreaFallbackColor,
78
94
  hideBackButtons,
95
+ offering,
96
+ packages,
97
+ workflow,
98
+ isPreview,
99
+ inputs,
100
+ customVariables,
79
101
  }: Props = $props();
80
102
  </script>
81
103
 
@@ -113,6 +135,12 @@
113
135
  {walletButtonRender}
114
136
  {safeAreaFallbackColor}
115
137
  {hideBackButtons}
138
+ {offering}
139
+ {packages}
140
+ {workflow}
141
+ {isPreview}
142
+ {inputs}
143
+ {customVariables}
116
144
  onError={(error) => {
117
145
  console.error("Paywall error:", error);
118
146
  }}
@@ -3,10 +3,12 @@ import type { ColorScheme } from "../../types/colors";
3
3
  import type { InitialInputSelections } from "../../stores/inputValidation";
4
4
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
5
5
  import type { WorkflowScreen } from "../../types/workflow";
6
- import type { PackageInfo, VariableDictionary } from "../../types/variables";
6
+ import type { PackageInfo, VariableDictionary, CustomVariables } from "../../types/variables";
7
7
  import type { WalletButtonRender } from "../../types/wallet";
8
8
  import type { UIConfig } from "../../types/ui-config";
9
9
  import type { ReservedAttribute } from "../../types/components/input-text";
10
+ import type { PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
11
+ import type { BridgeInputs } from "../web-view/build-paywall-context";
10
12
  interface Props {
11
13
  paywallComponents: WorkflowScreen | null | undefined;
12
14
  selectedLocale?: string;
@@ -36,6 +38,12 @@ interface Props {
36
38
  walletButtonRender?: WalletButtonRender;
37
39
  safeAreaFallbackColor?: ColorScheme | null;
38
40
  hideBackButtons?: boolean;
41
+ offering?: PaywallOffering | null;
42
+ packages?: PaywallPackage[];
43
+ workflow?: PaywallWorkflow;
44
+ isPreview?: boolean;
45
+ inputs?: BridgeInputs;
46
+ customVariables?: CustomVariables;
39
47
  }
40
48
  declare const Screen: import("svelte").Component<Props, {}, "">;
41
49
  type Screen = ReturnType<typeof Screen>;
@@ -5,7 +5,11 @@
5
5
  import type { ColorScheme } from "../../types/colors";
6
6
  import type { InitialInputSelections } from "../../stores/inputValidation";
7
7
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
8
- import type { PackageInfo, VariableDictionary } from "../../types/variables";
8
+ import type {
9
+ PackageInfo,
10
+ VariableDictionary,
11
+ CustomVariables,
12
+ } from "../../types/variables";
9
13
  import type { WalletButtonRender } from "../../types/wallet";
10
14
  import type { UIConfig } from "../../types/ui-config";
11
15
  import type { ReservedAttribute } from "../../types/components/input-text";
@@ -18,6 +22,11 @@
18
22
  WorkflowStepTriggerAction,
19
23
  WorkflowStep,
20
24
  } from "../../types/workflow";
25
+ import type {
26
+ PaywallOffering,
27
+ PaywallPackage,
28
+ } from "@revenuecat/workflow-web-components-sdk";
29
+ import type { BridgeInputs } from "../web-view/build-paywall-context";
21
30
 
22
31
  type TriggerActionValue = WorkflowStep["trigger_actions"][string];
23
32
 
@@ -78,6 +87,11 @@
78
87
  * purchase, error). Used by the host to fire workflow lifecycle events.
79
88
  */
80
89
  onStepChanged?: (event: WorkflowStepChangeEvent) => void;
90
+ offering?: PaywallOffering | null;
91
+ packages?: PaywallPackage[];
92
+ isPreview?: boolean;
93
+ inputs?: BridgeInputs;
94
+ customVariables?: CustomVariables;
81
95
  }
82
96
 
83
97
  const {
@@ -106,6 +120,11 @@
106
120
  onClose,
107
121
  onExitBack,
108
122
  onStepChanged,
123
+ offering,
124
+ packages,
125
+ isPreview,
126
+ inputs,
127
+ customVariables,
109
128
  }: Props = $props();
110
129
 
111
130
  // ── State store ────────────────────────────────────────────────────────────
@@ -158,6 +177,13 @@
158
177
  },
159
178
  );
160
179
  const currentScreen = $derived(workflow.pages[current.pageId]);
180
+ const currentStep = $derived(workflow.steps[current.stepId]);
181
+ const bridgeWorkflow = $derived({
182
+ workflow_id: workflow.workflowId,
183
+ step_id: current.stepId,
184
+ step_type: currentStep?.type ?? "screen",
185
+ screen_type: currentStep?.screen_type ?? [],
186
+ });
161
187
 
162
188
  $effect.pre(() => {
163
189
  const navKey = `${workflow.workflowId}:${workflow.initial_page_id}`;
@@ -365,5 +391,11 @@
365
391
  });
366
392
  onClose?.();
367
393
  }}
394
+ {offering}
395
+ {packages}
396
+ workflow={bridgeWorkflow}
397
+ {isPreview}
398
+ {inputs}
399
+ {customVariables}
368
400
  />
369
401
  {/key}
@@ -2,11 +2,13 @@ import type { CompleteWorkflowNavigateArgs } from "../../types/components/button
2
2
  import type { ColorScheme } from "../../types/colors";
3
3
  import type { InitialInputSelections } from "../../stores/inputValidation";
4
4
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
5
- import type { PackageInfo, VariableDictionary } from "../../types/variables";
5
+ import type { PackageInfo, VariableDictionary, CustomVariables } 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
9
  import type { WorkflowNavData, WorkflowStepChangeEvent } from "../../types/workflow-nav";
10
+ import type { PaywallOffering, PaywallPackage } from "@revenuecat/workflow-web-components-sdk";
11
+ import type { BridgeInputs } from "../web-view/build-paywall-context";
10
12
  interface Props {
11
13
  workflow: WorkflowNavData;
12
14
  uiConfig: UIConfig;
@@ -43,6 +45,11 @@ interface Props {
43
45
  * purchase, error). Used by the host to fire workflow lifecycle events.
44
46
  */
45
47
  onStepChanged?: (event: WorkflowStepChangeEvent) => void;
48
+ offering?: PaywallOffering | null;
49
+ packages?: PaywallPackage[];
50
+ isPreview?: boolean;
51
+ inputs?: BridgeInputs;
52
+ customVariables?: CustomVariables;
46
53
  }
47
54
  declare const Workflow: import("svelte").Component<Props, {}, "">;
48
55
  type Workflow = ReturnType<typeof Workflow>;
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export { type WorkflowNavData, type WorkflowStepChangeEvent, workflowDataToNavDa
29
29
  export { type WorkflowData, type WorkflowStep } from "./types/workflow";
30
30
  export { type InitialInputSelections } from "./stores/inputValidation";
31
31
  export { type UIConfig } from "./types/ui-config";
32
+ export type { PaywallContext, PaywallOffering, PaywallPackage, PaywallWorkflow, } from "@revenuecat/workflow-web-components-sdk";
32
33
  export { type WalletButtonRender, type WalletButtonTheme, } from "./types/wallet";
33
34
  export { type CustomVariables, CustomVariableValue, mergeCustomVariables, type VariableDictionary, type PackageInfo, } from "./types/variables";
34
35
  export type { Action, CompleteWorkflowNavigateArgs, CompleteWorkflowUrlQueryParams, } from "./types/components/button";
@@ -4,7 +4,9 @@ import type { ComponentInteractionData } from "../types/paywall-component-intera
4
4
  import type { UIConfig } from "../types/ui-config";
5
5
  import type { PackageInfo, VariableDictionary } from "../types/variables";
6
6
  import type { WalletButtonRender } from "../types/wallet";
7
+ import type { PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
7
8
  import { type Readable, type Writable } from "svelte/store";
9
+ import type { BridgeCustomValues, BridgeInputs } from "../components/web-view/build-paywall-context";
8
10
  type PaywallContext = Readonly<{
9
11
  defaultPackageId?: string;
10
12
  selectedPackageId: Writable<string | undefined>;
@@ -18,6 +20,17 @@ type PaywallContext = Readonly<{
18
20
  * */
19
21
  baseVariables: Readable<VariableDictionary | undefined>;
20
22
  infoPerPackage: Readable<Record<string, PackageInfo> | undefined>;
23
+ /** Host-supplied offering for the web_view bridge. */
24
+ offering?: PaywallOffering | null;
25
+ /** Host-mapped packages (SDK shape) for the web_view bridge. */
26
+ contextPackages?: PaywallPackage[];
27
+ /** Paywall-tree package ids in display order. */
28
+ packageIdsInDisplayOrder?: string[];
29
+ workflow?: PaywallWorkflow;
30
+ isPreview?: boolean;
31
+ selectedLocale?: string;
32
+ typedCustom?: BridgeCustomValues;
33
+ bridgeInputs?: Writable<BridgeInputs>;
21
34
  onPurchase: (actionId?: string) => void;
22
35
  emitComponentInteraction: (data: ComponentInteractionData) => void;
23
36
  onNavigateToUrl?: (url: string) => void;
@@ -42,6 +42,14 @@ export interface WorkflowStep {
42
42
  id: string;
43
43
  screen_id?: string;
44
44
  type: string;
45
+ /** Editor step name from the published SDK payload (khepri #24244). */
46
+ name?: string | null;
47
+ /**
48
+ * Editor screen-type classification from the published SDK payload
49
+ * (khepri #24244). Missing on unre-published funnels; treat as `[]`.
50
+ * Distinct from `metadata.screen_type` (native fallback-paywall contract).
51
+ */
52
+ screen_type?: string[];
45
53
  param_values: Record<string, unknown>;
46
54
  /**
47
55
  * Maps action IDs (from `triggers[].action_id`) to the next step to navigate
@@ -10,6 +10,11 @@ import type { PackageInfo, VariableDictionary } from "../types/variables.js";
10
10
  * @returns the id of the first package marked as `is_selected_by_default`, otherwise the first package id, or undefined
11
11
  */
12
12
  export declare function findSelectedPackageId({ stack, sticky_footer, }: RootPaywall): string | undefined;
13
+ /**
14
+ * Package ids in paywall display order (root stack, then sticky footer).
15
+ * Duplicates keep the first occurrence. Unselected tabs still count.
16
+ */
17
+ export declare function collectPackageIdsInDisplayOrder({ stack, sticky_footer, }: RootPaywall): string[];
13
18
  export type ConditionContext = {
14
19
  selectedPackageId: string | undefined;
15
20
  packageInfo: PackageInfo | undefined;
@@ -93,6 +93,28 @@ export function findSelectedPackageId({ stack, sticky_footer, }) {
93
93
  }
94
94
  return undefined;
95
95
  }
96
+ /**
97
+ * Package ids in paywall display order (root stack, then sticky footer).
98
+ * Duplicates keep the first occurrence. Unselected tabs still count.
99
+ */
100
+ export function collectPackageIdsInDisplayOrder({ stack, sticky_footer, }) {
101
+ const ids = [];
102
+ const seen = new Set();
103
+ const walk = (node) => {
104
+ if (node.type === "package" && !seen.has(node.package_id)) {
105
+ seen.add(node.package_id);
106
+ ids.push(node.package_id);
107
+ }
108
+ for (const child of getPaywallComponentChildNodes(node)) {
109
+ walk(child);
110
+ }
111
+ };
112
+ walk(stack);
113
+ if (sticky_footer != null) {
114
+ walk(sticky_footer);
115
+ }
116
+ return ids;
117
+ }
96
118
  /**
97
119
  * Merges the properties of every override whose conditions all match (AND),
98
120
  * applying them in override-array order (later overrides overwrite earlier ones).
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.8.18",
5
+ "version": "4.8.20",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },
@@ -35,7 +35,6 @@
35
35
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
36
36
  "storybook": "storybook dev -p 6006",
37
37
  "build-storybook": "storybook build",
38
- "prepare": "husky",
39
38
  "chromatic": "chromatic",
40
39
  "format": "prettier --write .",
41
40
  "typecheck": "tsc --noEmit",
@@ -76,7 +75,7 @@
76
75
  },
77
76
  "packageManager": "npm@11.7.0+sha512.c22099a6fff8d5b2286c2a09df5352b4858a7c0c716320f58989d60ad8b29ecf2ce6fdfe97ccb41c23ffb1272e1fa079f868487dd6b81d02a2a9e199c095a117",
78
77
  "dependencies": {
79
- "@revenuecat/workflow-web-components-sdk": "^0.1.4",
78
+ "@revenuecat/workflow-web-components-sdk": "^0.1.7",
80
79
  "qrcode": "^1.5.4"
81
80
  },
82
81
  "peerDependencies": {
@@ -105,10 +104,9 @@
105
104
  "eslint-plugin-storybook": "9.1.17",
106
105
  "eslint-plugin-svelte": "3.16.0",
107
106
  "globals": "16.4.0",
108
- "husky": "9.1.7",
109
107
  "jsdom": "27.0.1",
110
108
  "knip": "5.82.1",
111
- "lint-staged": "16.4.0",
109
+ "lefthook": "^2.1.10",
112
110
  "prettier": "3.8.1",
113
111
  "prettier-plugin-svelte": "3.5.1",
114
112
  "publint": "0.3.18",
@@ -122,10 +120,10 @@
122
120
  "vite-plugin-dts": "4.5.4",
123
121
  "vitest": "3.2.4"
124
122
  },
125
- "lint-staged": {
126
- "**/*": [
127
- "prettier --write --ignore-unknown",
128
- "eslint --fix"
129
- ]
123
+ "allowScripts": {
124
+ "esbuild@0.25.12": true,
125
+ "fsevents@2.3.2": true,
126
+ "fsevents@2.3.3": true,
127
+ "lefthook@2.1.10": true
130
128
  }
131
129
  }