@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
@@ -89,6 +89,12 @@
89
89
  componentName: props.name,
90
90
  componentValue: "screen_redirect",
91
91
  };
92
+ case "cookie_settings":
93
+ return {
94
+ componentType: "button",
95
+ componentName: props.name,
96
+ componentValue: "navigate_to_cookie_settings",
97
+ };
92
98
  case "privacy_policy":
93
99
  case "terms":
94
100
  case "url": {
@@ -8,7 +8,7 @@
8
8
  setVariablesContext,
9
9
  } from "../../stores/variables";
10
10
  import type { PackageProps } from "../../types/components/package";
11
- import { untrack } from "svelte";
11
+ import { onDestroy, untrack } from "svelte";
12
12
  import { writable } from "svelte/store";
13
13
  import {
14
14
  getPackageInfoContext,
@@ -20,9 +20,29 @@
20
20
  getSelectedPackageScopeContext,
21
21
  } from "../../stores/relativeDiscount";
22
22
  import { withScopedRelativeDiscount } from "../../utils/relative-discount";
23
+ import { useConditionContext } from "../../stores/condition-context.svelte";
24
+ import {
25
+ getOptionalRenderedPackagesContext,
26
+ getPackageSheetContext,
27
+ } from "../../stores/rendered-packages";
28
+ import { getDefaultSelectionContext } from "../../stores/default-selection";
29
+ import {
30
+ resolveOverrideProperties,
31
+ type ConditionContext,
32
+ } from "../../utils/style-utils";
33
+
34
+ const {
35
+ id,
36
+ visible,
37
+ overrides,
38
+ stack,
39
+ package_id,
40
+ is_selected_by_default,
41
+ name,
42
+ }: PackageProps = $props();
23
43
 
24
- const { stack, package_id, is_selected_by_default, name }: PackageProps =
25
- $props();
44
+ // Without state, as the SDKs evaluate a package's own rules.
45
+ const buildConditionContext = useConditionContext();
26
46
 
27
47
  // Expose the current package_id via a store so descendants stay in sync
28
48
  // when this component is reused across tab switches
@@ -41,16 +61,19 @@
41
61
  infoPerPackage,
42
62
  } = getPaywallContext();
43
63
 
44
- // When a package with is_selected_by_default appears, select it.
45
- // This handles tab switches where a new default package is shown.
46
- // selectedPackageId is deliberately not tracked so that manually
47
- // selecting another package does not re-trigger this effect and
48
- // snap selection back to the default.
64
+ // When a package with is_selected_by_default appears with its paywall, tab
65
+ // or sheet, select it. This handles tab switches where a new default package
66
+ // is shown. selectedPackageId is deliberately not tracked so that manually
67
+ // selecting another package does not re-trigger this effect and snap
68
+ // selection back to the default.
69
+ const isDefaultSelectionOpen = getDefaultSelectionContext();
49
70
  $effect(() => {
50
71
  if (is_selected_by_default) {
51
72
  const newPackageId = package_id;
52
73
  untrack(() => {
53
- $selectedPackageId = newPackageId;
74
+ if (isDefaultSelectionOpen()) {
75
+ $selectedPackageId = newPackageId;
76
+ }
54
77
  });
55
78
  }
56
79
  });
@@ -127,6 +150,55 @@
127
150
  packageInfoStore.set(computedPackageInfo);
128
151
  });
129
152
  setPackageInfoContext(packageInfoStore);
153
+
154
+ // buildConditionContext() reads the contexts above this package, so pass its
155
+ // own selected state, package info and variables.
156
+ const visibleWith = (
157
+ context: Partial<Pick<ConditionContext, "selected" | "selectedPackageId">>,
158
+ ) => {
159
+ const resolved = resolveOverrideProperties(
160
+ {
161
+ ...buildConditionContext(),
162
+ packageInfo: computedPackageInfo,
163
+ ...(computedVariables ? { variables: computedVariables } : {}),
164
+ ...context,
165
+ },
166
+ overrides,
167
+ );
168
+ return (resolved.visible ?? visible) !== false;
169
+ };
170
+ const isVisible = $derived(visibleWith({ selected: isSelected }));
171
+ // Only a package that shows both now, unselected, and once it's selected can
172
+ // take the selection; one hidden once selected would just hide again.
173
+ const isSelectable = $derived(
174
+ visibleWith({ selected: false }) &&
175
+ visibleWith({ selected: true, selectedPackageId: package_id }),
176
+ );
177
+
178
+ const renderedPackages = getOptionalRenderedPackagesContext();
179
+ const sheetId = getPackageSheetContext();
180
+ if (renderedPackages) {
181
+ const registration = Symbol("package");
182
+ $effect(() => {
183
+ // Getters, so the paywall reads the current visibility even before this
184
+ // effect re-runs.
185
+ renderedPackages.set(registration, {
186
+ componentId: id,
187
+ packageId: package_id,
188
+ isSelectedByDefault: is_selected_by_default,
189
+ sheetId,
190
+ get visible() {
191
+ return isVisible;
192
+ },
193
+ get selectable() {
194
+ return isSelectable;
195
+ },
196
+ });
197
+ });
198
+ onDestroy(() => renderedPackages.delete(registration));
199
+ }
130
200
  </script>
131
201
 
132
- <Stack {...stack} onclick={onPackageClick} />
202
+ {#if isVisible}
203
+ <Stack {...stack} onclick={onPackageClick} />
204
+ {/if}
@@ -23,13 +23,29 @@
23
23
  zStackTemplate,
24
24
  } from "../../stories/fixtures";
25
25
  import { waitForAnimations } from "storybook/internal/preview-api";
26
- import { expect, userEvent, within } from "storybook/test";
26
+ import { expect, fn, userEvent, waitFor, within } from "storybook/test";
27
27
  import { viewportDecorator } from "../../stories/viewport-decorator";
28
28
  import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
29
29
  import { FIXED_HEIGHT_STACK_PAYWALL } from "./fixtures/fixed-height-stack-paywall";
30
+ import {
31
+ ALIGNED_SCROLL_PAYWALL,
32
+ CENTERED_ROW_SCROLL_PAYWALL,
33
+ DECORATED_SCROLL_PAYWALL,
34
+ fitAroundFitScrollPaywall,
35
+ HORIZONTAL_SCROLL_PAYWALL,
36
+ ROOT_NO_SCROLL_PAYWALL,
37
+ SCROLLING_FILL_IN_FIT_SHEET_PAYWALL,
38
+ SCROLLING_FILL_SHEET_PAYWALL,
39
+ SCROLLING_FIT_SHEET_PAYWALL,
40
+ SIDE_BY_SIDE_SCROLL_PAYWALL,
41
+ STACKED_FIT_SCROLL_PAYWALL,
42
+ WINDOW_RULE_COLUMNS_PAYWALL,
43
+ } from "./fixtures/stack-scroll-paywall";
30
44
  import { FILL_HEIGHT_WEBVIEW_PAYWALL } from "./fixtures/fill-height-webview-paywall";
45
+ import { WINDOW_RULE_PACKAGES_PAYWALL } from "./fixtures/window-rule-packages-paywall";
31
46
  import { createHeroMediaPaywall } from "./fixtures/hero-media-paywall";
32
47
  import { OVERRIDE_PAYWALL } from "./fixtures/override-paywall";
48
+ import { WINDOW_SIZE_PAYWALL } from "./fixtures/window-size-paywall";
33
49
  import { SHEET_PAYWALL } from "./fixtures/sheet-paywall";
34
50
  import { SHEET_PAYWALL_VIDEO_STACKING } from "./fixtures/sheet-video-stacking-paywall";
35
51
  import { GRADIENT_SHEET_PAYWALL } from "./fixtures/gradient-sheet-paywall";
@@ -56,6 +72,30 @@
56
72
  paywallWithHeaderAndFooter,
57
73
  } from "./fixtures/transparent-footer-paywall";
58
74
 
75
+ // Boxes inside `root` that are scrolling containers with content to scroll.
76
+ const scrollers = (root: Element) =>
77
+ [...root.querySelectorAll<HTMLElement>("*")].filter((el) => {
78
+ const style = getComputedStyle(el);
79
+ const scrolls = (overflow: string) =>
80
+ ["auto", "scroll"].includes(overflow);
81
+ return (
82
+ (scrolls(style.overflowY) && el.scrollHeight > el.clientHeight + 1) ||
83
+ (scrolls(style.overflowX) && el.scrollWidth > el.clientWidth + 1)
84
+ );
85
+ });
86
+
87
+ // The fixed green box below a scrolling column is fully in view.
88
+ const expectOfferInView = (root: Element) => {
89
+ const offer = [...root.querySelectorAll(".stack")].find(
90
+ (el) => getComputedStyle(el).backgroundColor === "rgb(17, 212, 131)",
91
+ )!;
92
+ const paywall = root.querySelector(".paywall-content-scroll")!;
93
+ expect(offer.getBoundingClientRect().height).toBe(120);
94
+ expect(offer.getBoundingClientRect().bottom).toBeLessThanOrEqual(
95
+ paywall.getBoundingClientRect().bottom,
96
+ );
97
+ };
98
+
59
99
  const { Story } = defineMeta({
60
100
  title: "Example/Paywall",
61
101
  component: Paywall,
@@ -105,6 +145,273 @@
105
145
  }}
106
146
  />
107
147
 
148
+ <!--
149
+ Scroll stories render in a frame bounded to the viewport, so scrolling stacks
150
+ have a size to stop at. The shared frame grows with its content.
151
+ -->
152
+ {#snippet boundedFrame(props: ComponentProps<typeof Paywall>)}
153
+ <div class="paywall-story-frame bounded">
154
+ <Paywall {...props} />
155
+ </div>
156
+ {/snippet}
157
+
158
+ <Story
159
+ name="Scrolling Fit stack above a fixed box"
160
+ decorators={[viewportDecorator(375, 640, 0)]}
161
+ play={async ({ canvasElement }) => {
162
+ await waitFor(() => {
163
+ expect(scrollers(canvasElement)).toHaveLength(1);
164
+ expectOfferInView(canvasElement);
165
+ });
166
+ }}
167
+ args={{ paywallData: STACKED_FIT_SCROLL_PAYWALL }}
168
+ template={boundedFrame}
169
+ />
170
+
171
+ <Story
172
+ name="Scrolling Fit stacks side by side"
173
+ decorators={[viewportDecorator(800, 600, 0)]}
174
+ play={async ({ canvasElement }) => {
175
+ await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
176
+ }}
177
+ args={{ paywallData: SIDE_BY_SIDE_SCROLL_PAYWALL }}
178
+ template={boundedFrame}
179
+ />
180
+
181
+ <Story
182
+ name="Scrolling Fit stacks in a centered row"
183
+ decorators={[viewportDecorator(800, 600, 0)]}
184
+ play={async ({ canvasElement }) => {
185
+ const box = (id: string) =>
186
+ [...canvasElement.querySelectorAll(".stack")].find(
187
+ (el) =>
188
+ getComputedStyle(el).backgroundColor === id &&
189
+ el.closest(".rc-stack-scroll"),
190
+ )!;
191
+ await waitFor(() => {
192
+ // Only the tall column scrolls.
193
+ expect(scrollers(canvasElement)).toHaveLength(1);
194
+ const paywall = canvasElement
195
+ .querySelector(".paywall-content-scroll")!
196
+ .getBoundingClientRect();
197
+ const short = box("rgb(17, 212, 131)")
198
+ .closest(".rc-stack-scroll")!
199
+ .parentElement!.getBoundingClientRect();
200
+ // The short column stays centered in the row.
201
+ const paywallMiddle = paywall.top + paywall.height / 2;
202
+ expect(
203
+ Math.abs(short.top + short.height / 2 - paywallMiddle),
204
+ ).toBeLessThan(1);
205
+ });
206
+ }}
207
+ args={{ paywallData: CENTERED_ROW_SCROLL_PAYWALL }}
208
+ template={boundedFrame}
209
+ />
210
+
211
+ <Story
212
+ name="Horizontal scrolling stack"
213
+ decorators={[viewportDecorator(375, 640, 0)]}
214
+ play={async ({ canvasElement }) => {
215
+ await waitFor(() => {
216
+ const [row, ...others] = scrollers(canvasElement);
217
+ expect(others).toHaveLength(0);
218
+ expect(row?.classList.contains("rc-stack-scroll")).toBe(true);
219
+ });
220
+ }}
221
+ args={{ paywallData: HORIZONTAL_SCROLL_PAYWALL }}
222
+ template={boundedFrame}
223
+ />
224
+
225
+ <Story
226
+ name="Root scrolling off"
227
+ decorators={[viewportDecorator(375, 640, 0)]}
228
+ play={async ({ canvasElement }) => {
229
+ await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(0));
230
+ }}
231
+ args={{ paywallData: ROOT_NO_SCROLL_PAYWALL }}
232
+ template={boundedFrame}
233
+ />
234
+
235
+ <Story
236
+ name="Window size rule, narrow"
237
+ decorators={[viewportDecorator(375, 640, 0)]}
238
+ play={async ({ canvasElement }) => {
239
+ await waitFor(() =>
240
+ expect(
241
+ scrollers(canvasElement).map((el) =>
242
+ el.classList.contains("paywall-content-scroll"),
243
+ ),
244
+ ).toEqual([true]),
245
+ );
246
+ }}
247
+ args={{ paywallData: WINDOW_RULE_COLUMNS_PAYWALL }}
248
+ template={boundedFrame}
249
+ />
250
+
251
+ <Story
252
+ name="Window size rule, wide"
253
+ decorators={[viewportDecorator(800, 600, 0)]}
254
+ play={async ({ canvasElement }) => {
255
+ await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
256
+ }}
257
+ args={{ paywallData: WINDOW_RULE_COLUMNS_PAYWALL }}
258
+ template={boundedFrame}
259
+ />
260
+
261
+ <Story
262
+ name="Scrolling stacks aligned to the center and end"
263
+ decorators={[viewportDecorator(375, 640, 0)]}
264
+ play={async ({ canvasElement }) => {
265
+ await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
266
+ for (const viewport of scrollers(canvasElement)) {
267
+ const rows = viewport.firstElementChild!;
268
+ viewport.scrollTop = 0;
269
+ expect(rows.firstElementChild!.getBoundingClientRect().top).toBe(
270
+ viewport.getBoundingClientRect().top,
271
+ );
272
+ viewport.scrollTop = viewport.scrollHeight;
273
+ expect(rows.lastElementChild!.getBoundingClientRect().bottom).toBe(
274
+ viewport.getBoundingClientRect().bottom,
275
+ );
276
+ }
277
+ }}
278
+ args={{ paywallData: ALIGNED_SCROLL_PAYWALL }}
279
+ template={boundedFrame}
280
+ />
281
+
282
+ <Story
283
+ name="Scrolling stacks keep their border, badge and video"
284
+ decorators={[viewportDecorator(375, 640, 0)]}
285
+ play={async ({ canvasElement }) => {
286
+ await waitFor(() => expect(scrollers(canvasElement)).toHaveLength(2));
287
+ const [bordered, video] = scrollers(canvasElement);
288
+ const stack = bordered!.parentElement!;
289
+ expect(getComputedStyle(stack).borderTopWidth).toBe("10px");
290
+ expect(getComputedStyle(stack).overflowY).toBe("visible");
291
+ const badge = [...stack.children].find((el) => el !== bordered)!;
292
+ const badgeTop = badge.getBoundingClientRect().top;
293
+ bordered!.scrollTop = 150;
294
+ expect(badge.getBoundingClientRect().top).toBe(badgeTop);
295
+ video!.scrollTop = 150;
296
+ expect(video!.scrollTop).toBeGreaterThan(0);
297
+ }}
298
+ args={{ paywallData: DECORATED_SCROLL_PAYWALL }}
299
+ template={boundedFrame}
300
+ />
301
+
302
+ <Story
303
+ name="Fit stack around a scrolling Fit stack"
304
+ decorators={[viewportDecorator(375, 640, 0)]}
305
+ play={async ({ canvasElement }) => {
306
+ await waitFor(() => {
307
+ expect(scrollers(canvasElement)).toHaveLength(1);
308
+ expectOfferInView(canvasElement);
309
+ });
310
+ }}
311
+ args={{ paywallData: fitAroundFitScrollPaywall(false) }}
312
+ template={boundedFrame}
313
+ />
314
+
315
+ <Story
316
+ name="Fit stack around a scrolling package"
317
+ decorators={[viewportDecorator(375, 640, 0)]}
318
+ play={async ({ canvasElement }) => {
319
+ await waitFor(() => {
320
+ expect(scrollers(canvasElement)).toHaveLength(1);
321
+ expectOfferInView(canvasElement);
322
+ });
323
+ }}
324
+ args={{ paywallData: fitAroundFitScrollPaywall(true) }}
325
+ template={boundedFrame}
326
+ />
327
+
328
+ <Story
329
+ name="Window size rule hides a package, narrow"
330
+ decorators={[viewportDecorator(375, 640, 0)]}
331
+ play={async ({ args, canvasElement }) => {
332
+ const canvas = within(canvasElement);
333
+ await canvas.findByText(/^Annual/);
334
+ await userEvent.click(canvas.getByText("Purchase"));
335
+ await waitFor(() =>
336
+ expect(args.onPurchaseClicked).toHaveBeenLastCalledWith("$rc_annual", ""),
337
+ );
338
+ }}
339
+ args={{ paywallData: WINDOW_RULE_PACKAGES_PAYWALL, onPurchaseClicked: fn() }}
340
+ template={boundedFrame}
341
+ />
342
+
343
+ <Story
344
+ name="Window size rule hides a package, wide"
345
+ decorators={[viewportDecorator(800, 600, 0)]}
346
+ play={async ({ args, canvasElement }) => {
347
+ const canvas = within(canvasElement);
348
+ await canvas.findByText(/^Monthly/);
349
+ expect(canvas.queryByText(/^Annual/)).toBeNull();
350
+ await userEvent.click(canvas.getByText("Purchase"));
351
+ await waitFor(() =>
352
+ expect(args.onPurchaseClicked).toHaveBeenLastCalledWith(
353
+ "$rc_monthly",
354
+ "",
355
+ ),
356
+ );
357
+ }}
358
+ args={{ paywallData: WINDOW_RULE_PACKAGES_PAYWALL, onPurchaseClicked: fn() }}
359
+ template={boundedFrame}
360
+ />
361
+
362
+ <Story
363
+ name="Scrolling Fit sheet"
364
+ decorators={[viewportDecorator(375, 640, 0)]}
365
+ play={async ({ canvasElement }) => {
366
+ await userEvent.click(await within(canvasElement).findByText("Open sheet"));
367
+ await waitFor(() => {
368
+ const sheet = canvasElement.querySelector(".sheet")!;
369
+ const [contents, ...others] = scrollers(sheet.parentElement!);
370
+ expect(others).toHaveLength(0);
371
+ expect(contents).not.toBe(sheet);
372
+ expect(sheet.contains(contents!)).toBe(true);
373
+ expect(contents!.clientHeight).toBe(sheet.parentElement!.clientHeight);
374
+ });
375
+ }}
376
+ args={{ paywallData: SCROLLING_FIT_SHEET_PAYWALL }}
377
+ template={boundedFrame}
378
+ />
379
+
380
+ <Story
381
+ name="Scrolling Fill contents in a Fit sheet"
382
+ decorators={[viewportDecorator(375, 640, 0)]}
383
+ play={async ({ canvasElement }) => {
384
+ await userEvent.click(await within(canvasElement).findByText("Open sheet"));
385
+ await waitFor(() => {
386
+ const sheet = canvasElement.querySelector(".sheet")!;
387
+ // The sheet takes the screen's height and its contents scroll inside.
388
+ expect(sheet.clientHeight).toBe(sheet.parentElement!.clientHeight);
389
+ expect(scrollers(sheet)).toHaveLength(1);
390
+ });
391
+ }}
392
+ args={{ paywallData: SCROLLING_FILL_IN_FIT_SHEET_PAYWALL }}
393
+ template={boundedFrame}
394
+ />
395
+
396
+ <Story
397
+ name="Scrolling Fill sheet with margins"
398
+ decorators={[viewportDecorator(375, 640, 0)]}
399
+ play={async ({ canvasElement }) => {
400
+ await userEvent.click(await within(canvasElement).findByText("Open sheet"));
401
+ await waitFor(() => {
402
+ const sheet = canvasElement.querySelector(".sheet")!;
403
+ expect(scrollers(sheet)).toHaveLength(1);
404
+ // The contents stop 40px (their bottom margin) above the sheet's bottom.
405
+ const contents = sheet.querySelector(".stack")!;
406
+ expect(contents.getBoundingClientRect().bottom + 40).toBeLessThanOrEqual(
407
+ sheet.getBoundingClientRect().bottom + 0.5,
408
+ );
409
+ });
410
+ }}
411
+ args={{ paywallData: SCROLLING_FILL_SHEET_PAYWALL }}
412
+ template={boundedFrame}
413
+ />
414
+
108
415
  <!--
109
416
  Regression for FUN-2262: fill-height Stack + fill-height WebView must fill
110
417
  the space between the fixed header/footer bars. Collapse shows as the bars
@@ -125,6 +432,29 @@
125
432
  }}
126
433
  />
127
434
 
435
+ <!-- Resize the canvas across 600px wide, or past square, to see the rules switch. -->
436
+ <Story
437
+ name="Window size rules, narrow"
438
+ decorators={[viewportDecorator(375, 640, 0)]}
439
+ play={async ({ canvasElement }) => {
440
+ const canvas = within(canvasElement);
441
+ await expect(await canvas.findByText("Width under 600px")).toBeVisible();
442
+ await expect(canvas.getByText("Portrait or square")).toBeVisible();
443
+ }}
444
+ args={{ paywallData: WINDOW_SIZE_PAYWALL }}
445
+ />
446
+
447
+ <Story
448
+ name="Window size rules, wide"
449
+ decorators={[viewportDecorator(800, 600, 0)]}
450
+ play={async ({ canvasElement }) => {
451
+ const canvas = within(canvasElement);
452
+ await expect(await canvas.findByText("Width 600px or more")).toBeVisible();
453
+ await expect(canvas.getByText("Landscape")).toBeVisible();
454
+ }}
455
+ args={{ paywallData: WINDOW_SIZE_PAYWALL }}
456
+ />
457
+
128
458
  <Story
129
459
  name="State-driven - tab state"
130
460
  decorators={[viewportDecorator(375, 640, 0)]}
@@ -817,6 +1147,10 @@
817
1147
  />
818
1148
 
819
1149
  <style>
1150
+ .paywall-story-frame.bounded {
1151
+ min-height: 0;
1152
+ }
1153
+
820
1154
  .paywall-story-frame {
821
1155
  flex: 1 1 auto;
822
1156
  display: flex;
@@ -50,10 +50,22 @@
50
50
  } from "../../stores/relativeDiscount";
51
51
  import {
52
52
  collectPackageIdsInDisplayOrder,
53
+ collectPackagePlacements,
53
54
  findSelectedPackageId,
54
55
  } from "../../utils/style-utils";
56
+ import { reconciledSelection } from "../../utils/package-selection";
55
57
  import { onMount } from "svelte";
56
- import { derived, readable, writable } from "svelte/store";
58
+ import { SvelteMap } from "svelte/reactivity";
59
+ import { derived, readable, toStore, writable } from "svelte/store";
60
+ import { setWindowSizeContext } from "../../stores/window-size";
61
+ import {
62
+ setRenderedPackagesContext,
63
+ type RenderedPackages,
64
+ } from "../../stores/rendered-packages";
65
+ import {
66
+ openDefaultSelection,
67
+ setDefaultSelectionContext,
68
+ } from "../../stores/default-selection";
57
69
  import Stack from "../stack/Stack.svelte";
58
70
  import {
59
71
  firstContentIsHeroMedia,
@@ -106,6 +118,7 @@
106
118
  /** Called when a `close_workflow` button is pressed to dismiss the paywall/workflow. */
107
119
  onClose?: () => void;
108
120
  onVisitCustomerCenterClicked?: () => void;
121
+ onCookieSettingsClicked?: () => void;
109
122
  onRestorePurchasesClicked?: () => void;
110
123
  onNavigateToUrlClicked?: (url: string) => void;
111
124
  /**
@@ -180,6 +193,7 @@
180
193
  onBackClicked,
181
194
  onClose,
182
195
  onVisitCustomerCenterClicked,
196
+ onCookieSettingsClicked,
183
197
  onRestorePurchasesClicked,
184
198
  onNavigateToUrlClicked,
185
199
  onCompleteWorkflowNavigate,
@@ -322,6 +336,9 @@
322
336
  case "screen_redirect":
323
337
  onActionTriggered?.("screen_redirect");
324
338
  return;
339
+ case "cookie_settings":
340
+ onCookieSettingsClicked?.();
341
+ return;
325
342
  case "privacy_policy":
326
343
  case "terms":
327
344
  case "url":
@@ -464,6 +481,76 @@
464
481
 
465
482
  setPackageInfoContext(packageInfo);
466
483
 
484
+ let paywallWidth = $state<number>();
485
+ let paywallHeight = $state<number>();
486
+ // Window size rules evaluate against the paywall's own box, as the native
487
+ // SDKs measure the paywall's bounds rather than the screen.
488
+ setWindowSizeContext(
489
+ toStore(() =>
490
+ paywallWidth == null || paywallHeight == null
491
+ ? null
492
+ : { width: paywallWidth, height: paywallHeight },
493
+ ),
494
+ );
495
+
496
+ // Without ResizeObserver the size stays unknown, so window size rules don't
497
+ // match instead of the paywall failing to render.
498
+ function measurePaywall(node: HTMLElement) {
499
+ if (typeof ResizeObserver === "undefined") return;
500
+ // A hidden paywall measures 0 by 0, which says nothing about its size.
501
+ const isHidden = (width: number, height: number) =>
502
+ width === 0 && height === 0;
503
+ // Measure once before the first paint, so a wide paywall doesn't flash its
504
+ // narrow layout. The offset size is in whole px; ResizeObserver then
505
+ // reports the fractional box.
506
+ if (!isHidden(node.offsetWidth, node.offsetHeight)) {
507
+ paywallWidth = node.offsetWidth;
508
+ paywallHeight = node.offsetHeight;
509
+ }
510
+ let frame = 0;
511
+ const observer = new ResizeObserver(([entry]) => {
512
+ const box = entry?.borderBoxSize?.[0];
513
+ const width = box?.inlineSize ?? entry?.contentRect.width ?? 0;
514
+ const height = box?.blockSize ?? entry?.contentRect.height ?? 0;
515
+ if (isHidden(width, height)) return;
516
+ // Next frame, so a rule that resizes the paywall doesn't set off
517
+ // ResizeObserver's loop error.
518
+ cancelAnimationFrame(frame);
519
+ frame = requestAnimationFrame(() => {
520
+ paywallWidth = width;
521
+ paywallHeight = height;
522
+ });
523
+ });
524
+ observer.observe(node);
525
+ return {
526
+ destroy: () => {
527
+ observer.disconnect();
528
+ cancelAnimationFrame(frame);
529
+ },
530
+ };
531
+ }
532
+
533
+ setDefaultSelectionContext(openDefaultSelection());
534
+ const renderedPackages: RenderedPackages = new SvelteMap();
535
+ setRenderedPackagesContext(renderedPackages);
536
+ const packagePlacements = collectPackagePlacements(base);
537
+
538
+ // Moves the selection off a package a rule hides, so it can't stay selected
539
+ // and purchasable. Waits for the first measurement when there will be one,
540
+ // since window size rules can't be evaluated before it.
541
+ const willMeasure = typeof ResizeObserver !== "undefined";
542
+ $effect(() => {
543
+ if (willMeasure && (paywallWidth == null || paywallHeight == null)) return;
544
+ const replacement = reconciledSelection(
545
+ $selectedPackageId,
546
+ renderedPackages.values(),
547
+ packagePlacements,
548
+ );
549
+ if (replacement !== undefined) {
550
+ selectedPackageId.set(replacement);
551
+ }
552
+ });
553
+
467
554
  onMount(() => {
468
555
  registerFonts(uiConfig);
469
556
  });
@@ -491,7 +578,7 @@
491
578
  <svelte:boundary onerror={onError}>
492
579
  <!-- Outside the root: filter/transform on `.blur` would clip the fixed backdrop to the paywall box. -->
493
580
  <ViewportBackdrop model={viewportBackdropModel} blur={!!sheet} />
494
- <div class={paywallClass} style={paywallStyle}>
581
+ <div class={paywallClass} style={paywallStyle} use:measurePaywall>
495
582
  {#if header}
496
583
  <div
497
584
  class="header-wrapper"
@@ -510,6 +597,7 @@
510
597
  bottom: (stack.padding?.bottom ?? 0) + footerHeight,
511
598
  }
512
599
  : stack.padding}
600
+ rootStack
513
601
  class="paywall-content-scroll"
514
602
  style={mapPaywallContentStackStyle(
515
603
  stack.size.height,
@@ -547,7 +635,10 @@
547
635
  </div>
548
636
 
549
637
  {#if sheet}
550
- <Sheet {...sheet.props} />
638
+ <!-- Keyed, so opening another sheet from a sheet starts it fresh. -->
639
+ {#key sheet.props.id}
640
+ <Sheet {...sheet.props} />
641
+ {/key}
551
642
  {/if}
552
643
  </svelte:boundary>
553
644
 
@@ -600,10 +691,10 @@
600
691
  flex-direction: column;
601
692
  min-height: 0;
602
693
 
694
+ /* The root stack sets its own overflow: rootScrollStyle (stack-scroll.ts). */
603
695
  & > :global(.paywall-content-scroll) {
604
696
  flex: 1 1 auto;
605
697
  min-height: 0;
606
- overflow-y: auto;
607
698
  }
608
699
  }
609
700
 
@@ -33,6 +33,7 @@ interface Props {
33
33
  /** Called when a `close_workflow` button is pressed to dismiss the paywall/workflow. */
34
34
  onClose?: () => void;
35
35
  onVisitCustomerCenterClicked?: () => void;
36
+ onCookieSettingsClicked?: () => void;
36
37
  onRestorePurchasesClicked?: () => void;
37
38
  onNavigateToUrlClicked?: (url: string) => void;
38
39
  /**