@revenuecat/purchases-ui-js 4.10.2 → 4.11.0

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.
@@ -11,18 +11,45 @@
11
11
  import { defineMeta } from "@storybook/addon-svelte-csf";
12
12
  import { mockDateDecorator } from "storybook-mock-date-decorator";
13
13
  import type { PurchaseButtonProps } from "../../types/components/purchase-button";
14
+ import type { StackProps } from "../../types/components/stack";
15
+ import type {
16
+ WalletPurchasePrepare,
17
+ WalletPurchaseStart,
18
+ } from "../../types/wallet";
14
19
 
15
20
  const defaultLocale = "en_US";
16
21
  const textLidWhenAvailable = "purchase_wallet_available_text";
17
22
  const textLidWhenUnavailable = "purchase_wallet_unavailable_text";
23
+ const textLidCustomWalletButton = "custom_wallet_button_text";
18
24
  const loc = {
19
25
  en_US: {
20
26
  ...localizations["en_US"],
21
27
  [textLidWhenAvailable]: "Purchase with Wallet",
22
28
  [textLidWhenUnavailable]: "Wallet Unavailable",
29
+ [textLidCustomWalletButton]: "Pay with Custom Wallet Button",
23
30
  },
24
31
  };
25
32
 
33
+ const createPrepareWalletPurchase =
34
+ ({
35
+ available,
36
+ delay,
37
+ }: {
38
+ available: boolean;
39
+ delay?: number;
40
+ }): WalletPurchasePrepare =>
41
+ (_selectedPackageId: string) =>
42
+ new Promise((resolve) => {
43
+ setTimeout(() => resolve(available), delay);
44
+ });
45
+
46
+ const createOnWalletPurchaseClicked =
47
+ ({ delay = 500 }: { delay?: number } = {}): WalletPurchaseStart =>
48
+ (_selectedPackageId: string) =>
49
+ new Promise((resolve) => {
50
+ setTimeout(() => resolve(), delay);
51
+ });
52
+
26
53
  const createWalletButtonRender =
27
54
  ({ available, delay }: { available: boolean; delay?: number }) =>
28
55
  (
@@ -106,6 +133,116 @@
106
133
  shadow: null,
107
134
  };
108
135
 
136
+ const customWalletButtonProps = {
137
+ type: "wallet_button",
138
+ id: "wallet_button_custom",
139
+ name: "Wallet Button Custom",
140
+ mode: "custom",
141
+ size: {
142
+ width: { type: "fill" },
143
+ height: { type: "fit" },
144
+ },
145
+ margin: { top: 0, trailing: 0, bottom: 0, leading: 0 },
146
+ stack: {
147
+ type: "stack",
148
+ id: "wallet-button-custom-stack",
149
+ name: "Wallet Button Custom Stack",
150
+ background_color: {
151
+ light: {
152
+ type: "hex",
153
+ value: "#111111",
154
+ },
155
+ },
156
+ background: {
157
+ type: "color",
158
+ value: {
159
+ light: {
160
+ type: "hex",
161
+ value: "#111111",
162
+ },
163
+ },
164
+ },
165
+ components: [
166
+ {
167
+ background_color: null,
168
+ color: {
169
+ light: {
170
+ type: "hex",
171
+ value: "#ffffff",
172
+ },
173
+ },
174
+ font_name: null,
175
+ font_size: "heading_m",
176
+ font_weight: "extra_bold",
177
+ horizontal_alignment: "center",
178
+ id: "wallet-button-custom-text",
179
+ margin: {
180
+ bottom: 0,
181
+ leading: 0,
182
+ top: 0,
183
+ trailing: 0,
184
+ },
185
+ name: "Text",
186
+ padding: {
187
+ bottom: 0,
188
+ leading: 0,
189
+ top: 0,
190
+ trailing: 0,
191
+ },
192
+ size: {
193
+ height: {
194
+ type: "fit",
195
+ value: null,
196
+ },
197
+ width: {
198
+ type: "fill",
199
+ value: null,
200
+ },
201
+ },
202
+ text_lid: textLidCustomWalletButton,
203
+ type: "text",
204
+ },
205
+ ],
206
+ dimension: {
207
+ type: "vertical",
208
+ alignment: "center",
209
+ distribution: "center",
210
+ },
211
+ margin: {
212
+ bottom: 0,
213
+ leading: 0,
214
+ top: 0,
215
+ trailing: 0,
216
+ },
217
+ padding: {
218
+ bottom: 16,
219
+ leading: 16,
220
+ top: 16,
221
+ trailing: 16,
222
+ },
223
+ border: null,
224
+ shadow: null,
225
+ shape: {
226
+ corners: {
227
+ bottom_leading: 8,
228
+ bottom_trailing: 8,
229
+ top_leading: 8,
230
+ top_trailing: 8,
231
+ },
232
+ type: "rectangle",
233
+ },
234
+ size: {
235
+ height: {
236
+ type: "fit",
237
+ },
238
+ width: {
239
+ type: "fill",
240
+ },
241
+ },
242
+ spacing: 8,
243
+ },
244
+ } satisfies WalletButtonProps;
245
+
109
246
  const purchaseButtonWhenAvailableProps = {
110
247
  type: "purchase_button",
111
248
  id: "purchase-button",
@@ -216,6 +353,39 @@
216
353
  purchaseButtonWhenUnavailableProps.stack.components[0].text_lid =
217
354
  textLidWhenUnavailable;
218
355
 
356
+ const walletAvailableStackCustom = {
357
+ type: "stack",
358
+ id: "wallet-available-stack-custom",
359
+ name: "Wallet Available Stack (Custom)",
360
+ components: [customWalletButtonProps, purchaseButtonWhenAvailableProps],
361
+ size: {
362
+ width: { type: "fill" },
363
+ height: { type: "fit" },
364
+ },
365
+ dimension: {
366
+ type: "vertical",
367
+ alignment: "center",
368
+ distribution: "center",
369
+ },
370
+ spacing: 8,
371
+ margin: { top: 0, trailing: 0, bottom: 0, leading: 0 },
372
+ padding: { top: 16, trailing: 16, bottom: 16, leading: 16 },
373
+ background_color: null,
374
+ background: null,
375
+ border: null,
376
+ shape: {
377
+ type: "rectangle",
378
+ corners: {
379
+ top_leading: 8,
380
+ top_trailing: 8,
381
+ bottom_leading: 8,
382
+ bottom_trailing: 8,
383
+ },
384
+ },
385
+ shadow: null,
386
+ badge: null,
387
+ } satisfies StackProps;
388
+
219
389
  const { Story } = defineMeta({
220
390
  title: "Components/ExpressPurchaseButton",
221
391
  component: ExpressPurchaseButton,
@@ -460,3 +630,48 @@
460
630
  size: { width: { type: "fixed", value: 500 }, height: { type: "fit" } },
461
631
  }}
462
632
  />
633
+
634
+ <Story
635
+ name="Custom button — available"
636
+ decorators={[
637
+ paywallDecorator(
638
+ undefined,
639
+ "some-package-id",
640
+ createPrepareWalletPurchase({ available: true, delay: 200 }),
641
+ createOnWalletPurchaseClicked(),
642
+ ),
643
+ ]}
644
+ args={{
645
+ wallet_available_stack: walletAvailableStackCustom,
646
+ }}
647
+ />
648
+
649
+ <Story
650
+ name="Custom button — unavailable"
651
+ decorators={[
652
+ paywallDecorator(
653
+ undefined,
654
+ "some-package-id",
655
+ createPrepareWalletPurchase({ available: false, delay: 200 }),
656
+ createOnWalletPurchaseClicked(),
657
+ ),
658
+ ]}
659
+ args={{
660
+ wallet_available_stack: walletAvailableStackCustom,
661
+ }}
662
+ />
663
+
664
+ <Story
665
+ name="Custom button — slow prepare (skeleton)"
666
+ decorators={[
667
+ paywallDecorator(
668
+ undefined,
669
+ "some-package-id",
670
+ createPrepareWalletPurchase({ available: true, delay: 3000 }),
671
+ createOnWalletPurchaseClicked(),
672
+ ),
673
+ ]}
674
+ args={{
675
+ wallet_available_stack: walletAvailableStackCustom,
676
+ }}
677
+ />
@@ -3,37 +3,65 @@
3
3
  import { getPaywallContext, setPaywallContext } from "../../stores/paywall";
4
4
  import type { ExpressPurchaseButtonProps } from "../../types/components/express-purchase-button-props";
5
5
  import type { StackProps } from "../../types/components/stack";
6
- import { hasComponentOfTypeInSubtree } from "./component-subtree-utils";
6
+ import { untrack } from "svelte";
7
+ import {
8
+ findComponentOfTypeInSubtree,
9
+ hasComponentOfTypeInSubtree,
10
+ } from "./component-subtree-utils";
7
11
 
8
- const props: ExpressPurchaseButtonProps = $props();
9
12
  const {
10
13
  wallet_unknown_stack,
11
14
  wallet_available_stack,
12
15
  wallet_unavailable_stack,
13
16
  type,
14
17
  ...partialStackProps
15
- } = props;
18
+ }: ExpressPurchaseButtonProps = $props();
16
19
 
17
- const { walletButtonRender, ...rest } = getPaywallContext();
20
+ const {
21
+ walletButtonRender,
22
+ prepareWalletPurchase,
23
+ onWalletPurchaseClicked,
24
+ ...rest
25
+ } = getPaywallContext();
18
26
 
19
- const availableStackHasWalletButton = hasComponentOfTypeInSubtree(
20
- wallet_available_stack,
21
- "wallet_button",
27
+ const availableStackHasWalletButton = untrack(() =>
28
+ hasComponentOfTypeInSubtree(wallet_available_stack, "wallet_button"),
29
+ );
30
+
31
+ const walletButton = untrack(() =>
32
+ findComponentOfTypeInSubtree(wallet_available_stack, "wallet_button"),
22
33
  );
23
34
 
35
+ // A partial custom integration (prepare without a click handler, or vice versa)
36
+ // would otherwise show an enabled CTA whose click can never purchase.
37
+ const hostReady =
38
+ walletButton?.type === "wallet_button" && walletButton.mode === "custom"
39
+ ? !!prepareWalletPurchase && !!onWalletPurchaseClicked
40
+ : !!walletButtonRender;
41
+
24
42
  let walletButtonAvailable = $state<boolean | undefined>(
25
43
  availableStackHasWalletButton ? undefined : false,
26
44
  );
27
45
  let shouldShowWalletSkeleton = $state(
28
- !!walletButtonRender && availableStackHasWalletButton,
46
+ hostReady && availableStackHasWalletButton,
29
47
  );
30
48
 
31
49
  const onWalletButtonReady = (isAvailable?: boolean) => {
32
- walletButtonAvailable = isAvailable;
50
+ // wallet_available_stack (and the WalletButton inside it) always mounts, hidden
51
+ // via CSS, so its own successful prepare can report ready independently of
52
+ // hostReady; clamp to unavailable so an incomplete host integration never
53
+ // reveals a CTA that can't purchase.
54
+ walletButtonAvailable = hostReady ? isAvailable : false;
33
55
  shouldShowWalletSkeleton = false;
34
56
  };
35
57
 
36
- setPaywallContext({ walletButtonRender, ...rest, onWalletButtonReady });
58
+ setPaywallContext({
59
+ walletButtonRender,
60
+ prepareWalletPurchase,
61
+ onWalletPurchaseClicked,
62
+ ...rest,
63
+ onWalletButtonReady,
64
+ });
37
65
 
38
66
  const shouldShowUnavailableStack = $derived(
39
67
  !walletButtonAvailable || !wallet_available_stack,
@@ -46,7 +74,7 @@
46
74
  ),
47
75
  );
48
76
 
49
- const wrapperStackProps: StackProps = {
77
+ const wrapperStackProps: StackProps = $derived({
50
78
  components: [],
51
79
  type: "stack",
52
80
  visible: null,
@@ -58,7 +86,7 @@
58
86
  background_color: null,
59
87
  spacing: 0,
60
88
  ...partialStackProps,
61
- };
89
+ });
62
90
  </script>
63
91
 
64
92
  <Stack {...wrapperStackProps}>
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import { setColorModeContext } from "../../stores/color-mode";
3
+ import { setPaywallContext } from "../../stores/paywall";
4
+ import type { ExpressPurchaseButtonProps } from "../../types/components/express-purchase-button-props";
5
+ import type { UIConfig } from "../../types/ui-config";
6
+ import type {
7
+ WalletButtonRender,
8
+ WalletPurchasePrepare,
9
+ WalletPurchaseStart,
10
+ } from "../../types/wallet";
11
+ import { readable, writable } from "svelte/store";
12
+ import { untrack } from "svelte";
13
+ import ExpressPurchaseButton from "./ExpressPurchaseButton.svelte";
14
+
15
+ interface Props extends ExpressPurchaseButtonProps {
16
+ selectedPackageId?: string;
17
+ walletButtonRender?: WalletButtonRender;
18
+ prepareWalletPurchase?: WalletPurchasePrepare;
19
+ onWalletPurchaseClicked?: WalletPurchaseStart;
20
+ }
21
+
22
+ const {
23
+ selectedPackageId = "pkg-1",
24
+ walletButtonRender,
25
+ prepareWalletPurchase,
26
+ onWalletPurchaseClicked,
27
+ ...expressPurchaseButtonProps
28
+ }: Props = $props();
29
+
30
+ setColorModeContext();
31
+
32
+ const uiConfig: UIConfig = { app: { fonts: {} } };
33
+
34
+ setPaywallContext(
35
+ untrack(() => ({
36
+ selectedPackageId: writable<string | undefined>(selectedPackageId),
37
+ variablesPerPackage: readable(undefined),
38
+ baseVariables: readable(undefined),
39
+ infoPerPackage: readable(undefined),
40
+ onPurchase: () => {},
41
+ emitComponentInteraction: () => {},
42
+ onButtonAction: () => {},
43
+ uiConfig,
44
+ hideBackButtons: false,
45
+ walletButtonRender,
46
+ prepareWalletPurchase,
47
+ onWalletPurchaseClicked,
48
+ })),
49
+ );
50
+ </script>
51
+
52
+ <ExpressPurchaseButton {...expressPurchaseButtonProps} />
@@ -0,0 +1,11 @@
1
+ import type { ExpressPurchaseButtonProps } from "../../types/components/express-purchase-button-props";
2
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } from "../../types/wallet";
3
+ interface Props extends ExpressPurchaseButtonProps {
4
+ selectedPackageId?: string;
5
+ walletButtonRender?: WalletButtonRender;
6
+ prepareWalletPurchase?: WalletPurchasePrepare;
7
+ onWalletPurchaseClicked?: WalletPurchaseStart;
8
+ }
9
+ declare const ExpressPurchaseButtonTestWrapper: import("svelte").Component<Props, {}, "">;
10
+ type ExpressPurchaseButtonTestWrapper = ReturnType<typeof ExpressPurchaseButtonTestWrapper>;
11
+ export default ExpressPurchaseButtonTestWrapper;
@@ -2,4 +2,5 @@ import type { Component } from "../../types/component";
2
2
  import type { StackProps } from "../../types/components/stack";
3
3
  type ComponentType = Component["type"];
4
4
  export declare const hasComponentOfTypeInSubtree: (stack: StackProps, componentType: ComponentType) => boolean;
5
+ export declare const findComponentOfTypeInSubtree: (stack: StackProps, componentType: ComponentType) => Component | undefined;
5
6
  export {};
@@ -1,31 +1,56 @@
1
1
  export const hasComponentOfTypeInSubtree = (stack, componentType) => {
2
- return (stack.components.some((component) => componentHasTypeInSubtree(component, componentType)) ||
3
- (stack.badge?.stack
4
- ? hasComponentOfTypeInSubtree(stack.badge.stack, componentType)
5
- : false));
2
+ return findComponentOfTypeInSubtree(stack, componentType) !== undefined;
6
3
  };
7
- const componentHasTypeInSubtree = (component, componentType) => {
4
+ export const findComponentOfTypeInSubtree = (stack, componentType) => {
5
+ for (const component of stack.components) {
6
+ const found = findComponentTypeInComponent(component, componentType);
7
+ if (found)
8
+ return found;
9
+ }
10
+ return stack.badge?.stack
11
+ ? findComponentOfTypeInSubtree(stack.badge.stack, componentType)
12
+ : undefined;
13
+ };
14
+ const findComponentTypeInComponent = (component, componentType) => {
8
15
  if (component.type === componentType) {
9
- return true;
16
+ return component;
10
17
  }
11
- if (component.fallback &&
12
- componentHasTypeInSubtree(component.fallback, componentType)) {
13
- return true;
18
+ if (component.fallback) {
19
+ const found = findComponentTypeInComponent(component.fallback, componentType);
20
+ if (found)
21
+ return found;
14
22
  }
15
23
  switch (component.type) {
16
24
  case "carousel":
17
- return component.pages.some((page) => hasComponentOfTypeInSubtree(page, componentType));
25
+ for (const page of component.pages) {
26
+ const found = findComponentOfTypeInSubtree(page, componentType);
27
+ if (found)
28
+ return found;
29
+ }
30
+ return undefined;
18
31
  case "package":
19
32
  case "purchase_button":
20
33
  case "tab":
21
34
  case "tab_control_button":
22
- return hasComponentOfTypeInSubtree(component.stack, componentType);
35
+ return findComponentOfTypeInSubtree(component.stack, componentType);
36
+ case "wallet_button":
37
+ return component.stack
38
+ ? findComponentOfTypeInSubtree(component.stack, componentType)
39
+ : undefined;
23
40
  case "stack":
24
- return hasComponentOfTypeInSubtree(component, componentType);
25
- case "tabs":
26
- return (hasComponentOfTypeInSubtree(component.control.stack, componentType) ||
27
- component.tabs.some((tab) => hasComponentOfTypeInSubtree(tab.stack, componentType)));
41
+ return findComponentOfTypeInSubtree(component, componentType);
42
+ case "tabs": {
43
+ const foundInControl = findComponentOfTypeInSubtree(component.control.stack, componentType);
44
+ if (foundInControl)
45
+ return foundInControl;
46
+ for (const tab of component.tabs) {
47
+ const found = findComponentOfTypeInSubtree(tab.stack, componentType);
48
+ if (found)
49
+ return found;
50
+ }
51
+ return undefined;
52
+ }
28
53
  default:
29
- return false;
54
+ return undefined;
30
55
  }
31
56
  };
@@ -60,7 +60,11 @@
60
60
  type PackageInfoStore,
61
61
  setPackageInfoContext,
62
62
  } from "../../stores/packageInfo";
63
- import type { WalletButtonRender } from "../../types/wallet";
63
+ import type {
64
+ WalletButtonRender,
65
+ WalletPurchasePrepare,
66
+ WalletPurchaseStart,
67
+ } from "../../types/wallet";
64
68
  import type { ReservedAttribute } from "../../types/components/input-text";
65
69
  import type {
66
70
  PaywallOffering,
@@ -111,6 +115,8 @@
111
115
  onError?: (error: unknown) => void;
112
116
  hideBackButtons?: boolean;
113
117
  walletButtonRender?: WalletButtonRender;
118
+ prepareWalletPurchase?: WalletPurchasePrepare;
119
+ onWalletPurchaseClicked?: WalletPurchaseStart;
114
120
  onInputChanged?: (
115
121
  fieldId: string,
116
122
  value: string,
@@ -173,6 +179,8 @@
173
179
  onError,
174
180
  uiConfig,
175
181
  walletButtonRender,
182
+ prepareWalletPurchase,
183
+ onWalletPurchaseClicked,
176
184
  onInputChanged,
177
185
  onSensitiveInputChanged,
178
186
  onReservedAttributeChanged,
@@ -388,6 +396,8 @@
388
396
  onSensitiveInputChanged,
389
397
  onReservedAttributeChanged,
390
398
  walletButtonRender,
399
+ prepareWalletPurchase,
400
+ onWalletPurchaseClicked,
391
401
  uiConfig,
392
402
  hideBackButtons,
393
403
  });
@@ -6,7 +6,7 @@ import type { OnComponentInteraction } from "../../types/paywall-component-inter
6
6
  import type { PaywallData } from "../../types/paywall";
7
7
  import type { UIConfig } from "../../types/ui-config";
8
8
  import { type CustomVariables, type PackageInfo, type VariableDictionary } from "../../types/variables";
9
- import type { WalletButtonRender } from "../../types/wallet";
9
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } from "../../types/wallet";
10
10
  import type { ReservedAttribute } from "../../types/components/input-text";
11
11
  import type { PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
12
12
  import { type BridgeInputs } from "../web-view/build-paywall-context";
@@ -45,6 +45,8 @@ interface Props {
45
45
  onError?: (error: unknown) => void;
46
46
  hideBackButtons?: boolean;
47
47
  walletButtonRender?: WalletButtonRender;
48
+ prepareWalletPurchase?: WalletPurchasePrepare;
49
+ onWalletPurchaseClicked?: WalletPurchaseStart;
48
50
  onInputChanged?: (fieldId: string, value: string, actionId?: string) => void;
49
51
  /**
50
52
  * Called for sensitive inputs (keyboard_type: "password") instead of
@@ -1,18 +1,29 @@
1
1
  <script lang="ts">
2
+ import Stack from "../stack/Stack.svelte";
2
3
  import { mapDimension } from "../stack/stack-utils";
3
4
  import { getColorModeContext } from "../../stores/color-mode";
4
5
  import { getPaywallContext } from "../../stores/paywall";
5
6
  import type { WalletButtonProps } from "../../types/components/wallet-button";
6
7
  import { css, mapSize, mapSpacing, px } from "../../utils/base-utils";
7
8
  import { DEFAULT_SPACING } from "../../utils/constants";
9
+ import { onDestroy } from "svelte";
8
10
 
9
- const { walletButtonRender, onWalletButtonReady, selectedPackageId } =
10
- getPaywallContext();
11
+ const {
12
+ walletButtonRender,
13
+ onWalletButtonReady,
14
+ prepareWalletPurchase,
15
+ onWalletPurchaseClicked,
16
+ selectedPackageId,
17
+ emitComponentInteraction,
18
+ } = getPaywallContext();
11
19
  const getColorMode = getColorModeContext();
12
20
  const colorMode = $derived(getColorMode());
13
21
 
14
22
  const props: WalletButtonProps = $props();
15
23
 
24
+ const mode = $derived(props.mode ?? "store");
25
+ const onReady = $derived(props.onWalletButtonReady ?? onWalletButtonReady);
26
+
16
27
  const walletButtonTheme = $derived.by(() => {
17
28
  const { style } = props;
18
29
  if (style == null) {
@@ -65,9 +76,99 @@
65
76
  });
66
77
 
67
78
  const stackStyle = $derived(css(stackLayoutRules));
79
+
80
+ let busy = $state(false);
81
+ let prepared = $state(false);
82
+ const walletState = $derived(
83
+ busy ? "busy" : prepared ? "ready" : "preparing",
84
+ );
85
+ let prepareRequestId = 0;
86
+ let destroyed = false;
87
+ onDestroy(() => {
88
+ destroyed = true;
89
+ });
90
+
91
+ const runPrepare = (packageId: string) => {
92
+ const requestId = ++prepareRequestId;
93
+ if (!prepareWalletPurchase) {
94
+ onReady?.(false);
95
+ return;
96
+ }
97
+
98
+ // A host that throws synchronously (rather than rejecting, as documented)
99
+ // must not skip the .catch below and leave onReady unreported.
100
+ Promise.resolve()
101
+ .then(() => prepareWalletPurchase(packageId))
102
+ .catch(() => false)
103
+ .then((available) => {
104
+ if (requestId !== prepareRequestId || destroyed) {
105
+ return;
106
+ }
107
+ prepared = available;
108
+ onReady?.(available);
109
+ });
110
+ };
111
+
112
+ const refreshPrepare = () => {
113
+ if (destroyed || mode !== "custom") {
114
+ return;
115
+ }
116
+ prepared = false;
117
+ if (!props.stack) {
118
+ onReady?.(false);
119
+ return;
120
+ }
121
+
122
+ const packageId = $selectedPackageId;
123
+ if (!packageId) {
124
+ // Invalidate any in-flight prepare so its result can't mark this cleared
125
+ // selection ready.
126
+ prepareRequestId++;
127
+ return;
128
+ }
129
+
130
+ runPrepare(packageId);
131
+ };
132
+
133
+ $effect(refreshPrepare);
134
+
135
+ const onclick = () => {
136
+ if (busy || !prepared || !onWalletPurchaseClicked) {
137
+ return;
138
+ }
139
+ const packageId = $selectedPackageId;
140
+ if (!packageId) {
141
+ return;
142
+ }
143
+ busy = true;
144
+ prepared = false;
145
+ // Invoked synchronously (not deferred) to keep Safari's user activation for
146
+ // the wallet sheet; a synchronous throw here must still restore state below.
147
+ let purchase: ReturnType<typeof onWalletPurchaseClicked>;
148
+ try {
149
+ purchase = onWalletPurchaseClicked(packageId);
150
+ } catch {
151
+ busy = false;
152
+ refreshPrepare();
153
+ return;
154
+ }
155
+ // Attach cleanup before anything else can throw so a failing interaction
156
+ // handler cannot leave the button locked.
157
+ purchase
158
+ .catch(() => {})
159
+ .finally(() => {
160
+ busy = false;
161
+ refreshPrepare();
162
+ });
163
+ emitComponentInteraction({
164
+ componentType: "wallet_button",
165
+ componentName: props.name,
166
+ componentValue: "custom_purchase",
167
+ });
168
+ };
68
169
  </script>
69
170
 
70
- {#if walletButtonRender && $selectedPackageId}
171
+ {#if mode === "store" && walletButtonRender && $selectedPackageId}
71
172
  <div style={stackStyle}>
72
173
  {#key walletButtonTheme}
73
174
  <div
@@ -80,4 +181,26 @@
80
181
  ></div>
81
182
  {/key}
82
183
  </div>
184
+ {:else if mode === "custom" && props.stack}
185
+ <div
186
+ class="custom-wallet-button"
187
+ style={stackStyle}
188
+ data-wallet-state={walletState}
189
+ aria-busy={busy}
190
+ aria-disabled={busy || !prepared}
191
+ >
192
+ <Stack {...props.stack} {onclick} testId="wallet-button-custom" />
193
+ </div>
83
194
  {/if}
195
+
196
+ <style>
197
+ /* Default look for the not-ready states; hosts restyle via [data-wallet-state]. */
198
+ .custom-wallet-button:not([data-wallet-state="ready"]) {
199
+ opacity: 0.4;
200
+ cursor: not-allowed;
201
+ }
202
+
203
+ .custom-wallet-button:not([data-wallet-state="ready"]) > :global(*) {
204
+ pointer-events: none;
205
+ }
206
+ </style>
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import { setColorModeContext } from "../../stores/color-mode";
3
+ import { setPaywallContext } from "../../stores/paywall";
4
+ import type { WalletButtonProps } from "../../types/components/wallet-button";
5
+ import type { UIConfig } from "../../types/ui-config";
6
+ import type {
7
+ WalletButtonRender,
8
+ WalletPurchasePrepare,
9
+ WalletPurchaseStart,
10
+ } from "../../types/wallet";
11
+ import { readable, writable, type Writable } from "svelte/store";
12
+ import { untrack } from "svelte";
13
+ import WalletButton from "./WalletButton.svelte";
14
+
15
+ interface Props extends WalletButtonProps {
16
+ selectedPackageId?: Writable<string | undefined>;
17
+ walletButtonRender?: WalletButtonRender;
18
+ prepareWalletPurchase?: WalletPurchasePrepare;
19
+ onWalletPurchaseClicked?: WalletPurchaseStart;
20
+ }
21
+
22
+ const {
23
+ selectedPackageId = writable<string | undefined>("pkg-1"),
24
+ walletButtonRender,
25
+ prepareWalletPurchase,
26
+ onWalletPurchaseClicked,
27
+ ...walletButtonProps
28
+ }: Props = $props();
29
+
30
+ setColorModeContext();
31
+
32
+ const uiConfig: UIConfig = { app: { fonts: {} } };
33
+
34
+ setPaywallContext(
35
+ untrack(() => ({
36
+ selectedPackageId,
37
+ variablesPerPackage: readable(undefined),
38
+ baseVariables: readable(undefined),
39
+ infoPerPackage: readable(undefined),
40
+ onPurchase: () => {},
41
+ emitComponentInteraction: () => {},
42
+ onButtonAction: () => {},
43
+ uiConfig,
44
+ hideBackButtons: false,
45
+ walletButtonRender,
46
+ prepareWalletPurchase,
47
+ onWalletPurchaseClicked,
48
+ })),
49
+ );
50
+ </script>
51
+
52
+ <WalletButton {...walletButtonProps} />
@@ -0,0 +1,12 @@
1
+ import type { WalletButtonProps } from "../../types/components/wallet-button";
2
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } from "../../types/wallet";
3
+ import { type Writable } from "svelte/store";
4
+ interface Props extends WalletButtonProps {
5
+ selectedPackageId?: Writable<string | undefined>;
6
+ walletButtonRender?: WalletButtonRender;
7
+ prepareWalletPurchase?: WalletPurchasePrepare;
8
+ onWalletPurchaseClicked?: WalletPurchaseStart;
9
+ }
10
+ declare const WalletButtonTestWrapper: import("svelte").Component<Props, {}, "">;
11
+ type WalletButtonTestWrapper = ReturnType<typeof WalletButtonTestWrapper>;
12
+ export default WalletButtonTestWrapper;
@@ -10,7 +10,11 @@
10
10
  VariableDictionary,
11
11
  CustomVariables,
12
12
  } from "../../types/variables";
13
- import type { WalletButtonRender } from "../../types/wallet";
13
+ import type {
14
+ WalletButtonRender,
15
+ WalletPurchasePrepare,
16
+ WalletPurchaseStart,
17
+ } from "../../types/wallet";
14
18
  import type { UIConfig } from "../../types/ui-config";
15
19
  import type { ReservedAttribute } from "../../types/components/input-text";
16
20
  import type {
@@ -58,6 +62,8 @@
58
62
  onRestorePurchasesClicked?: () => void;
59
63
  onVisitCustomerCenterClicked?: () => void;
60
64
  walletButtonRender?: WalletButtonRender;
65
+ prepareWalletPurchase?: WalletPurchasePrepare;
66
+ onWalletPurchaseClicked?: WalletPurchaseStart;
61
67
  safeAreaFallbackColor?: ColorScheme | null;
62
68
  hideBackButtons?: boolean;
63
69
  offering?: PaywallOffering | null;
@@ -90,6 +96,8 @@
90
96
  onRestorePurchasesClicked,
91
97
  onVisitCustomerCenterClicked,
92
98
  walletButtonRender,
99
+ prepareWalletPurchase,
100
+ onWalletPurchaseClicked,
93
101
  safeAreaFallbackColor,
94
102
  hideBackButtons,
95
103
  offering,
@@ -137,6 +145,8 @@
137
145
  {onSensitiveInputChanged}
138
146
  {onReservedAttributeChanged}
139
147
  {walletButtonRender}
148
+ {prepareWalletPurchase}
149
+ {onWalletPurchaseClicked}
140
150
  {safeAreaFallbackColor}
141
151
  {hideBackButtons}
142
152
  {offering}
@@ -4,7 +4,7 @@ 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
6
  import type { PackageInfo, VariableDictionary, CustomVariables } from "../../types/variables";
7
- import type { WalletButtonRender } from "../../types/wallet";
7
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } from "../../types/wallet";
8
8
  import type { UIConfig } from "../../types/ui-config";
9
9
  import type { ReservedAttribute } from "../../types/components/input-text";
10
10
  import type { PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
@@ -36,6 +36,8 @@ interface Props {
36
36
  onRestorePurchasesClicked?: () => void;
37
37
  onVisitCustomerCenterClicked?: () => void;
38
38
  walletButtonRender?: WalletButtonRender;
39
+ prepareWalletPurchase?: WalletPurchasePrepare;
40
+ onWalletPurchaseClicked?: WalletPurchaseStart;
39
41
  safeAreaFallbackColor?: ColorScheme | null;
40
42
  hideBackButtons?: boolean;
41
43
  offering?: PaywallOffering | null;
@@ -10,7 +10,11 @@
10
10
  VariableDictionary,
11
11
  CustomVariables,
12
12
  } from "../../types/variables";
13
- import type { WalletButtonRender } from "../../types/wallet";
13
+ import type {
14
+ WalletButtonRender,
15
+ WalletPurchasePrepare,
16
+ WalletPurchaseStart,
17
+ } from "../../types/wallet";
14
18
  import type { UIConfig } from "../../types/ui-config";
15
19
  import type { ReservedAttribute } from "../../types/components/input-text";
16
20
  import type {
@@ -51,6 +55,8 @@
51
55
  initialInputSelections?: InitialInputSelections;
52
56
  containerId?: string;
53
57
  walletButtonRender?: WalletButtonRender;
58
+ prepareWalletPurchase?: WalletPurchasePrepare;
59
+ onWalletPurchaseClicked?: WalletPurchaseStart;
54
60
  safeAreaFallbackColor?: ColorScheme | null;
55
61
  hideBackButtons?: boolean;
56
62
  onPurchaseClicked?: (
@@ -105,6 +111,8 @@
105
111
  initialInputSelections,
106
112
  containerId,
107
113
  walletButtonRender,
114
+ prepareWalletPurchase,
115
+ onWalletPurchaseClicked,
108
116
  safeAreaFallbackColor,
109
117
  hideBackButtons,
110
118
  onPurchaseClicked,
@@ -368,6 +376,8 @@
368
376
  {initialInputSelections}
369
377
  {containerId}
370
378
  {walletButtonRender}
379
+ {prepareWalletPurchase}
380
+ {onWalletPurchaseClicked}
371
381
  {safeAreaFallbackColor}
372
382
  {hideBackButtons}
373
383
  {onPurchaseClicked}
@@ -3,7 +3,7 @@ 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 { PackageInfo, VariableDictionary, CustomVariables } from "../../types/variables";
6
- import type { WalletButtonRender } from "../../types/wallet";
6
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } 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";
@@ -21,6 +21,8 @@ interface Props {
21
21
  initialInputSelections?: InitialInputSelections;
22
22
  containerId?: string;
23
23
  walletButtonRender?: WalletButtonRender;
24
+ prepareWalletPurchase?: WalletPurchasePrepare;
25
+ onWalletPurchaseClicked?: WalletPurchaseStart;
24
26
  safeAreaFallbackColor?: ColorScheme | null;
25
27
  hideBackButtons?: boolean;
26
28
  onPurchaseClicked?: (packageId: string, actionId: string) => void | Promise<void>;
package/dist/index.d.ts CHANGED
@@ -30,7 +30,7 @@ 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
32
  export type { PaywallContext, PaywallOffering, PaywallPackage, PaywallWorkflow, } from "@revenuecat/workflow-web-components-sdk";
33
- export { type WalletButtonRender, type WalletButtonTheme, } from "./types/wallet";
33
+ export { type WalletButtonRender, type WalletButtonTheme, type WalletPurchasePrepare, type WalletPurchaseStart, } from "./types/wallet";
34
34
  export { type CustomVariables, CustomVariableValue, mergeCustomVariables, type VariableDictionary, type PackageInfo, } from "./types/variables";
35
35
  export type { Action, CompleteWorkflowNavigateArgs, CompleteWorkflowUrlQueryParams, } from "./types/components/button";
36
36
  export type { LoaderProps } from "./types/components/loader";
@@ -3,7 +3,7 @@ import type { ReservedAttribute } from "../types/components/input-text";
3
3
  import type { ComponentInteractionData } from "../types/paywall-component-interaction";
4
4
  import type { UIConfig } from "../types/ui-config";
5
5
  import type { PackageInfo, VariableDictionary } from "../types/variables";
6
- import type { WalletButtonRender } from "../types/wallet";
6
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } from "../types/wallet";
7
7
  import type { PaywallOffering, PaywallPackage, PaywallWorkflow } from "@revenuecat/workflow-web-components-sdk";
8
8
  import { type Readable, type Writable } from "svelte/store";
9
9
  import type { BridgeCustomValues, BridgeInputs } from "../components/web-view/build-paywall-context";
@@ -44,6 +44,8 @@ type PaywallContext = Readonly<{
44
44
  onReservedAttributeChanged?: (reservedAttribute: ReservedAttribute, value: string) => void;
45
45
  walletButtonRender?: WalletButtonRender;
46
46
  onWalletButtonReady?: (walletButtonAvailable?: boolean) => void;
47
+ prepareWalletPurchase?: WalletPurchasePrepare;
48
+ onWalletPurchaseClicked?: WalletPurchaseStart;
47
49
  onButtonAction: (action: Action, actionId?: string) => void;
48
50
  uiConfig: UIConfig;
49
51
  hideBackButtons: boolean;
@@ -1,3 +1,3 @@
1
- import type { WalletButtonRender } from "../types/wallet";
1
+ import type { WalletButtonRender, WalletPurchasePrepare, WalletPurchaseStart } from "../types/wallet";
2
2
  import type { DecoratorFunction, Renderer } from "storybook/internal/csf";
3
- export declare function paywallDecorator<TRenderer extends Renderer, TArgs>(walletButtonRender?: WalletButtonRender, selectedPackageId?: string): DecoratorFunction<TRenderer, TArgs>;
3
+ export declare function paywallDecorator<TRenderer extends Renderer, TArgs>(walletButtonRender?: WalletButtonRender, selectedPackageId?: string, prepareWalletPurchase?: WalletPurchasePrepare, onWalletPurchaseClicked?: WalletPurchaseStart): DecoratorFunction<TRenderer, TArgs>;
@@ -1,6 +1,6 @@
1
1
  import { setPaywallContext } from "../stores/paywall";
2
2
  import { readable, writable } from "svelte/store";
3
- export function paywallDecorator(walletButtonRender, selectedPackageId) {
3
+ export function paywallDecorator(walletButtonRender, selectedPackageId, prepareWalletPurchase, onWalletPurchaseClicked) {
4
4
  return (Story) => {
5
5
  const selectedPackageIdWritable = writable(selectedPackageId);
6
6
  selectedPackageIdWritable.subscribe((packageId) => {
@@ -24,6 +24,8 @@ export function paywallDecorator(walletButtonRender, selectedPackageId) {
24
24
  onButtonAction: (action, actionId) => window.alert(`Button clicked: ${JSON.stringify({ action, actionId }, undefined, 2)}`),
25
25
  hideBackButtons: false,
26
26
  walletButtonRender,
27
+ prepareWalletPurchase,
28
+ onWalletPurchaseClicked,
27
29
  uiConfig: emptyUiConfig,
28
30
  });
29
31
  return Story();
@@ -2,10 +2,12 @@ import type { BaseComponent } from "../base";
2
2
  import type { BorderType, ShadowType, ShapeType, SizeType, Spacing } from "../..";
3
3
  import type { Dimension } from "../alignment";
4
4
  import type { WalletButtonTheme } from "../wallet";
5
+ import type { StackProps } from "./stack";
5
6
  export interface WalletButtonStyle {
6
7
  light: WalletButtonTheme;
7
8
  dark?: WalletButtonTheme | null;
8
9
  }
10
+ export type WalletButtonMode = "store" | "custom";
9
11
  export interface WalletButtonProps extends BaseComponent {
10
12
  type: "wallet_button";
11
13
  size?: SizeType;
@@ -17,4 +19,6 @@ export interface WalletButtonProps extends BaseComponent {
17
19
  style?: WalletButtonStyle | null;
18
20
  onWalletButtonReady?: (walletButtonAvailable?: boolean) => void;
19
21
  shadow?: ShadowType | null;
22
+ mode?: WalletButtonMode | null;
23
+ stack?: StackProps | null;
20
24
  }
@@ -44,7 +44,8 @@ export type PackageSelectionSheetInteractionData = ComponentInteractionBase<"pac
44
44
  currentProductId?: string;
45
45
  resultingProductId?: string;
46
46
  };
47
- export type ComponentInteractionData = TabInteractionData | SwitchInteractionData | CarouselInteractionData | ButtonInteractionData | TextInteractionData | PackageInteractionData | PurchaseButtonInteractionData | PackageSelectionSheetInteractionData;
47
+ export type WalletButtonInteractionData = ComponentInteractionBase<"wallet_button", "custom_purchase">;
48
+ export type ComponentInteractionData = TabInteractionData | SwitchInteractionData | CarouselInteractionData | ButtonInteractionData | TextInteractionData | PackageInteractionData | PurchaseButtonInteractionData | PackageSelectionSheetInteractionData | WalletButtonInteractionData;
48
49
  export type ComponentInteractionType = ComponentInteractionData["componentType"];
49
50
  export type OnComponentInteraction = (data: ComponentInteractionData) => void;
50
51
  export {};
@@ -4,6 +4,10 @@
4
4
  * @default "black"
5
5
  */
6
6
  export type WalletButtonTheme = "black" | "white" | "white-outline";
7
+ /** Host checks whether the wallet purchase path is usable for this package. Resolves false on any error. */
8
+ export type WalletPurchasePrepare = (selectedPackageId: string) => Promise<boolean>;
9
+ /** Host starts the wallet purchase. Must be invoked synchronously from the click so Safari keeps the user activation. Resolves when the attempt settles (success, cancel, or error). */
10
+ export type WalletPurchaseStart = (selectedPackageId: string) => Promise<void>;
7
11
  export type WalletButtonRender = (node: HTMLElement, params: {
8
12
  selectedPackageId: string;
9
13
  onReady?: (walletButtonAvailable?: boolean) => void;
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.10.2",
5
+ "version": "4.11.0",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },