@revenuecat/purchases-ui-js 4.12.0 → 4.12.2
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.
- package/dist/components/package/Package.svelte +21 -3
- package/dist/components/paywall/Paywall.stories.svelte +71 -0
- package/dist/components/paywall/Paywall.svelte +25 -8
- package/dist/components/purchase-button/custom-web-checkout-url.d.ts +2 -1
- package/dist/components/purchase-button/custom-web-checkout-url.js +5 -0
- package/dist/components/tabs/Tabs.svelte +16 -1
- package/dist/components/tabs/fixtures/relative-discount-tabs-paywall.d.ts +11 -0
- package/dist/components/tabs/fixtures/relative-discount-tabs-paywall.js +155 -0
- package/dist/stores/relativeDiscount.d.ts +12 -0
- package/dist/stores/relativeDiscount.js +17 -0
- package/dist/types/variables.d.ts +2 -0
- package/dist/utils/relative-discount.d.ts +9 -0
- package/dist/utils/relative-discount.js +29 -0
- package/dist/utils/style-utils.d.ts +2 -0
- package/dist/utils/style-utils.js +16 -12
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
|
@@ -12,6 +12,7 @@ type CustomWebCheckoutContext = {
|
|
|
12
12
|
* Mirrors the native SDKs: source, then app user ID, env and package when
|
|
13
13
|
* configured. The source is the host's own, so it survives a same-tab hop.
|
|
14
14
|
* Same-named params are replaced; an unparseable URL is returned unchanged.
|
|
15
|
+
* Script-capable URLs resolve to undefined, so the button purchases instead.
|
|
15
16
|
*/
|
|
16
|
-
export declare const buildCustomWebCheckoutURL: (url: string, config: CustomURLConfig, { packageId, appUserId, isSandbox, rcSource }: CustomWebCheckoutContext) => string;
|
|
17
|
+
export declare const buildCustomWebCheckoutURL: (url: string, config: CustomURLConfig, { packageId, appUserId, isSandbox, rcSource }: CustomWebCheckoutContext) => string | undefined;
|
|
17
18
|
export {};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
const SCRIPT_PROTOCOLS = new Set(["javascript:", "data:", "vbscript:"]);
|
|
1
2
|
/**
|
|
2
3
|
* Mirrors the native SDKs: source, then app user ID, env and package when
|
|
3
4
|
* configured. The source is the host's own, so it survives a same-tab hop.
|
|
4
5
|
* Same-named params are replaced; an unparseable URL is returned unchanged.
|
|
6
|
+
* Script-capable URLs resolve to undefined, so the button purchases instead.
|
|
5
7
|
*/
|
|
6
8
|
export const buildCustomWebCheckoutURL = (url, config, { packageId, appUserId, isSandbox, rcSource }) => {
|
|
7
9
|
let parsed;
|
|
@@ -11,6 +13,9 @@ export const buildCustomWebCheckoutURL = (url, config, { packageId, appUserId, i
|
|
|
11
13
|
catch {
|
|
12
14
|
return url;
|
|
13
15
|
}
|
|
16
|
+
if (SCRIPT_PROTOCOLS.has(parsed.protocol)) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
14
19
|
const { searchParams } = parsed;
|
|
15
20
|
if (rcSource !== undefined) {
|
|
16
21
|
searchParams.set("rc_source", rcSource);
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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"
|
|
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(
|
|
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.
|