@revenuecat/purchases-ui-js 4.12.1 → 4.12.3

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.
@@ -15,6 +15,11 @@
15
15
  setPackageInfoContext,
16
16
  } from "../../stores/packageInfo";
17
17
  import type { PackageInfo, VariableDictionary } from "../../types/variables";
18
+ import {
19
+ getRelativeDiscountScopeContext,
20
+ getSelectedPackageScopeContext,
21
+ } from "../../stores/relativeDiscount";
22
+ import { withScopedRelativeDiscount } from "../../utils/relative-discount";
18
23
 
19
24
  const { stack, package_id, is_selected_by_default, name }: PackageProps =
20
25
  $props();
@@ -78,16 +83,29 @@
78
83
 
79
84
  // Use $derived to make variables reactive to package_id changes
80
85
  const fallbackVariables = getVariablesContext();
86
+ const relativeDiscountScope = getRelativeDiscountScopeContext();
81
87
  const computedVariables = $derived.by(() => {
82
88
  const packageVariables = $variablesPerPackage?.[package_id];
83
89
  if (packageVariables === undefined) {
84
90
  return $fallbackVariables;
85
91
  }
86
92
 
87
- return {
88
- ...$baseVariables,
89
- ...packageVariables,
93
+ return withScopedRelativeDiscount(
94
+ { ...$baseVariables, ...packageVariables },
95
+ $infoPerPackage?.[package_id],
96
+ $relativeDiscountScope,
97
+ );
98
+ });
99
+ const selectedPackageScope = getSelectedPackageScopeContext();
100
+ $effect(() => {
101
+ if (!isSelected || !selectedPackageScope) return;
102
+ const entry = {
103
+ packageId: package_id,
104
+ mostExpensivePricePerMonth: $relativeDiscountScope,
90
105
  };
106
+ selectedPackageScope.set(entry);
107
+ return () =>
108
+ selectedPackageScope.update((e) => (e === entry ? undefined : e));
91
109
  });
92
110
  const variablesStore = writable<VariableDictionary | undefined>(
93
111
  computedVariables,
@@ -23,6 +23,7 @@
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
27
  import { viewportDecorator } from "../../stories/viewport-decorator";
27
28
  import { BACKGROUND_PAYWALL } from "./fixtures/background-paywall";
28
29
  import { FIXED_HEIGHT_STACK_PAYWALL } from "./fixtures/fixed-height-stack-paywall";
@@ -36,6 +37,12 @@
36
37
  import { VARIABLES } from "./fixtures/variables";
37
38
  import { CUSTOM_VARIABLES_PAYWALL } from "./fixtures/custom-variables-paywall";
38
39
  import { TAB_STATE_PAYWALL } from "../tabs/fixtures/tab-state-paywall";
40
+ import {
41
+ RELATIVE_DISCOUNT_NESTED_TABS_PAYWALL,
42
+ RELATIVE_DISCOUNT_TABS_INFO_PER_PACKAGE,
43
+ RELATIVE_DISCOUNT_TABS_PAYWALL,
44
+ RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE,
45
+ } from "../tabs/fixtures/relative-discount-tabs-paywall";
39
46
  import { COUNTDOWN_PAYWALL } from "../countdown/fixtures/countdown-paywall";
40
47
  import { mockDateDecorator } from "storybook-mock-date-decorator";
41
48
  import {
@@ -714,6 +721,70 @@
714
721
  }}
715
722
  />
716
723
 
724
+ <Story
725
+ name="Relative Discount Scoped To Tab"
726
+ play={async ({ canvasElement }) => {
727
+ const canvas = within(canvasElement);
728
+ await expect(await canvas.findByText("Save 17%")).toBeInTheDocument();
729
+ await expect(
730
+ canvas.getByText("Subscribe and save 17%"),
731
+ ).toBeInTheDocument();
732
+ }}
733
+ args={{
734
+ paywallData: RELATIVE_DISCOUNT_TABS_PAYWALL,
735
+ variablesPerPackage: RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE,
736
+ infoPerPackage: RELATIVE_DISCOUNT_TABS_INFO_PER_PACKAGE,
737
+ }}
738
+ />
739
+
740
+ <Story
741
+ name="Relative Discount Scoped To Tab - Switched Tab"
742
+ play={async ({ canvasElement }) => {
743
+ const canvas = within(canvasElement);
744
+ await userEvent.click(await canvas.findByText("Radio Plus"));
745
+ await expect(await canvas.findByText("Save 25%")).toBeInTheDocument();
746
+ await expect(
747
+ canvas.getByText("Subscribe and save 25%"),
748
+ ).toBeInTheDocument();
749
+ }}
750
+ args={{
751
+ paywallData: RELATIVE_DISCOUNT_TABS_PAYWALL,
752
+ variablesPerPackage: RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE,
753
+ infoPerPackage: RELATIVE_DISCOUNT_TABS_INFO_PER_PACKAGE,
754
+ }}
755
+ />
756
+
757
+ <Story
758
+ name="Relative Discount Scoped To Tab - Nested Tabs"
759
+ play={async ({ canvasElement }) => {
760
+ const canvas = within(canvasElement);
761
+ await userEvent.click(await canvas.findByText("Radio Plus yearly €89.99"));
762
+ await expect(
763
+ await canvas.findByText("Subscribe and save 25%"),
764
+ ).toBeInTheDocument();
765
+ }}
766
+ args={{
767
+ paywallData: RELATIVE_DISCOUNT_NESTED_TABS_PAYWALL,
768
+ variablesPerPackage: RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE,
769
+ infoPerPackage: RELATIVE_DISCOUNT_TABS_INFO_PER_PACKAGE,
770
+ }}
771
+ />
772
+
773
+ <Story
774
+ name="Relative Discount Scoped To Tab - No Host Prices"
775
+ play={async ({ canvasElement }) => {
776
+ const canvas = within(canvasElement);
777
+ await expect(await canvas.findByText("Save 58%")).toBeInTheDocument();
778
+ await expect(
779
+ canvas.getByText("Subscribe and save 58%"),
780
+ ).toBeInTheDocument();
781
+ }}
782
+ args={{
783
+ paywallData: RELATIVE_DISCOUNT_TABS_PAYWALL,
784
+ variablesPerPackage: RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE,
785
+ }}
786
+ />
787
+
717
788
  <Story
718
789
  name="Duelinuge Paywall Trial Override"
719
790
  args={{
@@ -43,6 +43,11 @@
43
43
  import { STICKY_OVERLAY_Z_INDEX } from "../../utils/constants";
44
44
  import { applyDocumentBackground } from "../../utils/document-background";
45
45
  import { registerFonts } from "../../utils/font-utils";
46
+ import { withScopedRelativeDiscount } from "../../utils/relative-discount";
47
+ import {
48
+ setSelectedPackageScopeContext,
49
+ type SelectedPackageScopeStore,
50
+ } from "../../stores/relativeDiscount";
46
51
  import {
47
52
  collectPackageIdsInDisplayOrder,
48
53
  findSelectedPackageId,
@@ -424,14 +429,26 @@
424
429
  setInputValidationContext(inputValidationContext);
425
430
  setInitialInputSelectionsContext(initialInputSelections);
426
431
 
427
- const variables: VariablesStore = derived(selectedPackageId, (packageId) => {
428
- const packageVars = variablesPerPackage[packageId || ""];
429
- return {
430
- ...mergedCustomVars,
431
- ...globalVariables,
432
- ...packageVars,
433
- };
434
- });
432
+ const selectedPackageScope: SelectedPackageScopeStore = writable(undefined);
433
+ setSelectedPackageScopeContext(selectedPackageScope);
434
+
435
+ const variables: VariablesStore = derived(
436
+ [selectedPackageId, selectedPackageScope],
437
+ ([packageId, scope]) => {
438
+ const packageVars = variablesPerPackage[packageId || ""];
439
+ return withScopedRelativeDiscount(
440
+ {
441
+ ...mergedCustomVars,
442
+ ...globalVariables,
443
+ ...packageVars,
444
+ },
445
+ infoPerPackage[packageId || ""],
446
+ scope?.packageId === packageId
447
+ ? scope?.mostExpensivePricePerMonth
448
+ : undefined,
449
+ );
450
+ },
451
+ );
435
452
 
436
453
  setVariablesContext(variables);
437
454
 
@@ -57,7 +57,10 @@
57
57
  ...(margin
58
58
  ? {
59
59
  ...margin,
60
- bottom: Math.max(margin.bottom, minDistanceBetweenPurchaseButtons),
60
+ bottom: Math.max(
61
+ margin.bottom ?? 0,
62
+ minDistanceBetweenPurchaseButtons,
63
+ ),
61
64
  }
62
65
  : {
63
66
  ...DEFAULT_SPACING,
@@ -5,6 +5,7 @@
5
5
  import { localizations } from "../../stories/fixtures";
6
6
  import { localizationDecorator } from "../../stories/localization-decorator";
7
7
  import type { StackProps } from "../../types/components/stack";
8
+ import type { Spacing } from "../../types";
8
9
  import type { Component } from "../../types/component";
9
10
  import { STORY_BACKGROUND_VIDEO_FILL } from "../../stories/video-background-story-fixture";
10
11
  import { DEFAULT_TEXT_COLOR } from "../../utils/constants";
@@ -107,6 +108,62 @@
107
108
  },
108
109
  ];
109
110
 
111
+ function marginBox(
112
+ id: string,
113
+ labelLid: string,
114
+ margin: Spacing,
115
+ ): StackProps {
116
+ return {
117
+ type: "stack",
118
+ id,
119
+ name: id,
120
+ components: [
121
+ {
122
+ type: "text",
123
+ id: `${id}-label`,
124
+ name: "Label",
125
+ text_lid: labelLid,
126
+ color: { light: { type: "hex", value: "#ffffff" } },
127
+ font_name: null,
128
+ font_size: "body_m",
129
+ font_weight: "bold",
130
+ horizontal_alignment: "leading",
131
+ size: { width: { type: "fit" }, height: { type: "fit" } },
132
+ },
133
+ {
134
+ type: "text",
135
+ id: `${id}-expected`,
136
+ name: "Expected",
137
+ text_lid: "margin_expected",
138
+ color: { light: { type: "hex", value: "#ffffff" } },
139
+ font_name: null,
140
+ font_size: "body_s",
141
+ font_weight: "regular",
142
+ horizontal_alignment: "leading",
143
+ size: { width: { type: "fit" }, height: { type: "fit" } },
144
+ },
145
+ ] as unknown as TextNodeProps[],
146
+ size: { width: { type: "fill" }, height: { type: "fit" } },
147
+ dimension: {
148
+ type: "vertical",
149
+ alignment: "leading",
150
+ distribution: "start",
151
+ },
152
+ spacing: 4,
153
+ margin,
154
+ padding: { top: 12, trailing: 12, bottom: 12, leading: 12 },
155
+ background_color: {
156
+ light: { type: "hex", value: "#47aab1" },
157
+ dark: { type: "hex", value: "#47aab1" },
158
+ },
159
+ background: null,
160
+ border: null,
161
+ shape: null,
162
+ shadow: null,
163
+ badge: null,
164
+ };
165
+ }
166
+
110
167
  const { Story } = defineMeta({
111
168
  title: "Components/Stack",
112
169
  component: Stack,
@@ -483,6 +540,44 @@
483
540
  }}
484
541
  />
485
542
 
543
+ <Story
544
+ name="Nested stack — partial margin"
545
+ parameters={{
546
+ docs: {
547
+ description: {
548
+ story:
549
+ "The first inner stack only sets `margin.top`; the second sets all four sides explicitly. Missing sides render as 0, matching iOS and Android, so both boxes should look identical: 48px below whatever is above them and flush with both horizontal edges.",
550
+ },
551
+ },
552
+ }}
553
+ args={{
554
+ size: {
555
+ width: { type: "fixed", value: 300 },
556
+ height: { type: "fit" },
557
+ },
558
+ dimension: {
559
+ type: "vertical",
560
+ alignment: "leading",
561
+ distribution: "start",
562
+ },
563
+ spacing: 0,
564
+ padding: { top: 0, trailing: 0, bottom: 0, leading: 0 },
565
+ background_color: {
566
+ light: { type: "hex", value: "#F0F0F0" },
567
+ dark: { type: "hex", value: "#F0F0F0" },
568
+ },
569
+ components: [
570
+ marginBox("partial", "partial_margin", { top: 48 }),
571
+ marginBox("explicit", "explicit_margin", {
572
+ top: 48,
573
+ trailing: 0,
574
+ bottom: 0,
575
+ leading: 0,
576
+ }),
577
+ ],
578
+ }}
579
+ />
580
+
486
581
  <Story
487
582
  name="Complex - pill"
488
583
  args={{
@@ -25,6 +25,11 @@
25
25
  import { derived, get, readable, writable } from "svelte/store";
26
26
  import Stack from "../stack/Stack.svelte";
27
27
  import { setTabsContext, type SelectTab } from "./tabs-context";
28
+ import {
29
+ getRelativeDiscountScopeContext,
30
+ setRelativeDiscountScopeContext,
31
+ } from "../../stores/relativeDiscount";
32
+ import { mostExpensivePricePerMonth } from "../../utils/relative-discount";
28
33
 
29
34
  const props: TabsProps = $props();
30
35
  const { name, control, tabs, default_tab_id, state_updates } = props;
@@ -72,7 +77,7 @@
72
77
  }),
73
78
  );
74
79
 
75
- const { emitComponentInteraction } = getPaywallContext();
80
+ const { emitComponentInteraction, infoPerPackage } = getPaywallContext();
76
81
  const defaultTabIndex = Math.max(
77
82
  0,
78
83
  tabs.findIndex((tab) => tab.id === default_tab_id),
@@ -137,6 +142,16 @@
137
142
  selectTab,
138
143
  isActive: derived(selectedTabIndex, (value) => value === 1),
139
144
  });
145
+
146
+ const parentScope = getRelativeDiscountScopeContext();
147
+ setRelativeDiscountScopeContext(
148
+ derived(
149
+ [tab, infoPerPackage, parentScope],
150
+ ([selectedTab, info, parent]) =>
151
+ (selectedTab && mostExpensivePricePerMonth(selectedTab, info)) ??
152
+ parent,
153
+ ),
154
+ );
140
155
  </script>
141
156
 
142
157
  {#if isVisible}
@@ -0,0 +1,11 @@
1
+ import type { TabsProps } from "../../../types/components/tabs";
2
+ import type { PaywallData } from "../../../types/paywall";
3
+ import type { PackageInfo, VariableDictionary } from "../../../types/variables";
4
+ export declare const RELATIVE_DISCOUNT_TABS_TABS: TabsProps;
5
+ /** Two tabs whose most expensive monthly prices differ, like a multi-brand bundle paywall. */
6
+ export declare const RELATIVE_DISCOUNT_TABS_PAYWALL: PaywallData;
7
+ /** A package directly in an outer tab, next to nested tabs whose packages count toward it. */
8
+ export declare const RELATIVE_DISCOUNT_NESTED_TABS_PAYWALL: PaywallData;
9
+ export declare const RELATIVE_DISCOUNT_TABS_INFO_PER_PACKAGE: Record<string, PackageInfo>;
10
+ /** What the host computes against the whole offering (€9.99/month). */
11
+ export declare const RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE: Record<string, VariableDictionary>;
@@ -0,0 +1,155 @@
1
+ import { createPackageComponent, createStack, createTextComponent, } from "../../paywall/fixtures/helpers";
2
+ const zeroSpacing = { bottom: 0, leading: 0, top: 0, trailing: 0 };
3
+ function tabButton(tabId, tabIndex) {
4
+ return {
5
+ type: "tab_control_button",
6
+ id: `${tabId}-button`,
7
+ name: `${tabId} button`,
8
+ tab_id: tabId,
9
+ tab_index: tabIndex,
10
+ stack: createStack({
11
+ id: `${tabId}-button-stack`,
12
+ name: `${tabId} button stack`,
13
+ components: [
14
+ createTextComponent({ id: `${tabId}-button-text`, textLid: tabId }),
15
+ ],
16
+ }),
17
+ };
18
+ }
19
+ function packageWithBadge(packageId, isSelectedByDefault) {
20
+ return createPackageComponent({
21
+ id: `${packageId}-package`,
22
+ name: packageId,
23
+ package_id: packageId,
24
+ is_selected_by_default: isSelectedByDefault,
25
+ components: [
26
+ createTextComponent({ id: `${packageId}-name`, textLid: packageId }),
27
+ createTextComponent({ id: `${packageId}-save`, textLid: "save" }),
28
+ ],
29
+ });
30
+ }
31
+ function tab(id, components) {
32
+ return {
33
+ type: "tab",
34
+ id,
35
+ name: id,
36
+ stack: createStack({
37
+ id: `${id}-stack`,
38
+ name: `${id} stack`,
39
+ components: [
40
+ {
41
+ type: "tab_control",
42
+ id: `${id}-control`,
43
+ name: `${id} control`,
44
+ },
45
+ ...components,
46
+ ],
47
+ }),
48
+ };
49
+ }
50
+ function tabsOf(id, tabList) {
51
+ return {
52
+ type: "tabs",
53
+ id,
54
+ name: id,
55
+ control: {
56
+ type: "buttons",
57
+ stack: createStack({
58
+ id: `${id}-buttons`,
59
+ name: `${id} buttons`,
60
+ components: tabList.map((t, index) => tabButton(t.id, index)),
61
+ }),
62
+ },
63
+ default_tab_id: tabList[0].id,
64
+ tabs: tabList,
65
+ size: { width: { type: "fill" }, height: { type: "fit" } },
66
+ padding: zeroSpacing,
67
+ margin: zeroSpacing,
68
+ background: null,
69
+ shape: null,
70
+ border: null,
71
+ shadow: null,
72
+ };
73
+ }
74
+ const purchaseButton = {
75
+ type: "purchase_button",
76
+ id: "purchase-button",
77
+ name: "Purchase button",
78
+ stack: createStack({
79
+ id: "purchase-button-stack",
80
+ name: "Purchase button stack",
81
+ components: [
82
+ createTextComponent({ id: "purchase-button-text", textLid: "purchase" }),
83
+ ],
84
+ }),
85
+ };
86
+ export const RELATIVE_DISCOUNT_TABS_TABS = tabsOf("plan-tabs", [
87
+ tab("radio_x", [
88
+ packageWithBadge("x_monthly", false),
89
+ packageWithBadge("x_annual", true),
90
+ ]),
91
+ tab("radio_plus", [
92
+ packageWithBadge("plus_monthly", false),
93
+ packageWithBadge("plus_annual", true),
94
+ ]),
95
+ ]);
96
+ const localizations = {
97
+ radio_x: "Radio X Plus",
98
+ radio_plus: "Radio Plus",
99
+ bundle: "Bundle",
100
+ x_monthly: "Radio X monthly €4.99",
101
+ x_annual: "Radio X yearly €49.99",
102
+ plus_monthly: "Radio Plus monthly €9.99",
103
+ plus_annual: "Radio Plus yearly €89.99",
104
+ save: "Save {{ product.relative_discount }}",
105
+ purchase: "Subscribe and save {{ product.relative_discount }}",
106
+ };
107
+ function paywall(id, tabs) {
108
+ return {
109
+ id,
110
+ default_locale: "en_US",
111
+ components_localizations: { en_US: localizations },
112
+ components_config: {
113
+ base: {
114
+ stack: createStack({
115
+ id: "root",
116
+ name: "Root",
117
+ components: [tabs, purchaseButton],
118
+ }),
119
+ },
120
+ },
121
+ };
122
+ }
123
+ /** Two tabs whose most expensive monthly prices differ, like a multi-brand bundle paywall. */
124
+ export const RELATIVE_DISCOUNT_TABS_PAYWALL = paywall("relative-discount-tabs", RELATIVE_DISCOUNT_TABS_TABS);
125
+ /** A package directly in an outer tab, next to nested tabs whose packages count toward it. */
126
+ export const RELATIVE_DISCOUNT_NESTED_TABS_PAYWALL = paywall("relative-discount-nested-tabs", tabsOf("outer-tabs", [
127
+ tab("bundle", [
128
+ packageWithBadge("plus_annual", false),
129
+ tabsOf("inner-tabs", [
130
+ tab("radio_x", [
131
+ packageWithBadge("x_monthly", false),
132
+ packageWithBadge("x_annual", true),
133
+ ]),
134
+ tab("radio_plus", [packageWithBadge("plus_monthly", true)]),
135
+ ]),
136
+ ]),
137
+ ]));
138
+ const monthly = (price) => Math.round(price * 1_000_000);
139
+ const yearly = (price) => Math.round((price * 1_000_000) / 12);
140
+ export const RELATIVE_DISCOUNT_TABS_INFO_PER_PACKAGE = {
141
+ x_monthly: { pricePerMonthMicros: monthly(4.99) },
142
+ x_annual: { pricePerMonthMicros: yearly(49.99) },
143
+ plus_monthly: { pricePerMonthMicros: monthly(9.99) },
144
+ plus_annual: { pricePerMonthMicros: yearly(89.99) },
145
+ };
146
+ const hostDiscount = (discount) => ({
147
+ "product.relative_discount": discount,
148
+ });
149
+ /** What the host computes against the whole offering (€9.99/month). */
150
+ export const RELATIVE_DISCOUNT_TABS_VARIABLES_PER_PACKAGE = {
151
+ x_monthly: hostDiscount("50%"),
152
+ x_annual: hostDiscount("58%"),
153
+ plus_monthly: hostDiscount(""),
154
+ plus_annual: hostDiscount("25%"),
155
+ };
@@ -3,7 +3,7 @@
3
3
  import { getColorModeContext } from "../../stores/color-mode";
4
4
  import { useConditionContext } from "../../stores/condition-context.svelte";
5
5
  import type { TimelineItemProps } from "../../types/components/timeline";
6
- import { css, mapColor } from "../../utils/base-utils";
6
+ import { css, mapColor, mapSpacing } from "../../utils/base-utils";
7
7
  import { resolveOverrideProperties } from "../../utils/style-utils";
8
8
  import Icon from "../icon/Icon.svelte";
9
9
  import TextNode from "../text/TextNode.svelte";
@@ -56,8 +56,7 @@
56
56
  "z-index": "1",
57
57
  width: `${connector.width}px`,
58
58
  height: `100%`, // Extend beyond icon
59
- "padding-top": `${connector.margin.top}px`,
60
- "padding-bottom": `${connector.margin.bottom}px`,
59
+ padding: mapSpacing(connector.margin),
61
60
  });
62
61
  });
63
62
 
@@ -0,0 +1,12 @@
1
+ import { type Readable, type Writable } from "svelte/store";
2
+ /** Most expensive monthly price (micros) of the nearest enclosing tab with packages. */
3
+ export type RelativeDiscountScopeStore = Readable<number | undefined>;
4
+ export declare function setRelativeDiscountScopeContext(scope: RelativeDiscountScopeStore): void;
5
+ export declare function getRelativeDiscountScopeContext(): RelativeDiscountScopeStore;
6
+ /** Scope published by the mounted Package that is currently selected. */
7
+ export type SelectedPackageScopeStore = Writable<{
8
+ packageId: string;
9
+ mostExpensivePricePerMonth: number | undefined;
10
+ } | undefined>;
11
+ export declare function setSelectedPackageScopeContext(store: SelectedPackageScopeStore): void;
12
+ export declare function getSelectedPackageScopeContext(): SelectedPackageScopeStore | undefined;
@@ -0,0 +1,17 @@
1
+ import { getContext, setContext } from "svelte";
2
+ import { readable } from "svelte/store";
3
+ const key = Symbol("relativeDiscountScope");
4
+ const selectedPackageKey = Symbol("selectedPackageDiscountScope");
5
+ export function setRelativeDiscountScopeContext(scope) {
6
+ setContext(key, scope);
7
+ }
8
+ const noScopeStore = readable(undefined);
9
+ export function getRelativeDiscountScopeContext() {
10
+ return (getContext(key) ?? noScopeStore);
11
+ }
12
+ export function setSelectedPackageScopeContext(store) {
13
+ setContext(selectedPackageKey, store);
14
+ }
15
+ export function getSelectedPackageScopeContext() {
16
+ return getContext(selectedPackageKey);
17
+ }
@@ -18,6 +18,9 @@ export declare const localizations: {
18
18
  id2: string;
19
19
  id3: string;
20
20
  badge: string;
21
+ partial_margin: string;
22
+ explicit_margin: string;
23
+ margin_expected: string;
21
24
  countdown: string;
22
25
  ended: string;
23
26
  days: string;
@@ -17317,6 +17317,9 @@ export const localizations = {
17317
17317
  id2: "Item 2",
17318
17318
  id3: "Item 3",
17319
17319
  badge: "Badge Text",
17320
+ partial_margin: "margin: { top: 48 }",
17321
+ explicit_margin: "margin: { top: 48, trailing: 0, bottom: 0, leading: 0 }",
17322
+ margin_expected: "48px gap above, edge to edge horizontally",
17320
17323
  countdown: "{{ count_days_with_zero }} : {{ count_hours_with_zero }} : {{ count_minutes_with_zero }} : {{ count_seconds_with_zero }}",
17321
17324
  ended: "Time's up!",
17322
17325
  days: "{{ count_days_without_zero }} days",
@@ -20,8 +20,8 @@ export interface TimelineProps extends BaseComponent {
20
20
  export interface TimelineConnectorProps {
21
21
  width: number;
22
22
  margin: {
23
- top: number;
24
- bottom: number;
23
+ top?: number;
24
+ bottom?: number;
25
25
  };
26
26
  color: ColorGradientScheme | null;
27
27
  }
@@ -92,5 +92,7 @@ export interface PackageInfo {
92
92
  hasTrial?: boolean;
93
93
  hasPromoOffer?: boolean;
94
94
  webCheckoutURL?: string;
95
+ /** Monthly-equivalent price in micros; subscriptions only. */
96
+ pricePerMonthMicros?: number;
95
97
  }
96
98
  export {};
package/dist/types.d.ts CHANGED
@@ -87,10 +87,10 @@ export type BorderType = {
87
87
  width: number;
88
88
  };
89
89
  export type Spacing = {
90
- top: number;
91
- bottom: number;
92
- leading: number;
93
- trailing: number;
90
+ top?: number;
91
+ bottom?: number;
92
+ leading?: number;
93
+ trailing?: number;
94
94
  };
95
95
  export type CornerRadiusType = {
96
96
  bottom_leading: number;
@@ -34,7 +34,7 @@ export function mapSize(size) {
34
34
  }
35
35
  }
36
36
  export function mapSpacing(spacing) {
37
- const { top, bottom, leading, trailing } = spacing ?? DEFAULT_SPACING;
37
+ const { top = 0, bottom = 0, leading = 0, trailing = 0, } = spacing ?? DEFAULT_SPACING;
38
38
  return mapTuple(top, trailing, bottom, leading);
39
39
  }
40
40
  export function mapColorInfo(info) {
@@ -0,0 +1,9 @@
1
+ import type { Component } from "../types/component";
2
+ import type { PackageInfo, VariableDictionary } from "../types/variables";
3
+ /** Highest monthly price among the tab's packages, nested tabs included. */
4
+ export declare function mostExpensivePricePerMonth(tab: Component, infoPerPackage: Record<string, PackageInfo> | undefined): number | undefined;
5
+ /**
6
+ * Recomputes `product.relative_discount` against `mostExpensivePricePerMonth`.
7
+ * Returns the host's variables untouched when either price is unknown.
8
+ */
9
+ export declare function withScopedRelativeDiscount(variables: VariableDictionary | undefined, packageInfo: PackageInfo | undefined, mostExpensivePricePerMonth: number | undefined): VariableDictionary | undefined;
@@ -0,0 +1,29 @@
1
+ import { collectPackageIds } from "./style-utils";
2
+ /** Highest monthly price among the tab's packages, nested tabs included. */
3
+ export function mostExpensivePricePerMonth(tab, infoPerPackage) {
4
+ const prices = collectPackageIds(tab)
5
+ .map((id) => infoPerPackage?.[id]?.pricePerMonthMicros)
6
+ .filter((price) => price !== undefined);
7
+ return prices.length ? Math.max(...prices) : undefined;
8
+ }
9
+ /**
10
+ * Recomputes `product.relative_discount` against `mostExpensivePricePerMonth`.
11
+ * Returns the host's variables untouched when either price is unknown.
12
+ */
13
+ export function withScopedRelativeDiscount(variables, packageInfo, mostExpensivePricePerMonth) {
14
+ const pricePerMonth = packageInfo?.pricePerMonthMicros;
15
+ if (variables === undefined ||
16
+ pricePerMonth === undefined ||
17
+ mostExpensivePricePerMonth === undefined) {
18
+ return variables;
19
+ }
20
+ // Same rounding and < 1% cutoff as purchases-js; free-only scopes have no discount.
21
+ const discount = mostExpensivePricePerMonth > 0
22
+ ? ((mostExpensivePricePerMonth - pricePerMonth) * 100) /
23
+ mostExpensivePricePerMonth
24
+ : 0;
25
+ return {
26
+ ...variables,
27
+ "product.relative_discount": discount < 1 ? "" : `${discount.toFixed(0)}%`,
28
+ };
29
+ }
@@ -10,6 +10,8 @@ import type { PackageInfo, VariableDictionary } from "../types/variables.js";
10
10
  * @returns the id of the first package marked as `is_selected_by_default`, otherwise the first package id, or undefined
11
11
  */
12
12
  export declare function findSelectedPackageId({ stack, sticky_footer, }: RootPaywall): string | undefined;
13
+ /** Package ids under `root` in depth-first order, duplicates included. */
14
+ export declare function collectPackageIds(root: Component): string[];
13
15
  /**
14
16
  * Package ids in paywall display order (root stack, then sticky footer).
15
17
  * Duplicates keep the first occurrence. Unselected tabs still count.
@@ -93,28 +93,32 @@ export function findSelectedPackageId({ stack, sticky_footer, }) {
93
93
  }
94
94
  return undefined;
95
95
  }
96
- /**
97
- * Package ids in paywall display order (root stack, then sticky footer).
98
- * Duplicates keep the first occurrence. Unselected tabs still count.
99
- */
100
- export function collectPackageIdsInDisplayOrder({ stack, sticky_footer, }) {
96
+ /** Package ids under `root` in depth-first order, duplicates included. */
97
+ export function collectPackageIds(root) {
101
98
  const ids = [];
102
- const seen = new Set();
103
99
  const walk = (node) => {
104
- if (node.type === "package" && !seen.has(node.package_id)) {
105
- seen.add(node.package_id);
100
+ if (node.type === "package") {
106
101
  ids.push(node.package_id);
107
102
  }
108
103
  for (const child of getPaywallComponentChildNodes(node)) {
109
104
  walk(child);
110
105
  }
111
106
  };
112
- walk(stack);
113
- if (sticky_footer != null) {
114
- walk(sticky_footer);
115
- }
107
+ walk(root);
116
108
  return ids;
117
109
  }
110
+ /**
111
+ * Package ids in paywall display order (root stack, then sticky footer).
112
+ * Duplicates keep the first occurrence. Unselected tabs still count.
113
+ */
114
+ export function collectPackageIdsInDisplayOrder({ stack, sticky_footer, }) {
115
+ return [
116
+ ...new Set([
117
+ ...collectPackageIds(stack),
118
+ ...(sticky_footer != null ? collectPackageIds(sticky_footer) : []),
119
+ ]),
120
+ ];
121
+ }
118
122
  /**
119
123
  * Returns the overrides (in array order) whose conditions all match (AND) the
120
124
  * given context, without merging their properties.
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.12.1",
5
+ "version": "4.12.3",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },