@revenuecat/purchases-ui-js 4.14.2 → 4.16.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.
Files changed (39) hide show
  1. package/dist/components/button/ButtonNode.svelte +6 -0
  2. package/dist/components/package/Package.svelte +82 -10
  3. package/dist/components/paywall/Paywall.stories.svelte +335 -1
  4. package/dist/components/paywall/Paywall.svelte +95 -4
  5. package/dist/components/paywall/Paywall.svelte.d.ts +1 -0
  6. package/dist/components/paywall/Sheet.svelte +31 -1
  7. package/dist/components/paywall/fixtures/stack-scroll-paywall.d.ts +40 -0
  8. package/dist/components/paywall/fixtures/stack-scroll-paywall.js +280 -0
  9. package/dist/components/paywall/fixtures/window-rule-packages-paywall.d.ts +14 -0
  10. package/dist/components/paywall/fixtures/window-rule-packages-paywall.js +89 -0
  11. package/dist/components/paywall/fixtures/window-size-paywall.d.ts +2 -0
  12. package/dist/components/paywall/fixtures/window-size-paywall.js +48 -0
  13. package/dist/components/stack/Stack.svelte +87 -9
  14. package/dist/components/stack/Stack.svelte.d.ts +2 -0
  15. package/dist/components/stack/stack-scroll.d.ts +59 -0
  16. package/dist/components/stack/stack-scroll.js +162 -0
  17. package/dist/components/tabs/Tabs.svelte +11 -0
  18. package/dist/components/workflows/Screen.svelte +3 -0
  19. package/dist/components/workflows/Screen.svelte.d.ts +1 -0
  20. package/dist/components/workflows/Workflow.svelte +3 -0
  21. package/dist/components/workflows/Workflow.svelte.d.ts +1 -0
  22. package/dist/stores/condition-context.svelte.js +3 -0
  23. package/dist/stores/default-selection.d.ts +17 -0
  24. package/dist/stores/default-selection.js +21 -0
  25. package/dist/stores/rendered-packages.d.ts +10 -0
  26. package/dist/stores/rendered-packages.js +17 -0
  27. package/dist/stores/window-size.d.ts +10 -0
  28. package/dist/stores/window-size.js +10 -0
  29. package/dist/types/components/button.d.ts +1 -1
  30. package/dist/types/components/package.d.ts +1 -0
  31. package/dist/types/components/stack.d.ts +6 -0
  32. package/dist/types/overrides.d.ts +15 -1
  33. package/dist/types/overrides.js +3 -0
  34. package/dist/types/paywall-component-interaction.d.ts +1 -1
  35. package/dist/utils/package-selection.d.ts +26 -0
  36. package/dist/utils/package-selection.js +47 -0
  37. package/dist/utils/style-utils.d.ts +12 -0
  38. package/dist/utils/style-utils.js +59 -0
  39. package/package.json +2 -2
@@ -7,6 +7,8 @@ interface MiscProps {
7
7
  style?: CSS;
8
8
  class?: string;
9
9
  testId?: string;
10
+ /** The paywall's root stack, which the paywall scrolls vertically. */
11
+ rootStack?: boolean;
10
12
  }
11
13
  type $$ComponentProps = StackProps & MiscProps;
12
14
  declare const Stack: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,59 @@
1
+ import type { SizeType } from "../../types";
2
+ import type { Dimension } from "../../types/alignment";
3
+ import type { Component } from "../../types/component";
4
+ import type { StackProps } from "../../types/components/stack";
5
+ import type { CSS } from "../../utils/base-utils";
6
+ import { type ConditionContext } from "../../utils/style-utils";
7
+ export type ScrollAxis = "x" | "y";
8
+ /**
9
+ * The axis a stack scrolls along, like the native SDKs: horizontal stacks
10
+ * scroll sideways, vertical ones down. The root is already the paywall's
11
+ * vertical scroller, and z-layers never scroll.
12
+ */
13
+ export declare function stackScrollAxis(dimension: Dimension["type"], rootStack: boolean): ScrollAxis | null;
14
+ /**
15
+ * How the paywall's root stack scrolls, the one place that decides it. Like
16
+ * the SDKs it scrolls down unless its overflow is "default", and a horizontal
17
+ * root set to scroll also scrolls sideways.
18
+ */
19
+ export declare function rootScrollStyle(overflow: StackProps["overflow"], axis: ScrollAxis | null): CSS;
20
+ /**
21
+ * Like the SDKs, a Fit stack that scrolls takes the space its parent leaves
22
+ * it, up to its content, then scrolls. Along the parent's axis it shrinks
23
+ * first, so a parent that grows with its content never caps it. Across a row
24
+ * with a set height it stops at the row's height and keeps the row's
25
+ * alignment. A Fit row's height comes from its other children, where a
26
+ * percentage max-height doesn't resolve, so it stretches to the row instead.
27
+ * With no parent stack (a Fit root) it stops at its container. Inside a parent
28
+ * that scrolls the same way it keeps its full size.
29
+ */
30
+ export declare function fitScrollSizing(axis: ScrollAxis | null, size: SizeType, parentDimension: Dimension["type"] | undefined, parentScrollAxis: ScrollAxis | null | undefined, parentHasSetHeight?: boolean): CSS;
31
+ /**
32
+ * The axis a Fit stack gives way along because a Fit stack inside it scrolls
33
+ * there. In the SDKs the scroll view makes its Fit parents take the space
34
+ * they're offered, so they size like a scroller, without scrolling.
35
+ */
36
+ export declare function fitScrollerParentAxis(size: SizeType, components: Component[], context: ConditionContext): ScrollAxis | null;
37
+ /**
38
+ * What stops a sheet's contents stack that scrolls: a Fit stack stops at the
39
+ * screen's height, as the paywall's root does, and a stack with a set or Fill
40
+ * height at that height. A Fit sheet gives Fill contents no height to fill, so
41
+ * the sheet takes the screen's height for them. Either way the stack scrolls
42
+ * itself.
43
+ */
44
+ export declare function sheetScrollBound(contents: Pick<StackProps, "overflow" | "dimension" | "size">, sheetHeight: SizeType["height"]["type"]): "screen" | "full-screen" | "stack" | null;
45
+ /**
46
+ * Whether a stack's height is set (Fill, Fixed or the paywall's root) rather
47
+ * than coming from its children, so a child's percentage height resolves.
48
+ */
49
+ export declare function stackHasSetHeight(size: SizeType, rootStack: boolean): boolean;
50
+ type GetHasSetHeight = () => boolean;
51
+ /** Publishes whether this stack's height is set and returns the parent's. */
52
+ export declare function setStackSetHeightContext(context: GetHasSetHeight): GetHasSetHeight | undefined;
53
+ type GetScrollAxis = () => ScrollAxis | null;
54
+ /**
55
+ * Publishes the axis this stack scrolls along and returns the parent's, so a
56
+ * child knows when it sits inside a scroller.
57
+ */
58
+ export declare function setStackScrollContext(context: GetScrollAxis): GetScrollAxis | undefined;
59
+ export {};
@@ -0,0 +1,162 @@
1
+ import { resolveOverrideProperties, } from "../../utils/style-utils";
2
+ import { getContext, setContext } from "svelte";
3
+ /**
4
+ * The axis a stack scrolls along, like the native SDKs: horizontal stacks
5
+ * scroll sideways, vertical ones down. The root is already the paywall's
6
+ * vertical scroller, and z-layers never scroll.
7
+ */
8
+ export function stackScrollAxis(dimension, rootStack) {
9
+ if (dimension === "horizontal")
10
+ return "x";
11
+ if (dimension === "vertical" && !rootStack)
12
+ return "y";
13
+ return null;
14
+ }
15
+ /**
16
+ * How the paywall's root stack scrolls, the one place that decides it. Like
17
+ * the SDKs it scrolls down unless its overflow is "default", and a horizontal
18
+ * root set to scroll also scrolls sideways.
19
+ */
20
+ export function rootScrollStyle(overflow, axis) {
21
+ if (overflow === "default")
22
+ return { overflow: "clip" };
23
+ return axis === "x"
24
+ ? { "overflow-x": "auto", "overflow-y": "auto" }
25
+ : { "overflow-y": "auto" };
26
+ }
27
+ // Far above its siblings' 1, so a scrolling stack gives up space before they
28
+ // do, like a SwiftUI ScrollView next to fixed content.
29
+ const SCROLLER_FLEX_SHRINK = 1_000_000;
30
+ /**
31
+ * Like the SDKs, a Fit stack that scrolls takes the space its parent leaves
32
+ * it, up to its content, then scrolls. Along the parent's axis it shrinks
33
+ * first, so a parent that grows with its content never caps it. Across a row
34
+ * with a set height it stops at the row's height and keeps the row's
35
+ * alignment. A Fit row's height comes from its other children, where a
36
+ * percentage max-height doesn't resolve, so it stretches to the row instead.
37
+ * With no parent stack (a Fit root) it stops at its container. Inside a parent
38
+ * that scrolls the same way it keeps its full size.
39
+ */
40
+ export function fitScrollSizing(axis, size, parentDimension, parentScrollAxis, parentHasSetHeight = false) {
41
+ if (axis == null)
42
+ return {};
43
+ if (axis === parentScrollAxis)
44
+ return { "flex-shrink": 0 };
45
+ if (axis === "y" && size.height.type === "fit") {
46
+ if (parentDimension === "vertical") {
47
+ return { "flex-shrink": SCROLLER_FLEX_SHRINK, "min-height": 0 };
48
+ }
49
+ if (parentDimension == null || parentHasSetHeight) {
50
+ return { "min-height": 0, "max-height": "100%" };
51
+ }
52
+ return {
53
+ height: "auto",
54
+ "align-self": "stretch",
55
+ "min-height": 0,
56
+ "max-height": "max-content",
57
+ };
58
+ }
59
+ if (axis === "x" && size.width.type === "fit") {
60
+ return parentDimension === "horizontal"
61
+ ? { "flex-shrink": SCROLLER_FLEX_SHRINK, "min-width": 0 }
62
+ : { "min-width": 0, "max-width": "100%" };
63
+ }
64
+ return {};
65
+ }
66
+ // The stack a component lays out as: its own, or the one a package or button
67
+ // wraps and takes its size from.
68
+ function layoutStack(component) {
69
+ switch (component.type) {
70
+ case "stack":
71
+ return component;
72
+ case "package":
73
+ case "button":
74
+ case "purchase_button":
75
+ return component.stack;
76
+ default:
77
+ return undefined;
78
+ }
79
+ }
80
+ // Whether a Fit stack scrolls along this direction somewhere below, reached
81
+ // only through Fit stacks: a stack with a set size bounds what's inside it.
82
+ // A hidden stack doesn't render, so neither it nor its contents count.
83
+ function containsFitScroller(components, direction, context) {
84
+ return components.some((child) => {
85
+ const stack = layoutStack(child);
86
+ if (!stack)
87
+ return false;
88
+ const resolved = {
89
+ ...stack,
90
+ ...resolveOverrideProperties(context, stack.overrides),
91
+ };
92
+ if (resolved.visible === false)
93
+ return false;
94
+ const size = direction === "vertical" ? resolved.size.height : resolved.size.width;
95
+ if (size.type !== "fit")
96
+ return false;
97
+ if (resolved.overflow === "scroll" &&
98
+ resolved.dimension.type === direction) {
99
+ return true;
100
+ }
101
+ return containsFitScroller(resolved.components, direction, context);
102
+ });
103
+ }
104
+ /**
105
+ * The axis a Fit stack gives way along because a Fit stack inside it scrolls
106
+ * there. In the SDKs the scroll view makes its Fit parents take the space
107
+ * they're offered, so they size like a scroller, without scrolling.
108
+ */
109
+ export function fitScrollerParentAxis(size, components, context) {
110
+ if (size.height.type === "fit" &&
111
+ containsFitScroller(components, "vertical", context)) {
112
+ return "y";
113
+ }
114
+ if (size.width.type === "fit" &&
115
+ containsFitScroller(components, "horizontal", context)) {
116
+ return "x";
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * What stops a sheet's contents stack that scrolls: a Fit stack stops at the
122
+ * screen's height, as the paywall's root does, and a stack with a set or Fill
123
+ * height at that height. A Fit sheet gives Fill contents no height to fill, so
124
+ * the sheet takes the screen's height for them. Either way the stack scrolls
125
+ * itself.
126
+ */
127
+ export function sheetScrollBound(contents, sheetHeight) {
128
+ if (contents.overflow !== "scroll" ||
129
+ contents.dimension.type !== "vertical") {
130
+ return null;
131
+ }
132
+ if (contents.size.height.type === "fit")
133
+ return "screen";
134
+ if (contents.size.height.type === "fill" && sheetHeight === "fit") {
135
+ return "full-screen";
136
+ }
137
+ return "stack";
138
+ }
139
+ /**
140
+ * Whether a stack's height is set (Fill, Fixed or the paywall's root) rather
141
+ * than coming from its children, so a child's percentage height resolves.
142
+ */
143
+ export function stackHasSetHeight(size, rootStack) {
144
+ return rootStack || size.height.type !== "fit";
145
+ }
146
+ const heightKey = Symbol("stack-set-height");
147
+ /** Publishes whether this stack's height is set and returns the parent's. */
148
+ export function setStackSetHeightContext(context) {
149
+ const parentContext = getContext(heightKey);
150
+ setContext(heightKey, context);
151
+ return parentContext;
152
+ }
153
+ const key = Symbol("stack-scroll");
154
+ /**
155
+ * Publishes the axis this stack scrolls along and returns the parent's, so a
156
+ * child knows when it sits inside a scroller.
157
+ */
158
+ export function setStackScrollContext(context) {
159
+ const parentContext = getContext(key);
160
+ setContext(key, context);
161
+ return parentContext;
162
+ }
@@ -31,6 +31,11 @@
31
31
  setRelativeDiscountScopeContext,
32
32
  } from "../../stores/relativeDiscount";
33
33
  import { mostExpensivePricePerMonth } from "../../utils/relative-discount";
34
+ import {
35
+ getDefaultSelectionContext,
36
+ openDefaultSelection,
37
+ setDefaultSelectionContext,
38
+ } from "../../stores/default-selection";
34
39
 
35
40
  const props: TabsProps = $props();
36
41
  const { name, control, tabs, default_tab_id, state_updates } = props;
@@ -90,6 +95,11 @@
90
95
 
91
96
  const getTabName = (index: number) => tabs[index]?.name;
92
97
 
98
+ // The first tab renders with its paywall or sheet; each newly chosen tab
99
+ // lets its default package select itself.
100
+ let isDefaultSelectionOpen = getDefaultSelectionContext();
101
+ setDefaultSelectionContext(() => isDefaultSelectionOpen());
102
+
93
103
  const selectTab: SelectTab = (tabId, tabIndex, interactionType) => {
94
104
  const foundIndex = tabs.findIndex((tab) => tab.id === tabId);
95
105
  const nextIndex = foundIndex !== -1 ? foundIndex : tabIndex;
@@ -118,6 +128,7 @@
118
128
  });
119
129
  }
120
130
 
131
+ isDefaultSelectionOpen = openDefaultSelection();
121
132
  selectedTabIndex.set(nextIndex);
122
133
  }
123
134
  };
@@ -61,6 +61,7 @@
61
61
  onNavigateToUrlClicked?: (url: string) => void;
62
62
  onRestorePurchasesClicked?: () => void;
63
63
  onVisitCustomerCenterClicked?: () => void;
64
+ onCookieSettingsClicked?: () => void;
64
65
  walletButtonRender?: WalletButtonRender;
65
66
  prepareWalletPurchase?: WalletPurchasePrepare;
66
67
  onWalletPurchaseClicked?: WalletPurchaseStart;
@@ -99,6 +100,7 @@
99
100
  onNavigateToUrlClicked,
100
101
  onRestorePurchasesClicked,
101
102
  onVisitCustomerCenterClicked,
103
+ onCookieSettingsClicked,
102
104
  walletButtonRender,
103
105
  prepareWalletPurchase,
104
106
  onWalletPurchaseClicked,
@@ -141,6 +143,7 @@
141
143
  }}
142
144
  {onCompleteWorkflowNavigate}
143
145
  onVisitCustomerCenterClicked={() => onVisitCustomerCenterClicked?.()}
146
+ onCookieSettingsClicked={() => onCookieSettingsClicked?.()}
144
147
  {onBackClicked}
145
148
  {onClose}
146
149
  onRestorePurchasesClicked={() => onRestorePurchasesClicked?.()}
@@ -35,6 +35,7 @@ interface Props {
35
35
  onNavigateToUrlClicked?: (url: string) => void;
36
36
  onRestorePurchasesClicked?: () => void;
37
37
  onVisitCustomerCenterClicked?: () => void;
38
+ onCookieSettingsClicked?: () => void;
38
39
  walletButtonRender?: WalletButtonRender;
39
40
  prepareWalletPurchase?: WalletPurchasePrepare;
40
41
  onWalletPurchaseClicked?: WalletPurchaseStart;
@@ -88,6 +88,7 @@
88
88
  onNavigateToUrlClicked?: (url: string) => void;
89
89
  onRestorePurchasesClicked?: () => void;
90
90
  onVisitCustomerCenterClicked?: () => void;
91
+ onCookieSettingsClicked?: () => void;
91
92
  onComponentInteraction?: OnComponentInteraction;
92
93
  onClose?: () => void;
93
94
  onExitBack?: () => void;
@@ -132,6 +133,7 @@
132
133
  onNavigateToUrlClicked,
133
134
  onRestorePurchasesClicked,
134
135
  onVisitCustomerCenterClicked,
136
+ onCookieSettingsClicked,
135
137
  onComponentInteraction,
136
138
  onClose,
137
139
  onExitBack,
@@ -401,6 +403,7 @@
401
403
  {onNavigateToUrlClicked}
402
404
  {onRestorePurchasesClicked}
403
405
  {onVisitCustomerCenterClicked}
406
+ {onCookieSettingsClicked}
404
407
  {onComponentInteraction}
405
408
  onBackClicked={handleBackClicked}
406
409
  onClose={() => {
@@ -42,6 +42,7 @@ interface Props {
42
42
  onNavigateToUrlClicked?: (url: string) => void;
43
43
  onRestorePurchasesClicked?: () => void;
44
44
  onVisitCustomerCenterClicked?: () => void;
45
+ onCookieSettingsClicked?: () => void;
45
46
  onComponentInteraction?: OnComponentInteraction;
46
47
  onClose?: () => void;
47
48
  onExitBack?: () => void;
@@ -5,6 +5,7 @@ import { getPaywallContext } from "./paywall";
5
5
  import { getSelectedStateContext } from "./selected";
6
6
  import { getPaywallStateContext, getStateSignal } from "./state";
7
7
  import { getOptionalVariablesContext } from "./variables";
8
+ import { getWindowSizeContext } from "./window-size";
8
9
  /**
9
10
  * Builds ConditionContext snapshots for resolveOverrideProperties from the
10
11
  * ambient Svelte contexts. Call at component init; invoke the returned builder
@@ -16,6 +17,7 @@ export function useConditionContext(options = {}) {
16
17
  const packageInfo = fromStore(getOptionalPackageInfoContext());
17
18
  const selectedPackageId = fromStore(getPaywallContext().selectedPackageId);
18
19
  const variables = fromStore(getOptionalVariablesContext());
20
+ const windowSize = fromStore(getWindowSizeContext());
19
21
  const stateStore = options.state ? getPaywallStateContext() : undefined;
20
22
  const stateSignal = options.state ? fromStore(getStateSignal()) : null;
21
23
  return (extra) => {
@@ -27,6 +29,7 @@ export function useConditionContext(options = {}) {
27
29
  packageInfo: packageInfo.current,
28
30
  variables: variables.current ?? {},
29
31
  selected: selected.current,
32
+ windowSize: windowSize.current,
30
33
  ...extra,
31
34
  };
32
35
  if (hover) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a default package rendering now should select itself. It should
3
+ * while the paywall, a newly chosen tab or a sheet first renders, but not when
4
+ * a rule shows its stack again later and the user has picked something else.
5
+ */
6
+ export type DefaultSelectionOpen = () => boolean;
7
+ /**
8
+ * Open until Svelte has applied the update in progress, which mounts the
9
+ * paywall's, sheet's or new tab's packages and runs their effects. A tab
10
+ * switch reuses the package components, so the window can't close on a mount.
11
+ * Packages that render in a later update, such as a stack a rule shows again,
12
+ * find it closed. Covered by the "package selection" tests in
13
+ * window-size-rules.test.ts and by package-default-selection-tabs.test.ts.
14
+ */
15
+ export declare function openDefaultSelection(): DefaultSelectionOpen;
16
+ export declare function setDefaultSelectionContext(isOpen: DefaultSelectionOpen): void;
17
+ export declare function getDefaultSelectionContext(): DefaultSelectionOpen;
@@ -0,0 +1,21 @@
1
+ import { getContext, setContext, tick } from "svelte";
2
+ const key = Symbol("default-selection");
3
+ /**
4
+ * Open until Svelte has applied the update in progress, which mounts the
5
+ * paywall's, sheet's or new tab's packages and runs their effects. A tab
6
+ * switch reuses the package components, so the window can't close on a mount.
7
+ * Packages that render in a later update, such as a stack a rule shows again,
8
+ * find it closed. Covered by the "package selection" tests in
9
+ * window-size-rules.test.ts and by package-default-selection-tabs.test.ts.
10
+ */
11
+ export function openDefaultSelection() {
12
+ let open = true;
13
+ tick().then(() => (open = false));
14
+ return () => open;
15
+ }
16
+ export function setDefaultSelectionContext(isOpen) {
17
+ setContext(key, isOpen);
18
+ }
19
+ export function getDefaultSelectionContext() {
20
+ return getContext(key) ?? (() => true);
21
+ }
@@ -0,0 +1,10 @@
1
+ import type { SvelteMap } from "svelte/reactivity";
2
+ import type { RenderedPackage } from "../utils/package-selection";
3
+ /** The packages on screen, each registered by the Package component rendering it. */
4
+ export type RenderedPackages = SvelteMap<symbol, RenderedPackage>;
5
+ export declare function setRenderedPackagesContext(packages: RenderedPackages): void;
6
+ export declare function getOptionalRenderedPackagesContext(): RenderedPackages | undefined;
7
+ /** Marks the packages rendering inside a sheet with the sheet's id. */
8
+ export declare function setPackageSheetContext(sheetId: string): void;
9
+ /** The id of the sheet a package renders in, or null outside sheets. */
10
+ export declare function getPackageSheetContext(): string | null;
@@ -0,0 +1,17 @@
1
+ import { getContext, setContext } from "svelte";
2
+ const key = Symbol("rendered-packages");
3
+ export function setRenderedPackagesContext(packages) {
4
+ setContext(key, packages);
5
+ }
6
+ export function getOptionalRenderedPackagesContext() {
7
+ return getContext(key);
8
+ }
9
+ const sheetKey = Symbol("package-sheet");
10
+ /** Marks the packages rendering inside a sheet with the sheet's id. */
11
+ export function setPackageSheetContext(sheetId) {
12
+ setContext(sheetKey, sheetId);
13
+ }
14
+ /** The id of the sheet a package renders in, or null outside sheets. */
15
+ export function getPackageSheetContext() {
16
+ return getContext(sheetKey) ?? null;
17
+ }
@@ -0,0 +1,10 @@
1
+ import { type Readable } from "svelte/store";
2
+ /** The paywall's own box in CSS px, which window size rules evaluate against. */
3
+ export type WindowSize = {
4
+ width: number;
5
+ height: number;
6
+ };
7
+ /** Null until the paywall has been measured. */
8
+ export type WindowSizeStore = Readable<WindowSize | null>;
9
+ export declare function setWindowSizeContext(store: WindowSizeStore): void;
10
+ export declare function getWindowSizeContext(): WindowSizeStore;
@@ -0,0 +1,10 @@
1
+ import { getContext, setContext } from "svelte";
2
+ import { readable } from "svelte/store";
3
+ const key = Symbol("windowSize");
4
+ export function setWindowSizeContext(store) {
5
+ setContext(key, store);
6
+ }
7
+ const unknownWindowSize = readable(null);
8
+ export function getWindowSizeContext() {
9
+ return getContext(key) ?? unknownWindowSize;
10
+ }
@@ -17,7 +17,7 @@ interface CloseAction {
17
17
  }
18
18
  interface NavigateToAction {
19
19
  type: "navigate_to";
20
- destination: "customer_center" | "offer_code" | "screen_redirect";
20
+ destination: "customer_center" | "offer_code" | "screen_redirect" | "cookie_settings";
21
21
  }
22
22
  interface NavigateToSheetAction {
23
23
  type: "navigate_to";
@@ -14,6 +14,7 @@ export interface PaddleDiscountConfig {
14
14
  }
15
15
  export interface PackageProps extends BaseComponent {
16
16
  type: "package";
17
+ visible?: boolean | null;
17
18
  package_id: string;
18
19
  is_selected_by_default: boolean;
19
20
  apple_promo_offer_product_code?: string | null;
@@ -27,6 +27,12 @@ export interface StackProps extends BaseComponent {
27
27
  shape: ShapeType | null;
28
28
  shadow: ShadowType | null;
29
29
  badge?: Badge | null;
30
+ /**
31
+ * "scroll" scrolls the stack's content along its axis; "default" turns
32
+ * scrolling off. Unset is the platform default: the root scrolls, other
33
+ * stacks don't.
34
+ */
35
+ overflow?: "scroll" | "default" | null;
30
36
  overrides?: Overrides<StackProps>;
31
37
  }
32
38
  export {};
@@ -2,12 +2,16 @@ import type { CustomVariableValue } from "./variables.js";
2
2
  import type { StateValue } from "./state.js";
3
3
  type EqualityOperator = "=" | "!=";
4
4
  type ArrayOperator = "in" | "not in";
5
+ export type ComparisonOperator = ">=" | ">" | "<=" | "<" | "=";
5
6
  export declare const RuleKey: {
6
7
  readonly SelectedPackageCondition: "selected_package_condition";
7
8
  readonly IntroOfferCondition: "intro_offer_condition";
8
9
  readonly PromoOfferCondition: "promo_offer_condition";
9
10
  readonly VariableCondition: "variable_condition";
10
11
  readonly StateCondition: "state_condition";
12
+ readonly WindowWidthCondition: "window_width_condition";
13
+ readonly WindowHeightCondition: "window_height_condition";
14
+ readonly WindowAspectRatioCondition: "window_aspect_ratio_condition";
11
15
  };
12
16
  type IntroOfferCondition = {
13
17
  type: "intro_offer";
@@ -57,7 +61,17 @@ type StateCondition = {
57
61
  name: string;
58
62
  value: StateValue;
59
63
  };
60
- export type OverrideCondition = IntroOfferCondition | MultipleIntroOffersCondition | SelectedCondition | HoverCondition | FocusCondition | ErrorCondition | PromoOfferCondition | IntroductoryOfferCondition | PromotionalOfferCondition | SelectedPackageCondition | VariableCondition | StateCondition;
64
+ /**
65
+ * Compares the paywall's own box (CSS px, matching iOS points and Android dp)
66
+ * or its aspect ratio (width ÷ height). Never matches before the paywall is
67
+ * measured.
68
+ */
69
+ export type WindowSizeCondition = {
70
+ type: typeof RuleKey.WindowWidthCondition | typeof RuleKey.WindowHeightCondition | typeof RuleKey.WindowAspectRatioCondition;
71
+ operator: ComparisonOperator;
72
+ value: number;
73
+ };
74
+ export type OverrideCondition = IntroOfferCondition | MultipleIntroOffersCondition | SelectedCondition | HoverCondition | FocusCondition | ErrorCondition | PromoOfferCondition | IntroductoryOfferCondition | PromotionalOfferCondition | SelectedPackageCondition | VariableCondition | StateCondition | WindowSizeCondition;
61
75
  /**
62
76
  * The reserved properties of an override that should not be included in the override properties
63
77
  */
@@ -4,4 +4,7 @@ export const RuleKey = {
4
4
  PromoOfferCondition: "promo_offer_condition",
5
5
  VariableCondition: "variable_condition",
6
6
  StateCondition: "state_condition",
7
+ WindowWidthCondition: "window_width_condition",
8
+ WindowHeightCondition: "window_height_condition",
9
+ WindowAspectRatioCondition: "window_aspect_ratio_condition",
7
10
  };
@@ -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" | "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" | "navigate_to_cookie_settings" | "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;
@@ -0,0 +1,26 @@
1
+ import type { PackagePlacement } from "./style-utils";
2
+ export type RenderedPackage = {
3
+ componentId: string;
4
+ packageId: string;
5
+ isSelectedByDefault: boolean;
6
+ /** The sheet it renders in, or null outside sheets. */
7
+ sheetId: string | null;
8
+ visible: boolean;
9
+ /** Whether it shows both before and once it's selected, so it can take over the selection. */
10
+ selectable: boolean;
11
+ };
12
+ /**
13
+ * The package to select instead of `current` when a rule hides every copy of
14
+ * it on screen, or `undefined` to keep it. The replacement is a tab's authored
15
+ * default, then one outside the tabs, then the first selectable package in a
16
+ * tab, then outside. The selection stays when no copy is on screen or nothing
17
+ * else can take it.
18
+ *
19
+ * This follows the order in purchases-android "Reconcile package selection
20
+ * when a window size rule hides the selected package" (#4198, not merged yet),
21
+ * without its per-tab remembered pick, which web doesn't have. iOS differs: it
22
+ * only picks within the hidden package's own tab, or among the packages
23
+ * outside tabs (PackageValidator.swift). Either way, a package hidden in an
24
+ * open sheet is replaced from that sheet first, when one there can take it.
25
+ */
26
+ export declare function reconciledSelection(current: string | undefined, rendered: Iterable<RenderedPackage>, placements: Map<string, PackagePlacement>): string | undefined;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The package to select instead of `current` when a rule hides every copy of
3
+ * it on screen, or `undefined` to keep it. The replacement is a tab's authored
4
+ * default, then one outside the tabs, then the first selectable package in a
5
+ * tab, then outside. The selection stays when no copy is on screen or nothing
6
+ * else can take it.
7
+ *
8
+ * This follows the order in purchases-android "Reconcile package selection
9
+ * when a window size rule hides the selected package" (#4198, not merged yet),
10
+ * without its per-tab remembered pick, which web doesn't have. iOS differs: it
11
+ * only picks within the hidden package's own tab, or among the packages
12
+ * outside tabs (PackageValidator.swift). Either way, a package hidden in an
13
+ * open sheet is replaced from that sheet first, when one there can take it.
14
+ */
15
+ export function reconciledSelection(current, rendered, placements) {
16
+ if (current === undefined)
17
+ return undefined;
18
+ const packages = [...rendered];
19
+ const copies = packages.filter((pkg) => pkg.packageId === current);
20
+ if (copies.length === 0 || copies.some((pkg) => pkg.visible)) {
21
+ return undefined;
22
+ }
23
+ // A sheet's packages aren't in the base tree, so they sort last.
24
+ const placement = (pkg) => placements.get(pkg.componentId) ?? {
25
+ index: Number.POSITIVE_INFINITY,
26
+ inTabs: false,
27
+ };
28
+ const candidates = packages
29
+ .filter((pkg) => pkg.selectable)
30
+ .sort((a, b) => placement(a).index - placement(b).index);
31
+ // A sheet's purchase button buys the selection, so when the hidden package
32
+ // is in an open sheet, a package still showing in that sheet comes first.
33
+ const sheetId = copies[0].sheetId;
34
+ if (sheetId !== null && copies.every((pkg) => pkg.sheetId === sheetId)) {
35
+ const inSheet = candidates.filter((pkg) => pkg.sheetId === sheetId);
36
+ const replacement = inSheet.find((pkg) => pkg.isSelectedByDefault) ?? inSheet[0];
37
+ if (replacement)
38
+ return replacement.packageId;
39
+ }
40
+ const inTabs = candidates.filter((pkg) => placement(pkg).inTabs);
41
+ const outsideTabs = candidates.filter((pkg) => !placement(pkg).inTabs);
42
+ const replacement = inTabs.find((pkg) => pkg.isSelectedByDefault) ??
43
+ outsideTabs.find((pkg) => pkg.isSelectedByDefault) ??
44
+ inTabs[0] ??
45
+ outsideTabs[0];
46
+ return replacement?.packageId;
47
+ }
@@ -3,6 +3,7 @@ import type { Overrides } from "../types/overrides.js";
3
3
  import type { RootPaywall } from "../types/paywall.js";
4
4
  import type { StateValue } from "../types/state.js";
5
5
  import type { PackageInfo, VariableDictionary } from "../types/variables.js";
6
+ import type { WindowSize } from "../stores/window-size.js";
6
7
  /**
7
8
  * Given an instance of PaywallData, returns the id of the first package marked as `is_selected_by_default` if any.
8
9
  * If none are marked, returns the first package encountered in traversal order (root stack, then sticky footer).
@@ -17,6 +18,15 @@ export declare function collectPackageIds(root: Component): string[];
17
18
  * Duplicates keep the first occurrence. Unselected tabs still count.
18
19
  */
19
20
  export declare function collectPackageIdsInDisplayOrder({ stack, sticky_footer, }: RootPaywall): string[];
21
+ export type PackagePlacement = {
22
+ index: number;
23
+ inTabs: boolean;
24
+ };
25
+ /**
26
+ * Each package component's place in display order (root stack, then sticky
27
+ * footer) and whether it sits inside tabs, by component id.
28
+ */
29
+ export declare function collectPackagePlacements({ stack, sticky_footer, }: RootPaywall): Map<string, PackagePlacement>;
20
30
  export type ConditionContext = {
21
31
  selectedPackageId: string | undefined;
22
32
  packageInfo: PackageInfo | undefined;
@@ -25,6 +35,8 @@ export type ConditionContext = {
25
35
  stateValues?: Record<string, StateValue>;
26
36
  /** Declared state defaults; optional so existing call sites compile unchanged. */
27
37
  stateDefaults?: Record<string, StateValue>;
38
+ /** The paywall's measured box; absent until measured, so window rules don't match. */
39
+ windowSize?: WindowSize | null;
28
40
  /** Interaction pseudo-state flags; absent means "not in that state". */
29
41
  selected?: boolean;
30
42
  hover?: boolean;