2025-05-13 16:14:39 +02:00
|
|
|
import m, { Children, Component, Vnode } from "mithril"
|
2025-04-24 15:37:24 +02:00
|
|
|
import { lang } from "../misc/LanguageViewModel"
|
2025-09-17 16:32:10 +02:00
|
|
|
import { PaymentInterval, PriceAndConfigProvider } from "./utils/PriceUtils"
|
2025-05-13 16:14:39 +02:00
|
|
|
import { SelectedSubscriptionOptions } from "./FeatureListProvider"
|
|
|
|
import { lazy } from "@tutao/tutanota-utils"
|
2025-06-25 12:05:45 +02:00
|
|
|
import { AvailablePlanType, PlanType } from "../api/common/TutanotaConstants.js"
|
2025-09-05 15:04:29 +02:00
|
|
|
import { component_size, px, size } from "../gui/size.js"
|
2025-04-24 15:37:24 +02:00
|
|
|
import { LoginButton, LoginButtonAttrs, LoginButtonType } from "../gui/base/buttons/LoginButton.js"
|
|
|
|
import Stream from "mithril/stream"
|
|
|
|
import stream from "mithril/stream"
|
|
|
|
import { theme } from "../gui/theme.js"
|
|
|
|
import { styles } from "../gui/styles.js"
|
2025-04-04 09:59:49 +02:00
|
|
|
import { boxShadowHigh } from "../gui/main-styles.js"
|
2025-04-24 15:37:24 +02:00
|
|
|
import { windowFacade } from "../misc/WindowFacade.js"
|
2025-09-17 16:32:10 +02:00
|
|
|
import { getApplePriceStr, getPriceStr } from "./utils/SubscriptionUtils.js"
|
|
|
|
import { PaymentIntervalSwitch } from "./components/PaymentIntervalSwitch.js"
|
|
|
|
import { PersonalPlanContainer } from "./components/PersonalPlanContainer"
|
|
|
|
import { BusinessPlanContainer } from "./components/BusinessPlanContainer"
|
|
|
|
import { DiscountDetail, isPersonalPlanAvailable } from "./utils/PlanSelectorUtils"
|
2025-04-24 15:37:24 +02:00
|
|
|
|
|
|
|
type PlanSelectorAttr = {
|
|
|
|
options: SelectedSubscriptionOptions
|
|
|
|
actionButtons: SubscriptionActionButtons
|
|
|
|
priceAndConfigProvider: PriceAndConfigProvider
|
2025-06-25 12:05:45 +02:00
|
|
|
availablePlans: readonly AvailablePlanType[]
|
2025-05-13 16:14:39 +02:00
|
|
|
isApplePrice: boolean
|
2025-06-25 12:05:45 +02:00
|
|
|
currentPlan?: PlanType
|
|
|
|
currentPaymentInterval?: PaymentInterval
|
|
|
|
allowSwitchingPaymentInterval: boolean
|
|
|
|
showMultiUser: boolean
|
2025-09-17 16:32:10 +02:00
|
|
|
discountDetail?: DiscountDetail
|
2025-04-24 15:37:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export class PlanSelector implements Component<PlanSelectorAttr> {
|
2025-09-17 16:32:10 +02:00
|
|
|
private readonly selectedPlan: Stream<PlanType> = stream(PlanType.Revolutionary)
|
2025-04-24 15:37:24 +02:00
|
|
|
private readonly shouldFixButtonPos: Stream<boolean> = stream(false)
|
|
|
|
|
2025-06-25 12:05:45 +02:00
|
|
|
oncreate({ attrs: { availablePlans, currentPlan } }: Vnode<PlanSelectorAttr>) {
|
|
|
|
if (availablePlans.includes(PlanType.Free) && availablePlans.length === 1) {
|
|
|
|
// Only Free plan is available. This would be the case if the user already has a paid Apple account.
|
|
|
|
this.selectedPlan(PlanType.Free)
|
|
|
|
} else if ((!availablePlans.includes(PlanType.Revolutionary) && availablePlans.includes(PlanType.Legend)) || currentPlan === PlanType.Revolutionary) {
|
|
|
|
// Only Legend plan is available or the current plan is Revolutionary.
|
|
|
|
this.selectedPlan(PlanType.Legend)
|
|
|
|
}
|
2025-05-13 16:14:39 +02:00
|
|
|
|
2025-04-24 15:37:24 +02:00
|
|
|
this.handleResize()
|
|
|
|
windowFacade.addResizeListener(this.handleResize)
|
|
|
|
}
|
|
|
|
|
|
|
|
onbeforeremove(): void {
|
|
|
|
windowFacade.removeResizeListener(this.handleResize)
|
|
|
|
}
|
|
|
|
|
2025-08-05 16:19:12 +02:00
|
|
|
view({
|
2025-06-25 12:05:45 +02:00
|
|
|
attrs: {
|
|
|
|
options,
|
|
|
|
priceAndConfigProvider,
|
|
|
|
actionButtons,
|
|
|
|
availablePlans,
|
|
|
|
isApplePrice,
|
|
|
|
currentPlan,
|
|
|
|
currentPaymentInterval,
|
|
|
|
allowSwitchingPaymentInterval,
|
|
|
|
showMultiUser,
|
2025-09-17 16:32:10 +02:00
|
|
|
discountDetail,
|
2025-06-25 12:05:45 +02:00
|
|
|
},
|
2025-08-05 16:19:12 +02:00
|
|
|
}: Vnode<PlanSelectorAttr>): Children {
|
2025-04-24 15:37:24 +02:00
|
|
|
const isYearly = options.paymentInterval() === PaymentInterval.Yearly
|
2025-09-17 16:32:10 +02:00
|
|
|
|
|
|
|
options.businessUse(!isPersonalPlanAvailable(availablePlans) ? true : options.businessUse())
|
2025-04-24 15:37:24 +02:00
|
|
|
|
2025-05-13 16:14:39 +02:00
|
|
|
const renderFootnoteElement = (): Children => {
|
|
|
|
const getRevoPriceStrProps = {
|
|
|
|
priceAndConfigProvider,
|
|
|
|
paymentInterval: PaymentInterval.Yearly,
|
|
|
|
targetPlan: PlanType.Revolutionary,
|
|
|
|
}
|
|
|
|
const { referencePriceStr: revoRefPriceStr } = isApplePrice ? getApplePriceStr(getRevoPriceStrProps) : getPriceStr(getRevoPriceStrProps)
|
|
|
|
|
|
|
|
const getLegendPriceStrProps = {
|
|
|
|
priceAndConfigProvider,
|
|
|
|
paymentInterval: PaymentInterval.Yearly,
|
|
|
|
targetPlan: PlanType.Legend,
|
|
|
|
}
|
|
|
|
const { referencePriceStr: legendRefPriceStr } = isApplePrice ? getApplePriceStr(getLegendPriceStrProps) : getPriceStr(getLegendPriceStrProps)
|
|
|
|
|
2025-09-17 16:32:10 +02:00
|
|
|
if (discountDetail?.discountType === "GlobalFirstYear" && isYearly) {
|
2025-05-13 16:14:39 +02:00
|
|
|
return m(
|
|
|
|
".flex.column-gap-s",
|
|
|
|
m("span", m("sup", "1")),
|
|
|
|
m(
|
|
|
|
"span",
|
|
|
|
lang.get(isApplePrice ? "pricing.firstYearDiscountIos_revo_legend_msg" : "pricing.firstYearDiscount_revo_legend_msg", {
|
|
|
|
"{revo-price}": revoRefPriceStr ?? "",
|
|
|
|
"{legend-price}": legendRefPriceStr ?? "",
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
)
|
2025-04-24 15:37:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
|
2025-05-13 16:14:39 +02:00
|
|
|
const renderActionButton = (): Children => {
|
|
|
|
return m(LoginButton, {
|
|
|
|
// The label text for go european campaign shall not be translated.
|
|
|
|
label: "continue_action",
|
|
|
|
type: LoginButtonType.FullWidth,
|
2025-06-25 12:05:45 +02:00
|
|
|
onclick: (event, dom) => actionButtons[this.selectedPlan() as AvailablePlans]().onclick(event, dom),
|
2025-09-17 16:32:10 +02:00
|
|
|
// Used for changing button design during global campaigns.
|
|
|
|
// ...(discountDetail?.discountType === "GlobalFirstYear" && {
|
|
|
|
// // As we modify the size of the Login button for the campaign, the normal "Continue" button should have the same size to avoid layout shifting
|
|
|
|
// class: "go-european-button",
|
|
|
|
// icon: m("img.block", {
|
|
|
|
// src: `${window.tutao.appState.prefixWithoutFile}/images/go-european/eu-quantum.svg`,
|
|
|
|
// alt: "",
|
|
|
|
// rel: "noreferrer",
|
|
|
|
// loading: "lazy",
|
|
|
|
// decoding: "async",
|
|
|
|
// style: {
|
|
|
|
// height: px(36),
|
|
|
|
// width: px(36),
|
|
|
|
// },
|
|
|
|
// }),
|
|
|
|
// }),
|
2025-05-13 16:14:39 +02:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
const renderPaymentIntervalSwitch = () => {
|
|
|
|
return m(
|
|
|
|
".flex.gap-hpad.items-center",
|
|
|
|
m(`div.right.full-width${isYearly ? ".font-weight-600" : ""}`, lang.getTranslationText("pricing.yearly_label")),
|
|
|
|
m(PaymentIntervalSwitch, {
|
|
|
|
state: isYearly ? "left" : "right",
|
2025-06-25 12:05:45 +02:00
|
|
|
onclick: (value) => {
|
|
|
|
const targetInterval = value === "left" ? PaymentInterval.Yearly : PaymentInterval.Monthly
|
|
|
|
options.paymentInterval(targetInterval)
|
|
|
|
// Switch the selectedPlan to another plan to do not select the current plan with the current interval
|
|
|
|
if (targetInterval === currentPaymentInterval && this.selectedPlan() === currentPlan) {
|
|
|
|
this.selectedPlan(currentPlan === PlanType.Revolutionary ? PlanType.Legend : PlanType.Revolutionary)
|
|
|
|
}
|
|
|
|
},
|
2025-05-13 16:14:39 +02:00
|
|
|
ariaLabel: lang.get("emptyString_msg"),
|
|
|
|
}),
|
|
|
|
m(`div.left.full-width${!isYearly ? ".font-weight-600" : ""}`, lang.getTranslationText("pricing.monthly_label")),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-04-24 15:37:24 +02:00
|
|
|
return m(
|
2025-09-26 16:06:19 +02:00
|
|
|
"#plan-selector.flex.flex-column.gap-vpad-l",
|
2025-04-24 15:37:24 +02:00
|
|
|
{
|
|
|
|
style: this.shouldFixButtonPos() && {
|
2025-09-19 11:26:55 +02:00
|
|
|
"padding-bottom": px(component_size.button_floating_size + size.spacing_16),
|
2025-04-24 15:37:24 +02:00
|
|
|
},
|
|
|
|
lang: lang.code,
|
|
|
|
},
|
|
|
|
[
|
|
|
|
m(
|
2025-09-26 16:06:19 +02:00
|
|
|
".flex.flex-column.gap-vpad-l",
|
2025-09-17 16:32:10 +02:00
|
|
|
!(availablePlans.length === 1 && availablePlans.includes(PlanType.Free)) && allowSwitchingPaymentInterval && renderPaymentIntervalSwitch(),
|
2025-04-24 15:37:24 +02:00
|
|
|
),
|
2025-09-17 16:32:10 +02:00
|
|
|
|
|
|
|
m(options.businessUse() ? BusinessPlanContainer : PersonalPlanContainer, {
|
|
|
|
allowSwitchingPaymentInterval,
|
|
|
|
availablePlans,
|
|
|
|
currentPaymentInterval,
|
|
|
|
currentPlan,
|
|
|
|
isApplePrice,
|
|
|
|
priceAndConfigProvider,
|
|
|
|
selectedPlan: this.selectedPlan,
|
|
|
|
selectedSubscriptionOptions: options,
|
|
|
|
showMultiUser,
|
|
|
|
discountDetail,
|
|
|
|
}),
|
2025-04-24 15:37:24 +02:00
|
|
|
m(
|
|
|
|
".flex.flex-column.gap-vpad",
|
|
|
|
m(
|
2025-05-13 16:14:39 +02:00
|
|
|
"#continue-wrapper.flex-v-center.plr",
|
2025-04-24 15:37:24 +02:00
|
|
|
{
|
|
|
|
style: this.shouldFixButtonPos() && {
|
|
|
|
position: "fixed",
|
2025-09-19 11:26:55 +02:00
|
|
|
height: px(component_size.button_floating_size + size.spacing_4 * 2),
|
2025-04-24 15:37:24 +02:00
|
|
|
bottom: 0,
|
|
|
|
left: 0,
|
|
|
|
right: 0,
|
2025-04-04 09:59:49 +02:00
|
|
|
"background-color": theme.surface,
|
2025-04-24 15:37:24 +02:00
|
|
|
"z-index": 1,
|
2025-04-04 09:59:49 +02:00
|
|
|
"box-shadow": boxShadowHigh,
|
2025-04-24 15:37:24 +02:00
|
|
|
},
|
|
|
|
},
|
|
|
|
m(
|
|
|
|
"",
|
|
|
|
{
|
|
|
|
style: {
|
2025-05-13 16:14:39 +02:00
|
|
|
"min-width": styles.isMobileLayout() ? "100%" : px(265),
|
|
|
|
"max-width": styles.isMobileLayout() ? "100%" : px(265),
|
2025-04-24 15:37:24 +02:00
|
|
|
"margin-inline": "auto",
|
|
|
|
},
|
|
|
|
},
|
2025-05-13 16:14:39 +02:00
|
|
|
renderActionButton(),
|
2025-04-24 15:37:24 +02:00
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
2025-09-17 16:32:10 +02:00
|
|
|
!(availablePlans.length === 1 && availablePlans.includes(PlanType.Free)) &&
|
2025-05-13 16:14:39 +02:00
|
|
|
m(".flex.flex-column", [
|
|
|
|
m(".small.mb.center", lang.get("pricing.subscriptionPeriodInfoPrivate_msg")),
|
|
|
|
m(".small.mb", renderFootnoteElement()),
|
|
|
|
]),
|
2025-04-24 15:37:24 +02:00
|
|
|
],
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-05-13 16:14:39 +02:00
|
|
|
/**
|
|
|
|
* Change the position of the "Continue" button to be fixed on the bottom
|
|
|
|
* if there is not enough space to show the button without scrolling.
|
|
|
|
*/
|
2025-04-24 15:37:24 +02:00
|
|
|
private readonly handleResize = () => {
|
|
|
|
const planSelectorEl = document.querySelector("#plan-selector")
|
|
|
|
const containerEl = document.querySelector(".dialog-container")
|
|
|
|
if (planSelectorEl && containerEl) {
|
|
|
|
const contentHeight = parseInt(getComputedStyle(planSelectorEl).height)
|
|
|
|
const containerHeight = parseInt(getComputedStyle(containerEl).height)
|
|
|
|
|
2025-09-05 15:04:29 +02:00
|
|
|
this.shouldFixButtonPos(contentHeight + component_size.button_floating_size > containerHeight)
|
2025-04-24 15:37:24 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-09-17 16:32:10 +02:00
|
|
|
export type AvailablePlans = PlanType.Revolutionary | PlanType.Legend | PlanType.Free
|
2025-04-24 15:37:24 +02:00
|
|
|
|
|
|
|
export type SubscriptionActionButtons = Record<AvailablePlans, lazy<LoginButtonAttrs>>
|