[web] dynamic free storage (#1644)

Behave like the mobile app and use the server's response to show the
storage value for the free plan instead of hardcoding it to 1 GB.

Tested with a local museum:
<img width="203" alt="Screenshot 2024-05-07 at 13 12 39"
src="https://github.com/ente-io/ente/assets/24503581/79a81a3f-614e-4e0a-9839-de39d7e9396a">
This commit is contained in:
Manav Rathi 2024-05-07 13:17:24 +05:30 committed by GitHub
commit 425cc9050b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 480 additions and 459 deletions

View file

@ -0,0 +1,356 @@
import log from "@/next/log";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { SUPPORT_EMAIL } from "@ente/shared/constants/urls";
import Close from "@mui/icons-material/Close";
import { IconButton, Link, Stack } from "@mui/material";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { PLAN_PERIOD } from "constants/gallery";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import { GalleryContext } from "pages/gallery";
import { useContext, useEffect, useMemo, useState } from "react";
import { Trans } from "react-i18next";
import billingService, { type PlansResponse } from "services/billingService";
import { Plan } from "types/billing";
import { SetLoading } from "types/gallery";
import {
getLocalUserSubscription,
hasAddOnBonus,
hasMobileSubscription,
hasPaidSubscription,
hasStripeSubscription,
isOnFreePlan,
isSubscriptionActive,
isSubscriptionCancelled,
isUserSubscribedPlan,
planForSubscription,
updateSubscription,
} from "utils/billing";
import { bytesInGB } from "utils/units";
import { getLocalUserDetails } from "utils/user";
import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family";
import { ManageSubscription } from "./manageSubscription";
import { PeriodToggler } from "./periodToggler";
import Plans from "./plans";
import { BFAddOnRow } from "./plans/BfAddOnRow";
interface Props {
closeModal: any;
setLoading: SetLoading;
}
function PlanSelectorCard(props: Props) {
const subscription = useMemo(() => getLocalUserSubscription(), []);
const [plansResponse, setPlansResponse] = useState<
PlansResponse | undefined
>();
const [planPeriod, setPlanPeriod] = useState<PLAN_PERIOD>(
subscription?.period || PLAN_PERIOD.MONTH,
);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
const bonusData = useMemo(() => {
const userDetails = getLocalUserDetails();
if (!userDetails) {
return null;
}
return userDetails.bonusData;
}, []);
const usage = useMemo(() => {
const userDetails = getLocalUserDetails();
if (!userDetails) {
return 0;
}
return isPartOfFamily(userDetails.familyData)
? getTotalFamilyUsage(userDetails.familyData)
: userDetails.usage;
}, []);
const togglePeriod = () => {
setPlanPeriod((prevPeriod) =>
prevPeriod === PLAN_PERIOD.MONTH
? PLAN_PERIOD.YEAR
: PLAN_PERIOD.MONTH,
);
};
function onReopenClick() {
appContext.closeMessageDialog();
galleryContext.showPlanSelectorModal();
}
useEffect(() => {
const main = async () => {
try {
props.setLoading(true);
const response = await billingService.getPlans();
const { plans } = response;
if (isSubscriptionActive(subscription)) {
const planNotListed =
plans.filter((plan) =>
isUserSubscribedPlan(plan, subscription),
).length === 0;
if (
subscription &&
!isOnFreePlan(subscription) &&
planNotListed
) {
plans.push(planForSubscription(subscription));
}
}
setPlansResponse(response);
} catch (e) {
log.error("plan selector modal open failed", e);
props.closeModal();
appContext.setDialogMessage({
title: t("OPEN_PLAN_SELECTOR_MODAL_FAILED"),
content: t("UNKNOWN_ERROR"),
close: { text: t("CLOSE"), variant: "secondary" },
proceed: {
text: t("REOPEN_PLAN_SELECTOR_MODAL"),
variant: "accent",
action: onReopenClick,
},
});
} finally {
props.setLoading(false);
}
};
main();
}, []);
async function onPlanSelect(plan: Plan) {
if (
!hasPaidSubscription(subscription) ||
isSubscriptionCancelled(subscription)
) {
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: t("ERROR"),
content: t("SUBSCRIPTION_PURCHASE_FAILED"),
close: { variant: "critical" },
});
}
} else if (hasStripeSubscription(subscription)) {
appContext.setDialogMessage({
title: t("update_subscription_title"),
content: t("UPDATE_SUBSCRIPTION_MESSAGE"),
proceed: {
text: t("UPDATE_SUBSCRIPTION"),
action: updateSubscription.bind(
null,
plan,
appContext.setDialogMessage,
props.setLoading,
props.closeModal,
),
variant: "accent",
},
close: { text: t("CANCEL") },
});
} else if (hasMobileSubscription(subscription)) {
appContext.setDialogMessage({
title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"),
content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"),
close: { variant: "secondary" },
});
} else {
appContext.setDialogMessage({
title: t("MANAGE_PLAN"),
content: (
<Trans
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
components={{
a: <Link href={`mailto:${SUPPORT_EMAIL}`} />,
}}
values={{ emailID: SUPPORT_EMAIL }}
/>
),
close: { variant: "secondary" },
});
}
}
const { closeModal, setLoading } = props;
const commonCardData = {
subscription,
bonusData,
closeModal,
planPeriod,
togglePeriod,
setLoading,
};
const plansList = (
<Plans
plansResponse={plansResponse}
planPeriod={planPeriod}
onPlanSelect={onPlanSelect}
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
/>
);
return (
<>
<Stack spacing={3} p={1.5}>
{hasPaidSubscription(subscription) ? (
<PaidSubscriptionPlanSelectorCard
{...commonCardData}
usage={usage}
>
{plansList}
</PaidSubscriptionPlanSelectorCard>
) : (
<FreeSubscriptionPlanSelectorCard {...commonCardData}>
{plansList}
</FreeSubscriptionPlanSelectorCard>
)}
</Stack>
</>
);
}
export default PlanSelectorCard;
function FreeSubscriptionPlanSelectorCard({
children,
subscription,
bonusData,
closeModal,
setLoading,
planPeriod,
togglePeriod,
}) {
return (
<>
<Typography variant="h3" fontWeight={"bold"}>
{t("CHOOSE_PLAN")}
</Typography>
<Box>
<Stack spacing={3}>
<Box>
<PeriodToggler
planPeriod={planPeriod}
togglePeriod={togglePeriod}
/>
<Typography variant="small" mt={0.5} color="text.muted">
{t("TWO_MONTHS_FREE")}
</Typography>
</Box>
{children}
{hasAddOnBonus(bonusData) && (
<BFAddOnRow
bonusData={bonusData}
closeModal={closeModal}
/>
)}
{hasAddOnBonus(bonusData) && (
<ManageSubscription
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
setLoading={setLoading}
/>
)}
</Stack>
</Box>
</>
);
}
function PaidSubscriptionPlanSelectorCard({
children,
subscription,
bonusData,
closeModal,
usage,
planPeriod,
togglePeriod,
setLoading,
}) {
return (
<>
<Box pl={1.5} py={0.5}>
<SpaceBetweenFlex>
<Box>
<Typography variant="h3" fontWeight={"bold"}>
{t("SUBSCRIPTION")}
</Typography>
<Typography variant="small" color={"text.muted"}>
{bytesInGB(subscription.storage, 2)}{" "}
{t("storage_unit.gb")}
</Typography>
</Box>
<IconButton onClick={closeModal} color="secondary">
<Close />
</IconButton>
</SpaceBetweenFlex>
</Box>
<Box px={1.5}>
<Typography color={"text.muted"} fontWeight={"bold"}>
<Trans
i18nKey="CURRENT_USAGE"
values={{
usage: `${bytesInGB(usage, 2)} ${t("storage_unit.gb")}`,
}}
/>
</Typography>
</Box>
<Box>
<Stack
spacing={3}
border={(theme) => `1px solid ${theme.palette.divider}`}
p={1.5}
borderRadius={(theme) => `${theme.shape.borderRadius}px`}
>
<Box>
<PeriodToggler
planPeriod={planPeriod}
togglePeriod={togglePeriod}
/>
<Typography variant="small" mt={0.5} color="text.muted">
{t("TWO_MONTHS_FREE")}
</Typography>
</Box>
{children}
</Stack>
<Box py={1} px={1.5}>
<Typography color={"text.muted"}>
{!isSubscriptionCancelled(subscription)
? t("RENEWAL_ACTIVE_SUBSCRIPTION_STATUS", {
date: subscription.expiryTime,
})
: t("RENEWAL_CANCELLED_SUBSCRIPTION_STATUS", {
date: subscription.expiryTime,
})}
</Typography>
{hasAddOnBonus(bonusData) && (
<BFAddOnRow
bonusData={bonusData}
closeModal={closeModal}
/>
)}
</Box>
</Box>
<ManageSubscription
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
setLoading={setLoading}
/>
</>
);
}

View file

@ -1,64 +0,0 @@
import { Stack } from "@mui/material";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { t } from "i18next";
import { hasAddOnBonus } from "utils/billing";
import { ManageSubscription } from "../manageSubscription";
import { PeriodToggler } from "../periodToggler";
import Plans from "../plans";
import { BFAddOnRow } from "../plans/BfAddOnRow";
export default function FreeSubscriptionPlanSelectorCard({
plans,
subscription,
bonusData,
closeModal,
setLoading,
planPeriod,
togglePeriod,
onPlanSelect,
}) {
return (
<>
<Typography variant="h3" fontWeight={"bold"}>
{t("CHOOSE_PLAN")}
</Typography>
<Box>
<Stack spacing={3}>
<Box>
<PeriodToggler
planPeriod={planPeriod}
togglePeriod={togglePeriod}
/>
<Typography variant="small" mt={0.5} color="text.muted">
{t("TWO_MONTHS_FREE")}
</Typography>
</Box>
<Plans
plans={plans}
planPeriod={planPeriod}
onPlanSelect={onPlanSelect}
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
/>
{hasAddOnBonus(bonusData) && (
<BFAddOnRow
bonusData={bonusData}
closeModal={closeModal}
/>
)}
{hasAddOnBonus(bonusData) && (
<ManageSubscription
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
setLoading={setLoading}
/>
)}
</Stack>
</Box>
</>
);
}

View file

@ -1,202 +0,0 @@
import log from "@/next/log";
import { SUPPORT_EMAIL } from "@ente/shared/constants/urls";
import { useLocalState } from "@ente/shared/hooks/useLocalState";
import { LS_KEYS } from "@ente/shared/storage/localStorage";
import { Link, Stack } from "@mui/material";
import { PLAN_PERIOD } from "constants/gallery";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import { GalleryContext } from "pages/gallery";
import { useContext, useEffect, useMemo, useState } from "react";
import { Trans } from "react-i18next";
import billingService from "services/billingService";
import { Plan } from "types/billing";
import { SetLoading } from "types/gallery";
import {
getLocalUserSubscription,
hasMobileSubscription,
hasPaidSubscription,
hasStripeSubscription,
isOnFreePlan,
isSubscriptionActive,
isSubscriptionCancelled,
isUserSubscribedPlan,
planForSubscription,
updateSubscription,
} from "utils/billing";
import { getLocalUserDetails } from "utils/user";
import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family";
import FreeSubscriptionPlanSelectorCard from "./free";
import PaidSubscriptionPlanSelectorCard from "./paid";
interface Props {
closeModal: any;
setLoading: SetLoading;
}
function PlanSelectorCard(props: Props) {
const subscription = useMemo(() => getLocalUserSubscription(), []);
const [plans, setPlans] = useLocalState<Plan[]>(LS_KEYS.PLANS);
const [planPeriod, setPlanPeriod] = useState<PLAN_PERIOD>(
subscription?.period || PLAN_PERIOD.MONTH,
);
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
const bonusData = useMemo(() => {
const userDetails = getLocalUserDetails();
if (!userDetails) {
return null;
}
return userDetails.bonusData;
}, []);
const usage = useMemo(() => {
const userDetails = getLocalUserDetails();
if (!userDetails) {
return 0;
}
return isPartOfFamily(userDetails.familyData)
? getTotalFamilyUsage(userDetails.familyData)
: userDetails.usage;
}, []);
const togglePeriod = () => {
setPlanPeriod((prevPeriod) =>
prevPeriod === PLAN_PERIOD.MONTH
? PLAN_PERIOD.YEAR
: PLAN_PERIOD.MONTH,
);
};
function onReopenClick() {
appContext.closeMessageDialog();
galleryContext.showPlanSelectorModal();
}
useEffect(() => {
const main = async () => {
try {
props.setLoading(true);
const plans = await billingService.getPlans();
if (isSubscriptionActive(subscription)) {
const planNotListed =
plans.filter((plan) =>
isUserSubscribedPlan(plan, subscription),
).length === 0;
if (
subscription &&
!isOnFreePlan(subscription) &&
planNotListed
) {
plans.push(planForSubscription(subscription));
}
}
setPlans(plans);
} catch (e) {
log.error("plan selector modal open failed", e);
props.closeModal();
appContext.setDialogMessage({
title: t("OPEN_PLAN_SELECTOR_MODAL_FAILED"),
content: t("UNKNOWN_ERROR"),
close: { text: t("CLOSE"), variant: "secondary" },
proceed: {
text: t("REOPEN_PLAN_SELECTOR_MODAL"),
variant: "accent",
action: onReopenClick,
},
});
} finally {
props.setLoading(false);
}
};
main();
}, []);
async function onPlanSelect(plan: Plan) {
if (
!hasPaidSubscription(subscription) ||
isSubscriptionCancelled(subscription)
) {
try {
props.setLoading(true);
await billingService.buySubscription(plan.stripeID);
} catch (e) {
props.setLoading(false);
appContext.setDialogMessage({
title: t("ERROR"),
content: t("SUBSCRIPTION_PURCHASE_FAILED"),
close: { variant: "critical" },
});
}
} else if (hasStripeSubscription(subscription)) {
appContext.setDialogMessage({
title: t("update_subscription_title"),
content: t("UPDATE_SUBSCRIPTION_MESSAGE"),
proceed: {
text: t("UPDATE_SUBSCRIPTION"),
action: updateSubscription.bind(
null,
plan,
appContext.setDialogMessage,
props.setLoading,
props.closeModal,
),
variant: "accent",
},
close: { text: t("CANCEL") },
});
} else if (hasMobileSubscription(subscription)) {
appContext.setDialogMessage({
title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"),
content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"),
close: { variant: "secondary" },
});
} else {
appContext.setDialogMessage({
title: t("MANAGE_PLAN"),
content: (
<Trans
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
components={{
a: <Link href={`mailto:${SUPPORT_EMAIL}`} />,
}}
values={{ emailID: SUPPORT_EMAIL }}
/>
),
close: { variant: "secondary" },
});
}
}
return (
<>
<Stack spacing={3} p={1.5}>
{hasPaidSubscription(subscription) ? (
<PaidSubscriptionPlanSelectorCard
plans={plans}
subscription={subscription}
bonusData={bonusData}
closeModal={props.closeModal}
planPeriod={planPeriod}
togglePeriod={togglePeriod}
onPlanSelect={onPlanSelect}
setLoading={props.setLoading}
usage={usage}
/>
) : (
<FreeSubscriptionPlanSelectorCard
plans={plans}
subscription={subscription}
bonusData={bonusData}
closeModal={props.closeModal}
setLoading={props.setLoading}
planPeriod={planPeriod}
togglePeriod={togglePeriod}
onPlanSelect={onPlanSelect}
/>
)}
</Stack>
</>
);
}
export default PlanSelectorCard;

View file

@ -1,109 +0,0 @@
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import Close from "@mui/icons-material/Close";
import { IconButton, Stack } from "@mui/material";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { t } from "i18next";
import { Trans } from "react-i18next";
import { hasAddOnBonus, isSubscriptionCancelled } from "utils/billing";
import { bytesInGB } from "utils/units";
import { ManageSubscription } from "../manageSubscription";
import { PeriodToggler } from "../periodToggler";
import Plans from "../plans";
import { BFAddOnRow } from "../plans/BfAddOnRow";
export default function PaidSubscriptionPlanSelectorCard({
plans,
subscription,
bonusData,
closeModal,
usage,
planPeriod,
togglePeriod,
onPlanSelect,
setLoading,
}) {
return (
<>
<Box pl={1.5} py={0.5}>
<SpaceBetweenFlex>
<Box>
<Typography variant="h3" fontWeight={"bold"}>
{t("SUBSCRIPTION")}
</Typography>
<Typography variant="small" color={"text.muted"}>
{bytesInGB(subscription.storage, 2)}{" "}
{t("storage_unit.gb")}
</Typography>
</Box>
<IconButton onClick={closeModal} color="secondary">
<Close />
</IconButton>
</SpaceBetweenFlex>
</Box>
<Box px={1.5}>
<Typography color={"text.muted"} fontWeight={"bold"}>
<Trans
i18nKey="CURRENT_USAGE"
values={{
usage: `${bytesInGB(usage, 2)} ${t("storage_unit.gb")}`,
}}
/>
</Typography>
</Box>
<Box>
<Stack
spacing={3}
border={(theme) => `1px solid ${theme.palette.divider}`}
p={1.5}
borderRadius={(theme) => `${theme.shape.borderRadius}px`}
>
<Box>
<PeriodToggler
planPeriod={planPeriod}
togglePeriod={togglePeriod}
/>
<Typography variant="small" mt={0.5} color="text.muted">
{t("TWO_MONTHS_FREE")}
</Typography>
</Box>
<Plans
plans={plans}
planPeriod={planPeriod}
onPlanSelect={onPlanSelect}
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
/>
</Stack>
<Box py={1} px={1.5}>
<Typography color={"text.muted"}>
{!isSubscriptionCancelled(subscription)
? t("RENEWAL_ACTIVE_SUBSCRIPTION_STATUS", {
date: subscription.expiryTime,
})
: t("RENEWAL_CANCELLED_SUBSCRIPTION_STATUS", {
date: subscription.expiryTime,
})}
</Typography>
{hasAddOnBonus(bonusData) && (
<BFAddOnRow
bonusData={bonusData}
closeModal={closeModal}
/>
)}
</Box>
</Box>
<ManageSubscription
subscription={subscription}
bonusData={bonusData}
closeModal={closeModal}
setLoading={setLoading}
/>
</>
);
}

View file

@ -1,28 +0,0 @@
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import ArrowForward from "@mui/icons-material/ArrowForward";
import { Box, IconButton, styled, Typography } from "@mui/material";
import { t } from "i18next";
const RowContainer = styled(SpaceBetweenFlex)(({ theme }) => ({
gap: theme.spacing(1.5),
padding: theme.spacing(1.5, 1),
cursor: "pointer",
"&:hover .endIcon": {
backgroundColor: "rgba(255,255,255,0.08)",
},
}));
export function FreePlanRow({ closeModal }) {
return (
<RowContainer onClick={closeModal}>
<Box>
<Typography> {t("FREE_PLAN_OPTION_LABEL")}</Typography>
<Typography variant="small" color="text.muted">
{t("FREE_PLAN_DESCRIPTION")}
</Typography>
</Box>
<IconButton className={"endIcon"}>
<ArrowForward />
</IconButton>
</RowContainer>
);
}

View file

@ -1,5 +1,9 @@
import { Stack } from "@mui/material"; import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import ArrowForward from "@mui/icons-material/ArrowForward";
import { Box, IconButton, Stack, Typography, styled } from "@mui/material";
import { PLAN_PERIOD } from "constants/gallery"; import { PLAN_PERIOD } from "constants/gallery";
import { t } from "i18next";
import type { PlansResponse } from "services/billingService";
import { Plan, Subscription } from "types/billing"; import { Plan, Subscription } from "types/billing";
import { BonusData } from "types/user"; import { BonusData } from "types/user";
import { import {
@ -8,11 +12,11 @@ import {
isPopularPlan, isPopularPlan,
isUserSubscribedPlan, isUserSubscribedPlan,
} from "utils/billing"; } from "utils/billing";
import { FreePlanRow } from "./FreePlanRow"; import { formattedStorageByteSize } from "utils/units";
import { PlanRow } from "./planRow"; import { PlanRow } from "./planRow";
interface Iprops { interface Iprops {
plans: Plan[]; plansResponse: PlansResponse | undefined;
planPeriod: PLAN_PERIOD; planPeriod: PLAN_PERIOD;
subscription: Subscription; subscription: Subscription;
bonusData?: BonusData; bonusData?: BonusData;
@ -21,30 +25,70 @@ interface Iprops {
} }
const Plans = ({ const Plans = ({
plans, plansResponse,
planPeriod, planPeriod,
subscription, subscription,
bonusData, bonusData,
onPlanSelect, onPlanSelect,
closeModal, closeModal,
}: Iprops) => ( }: Iprops) => {
<Stack spacing={2}> const { freePlan, plans } = plansResponse ?? {};
{plans return (
?.filter((plan) => plan.period === planPeriod) <Stack spacing={2}>
?.map((plan) => ( {plans
<PlanRow ?.filter((plan) => plan.period === planPeriod)
disabled={isUserSubscribedPlan(plan, subscription)} ?.map((plan) => (
popular={isPopularPlan(plan)} <PlanRow
key={plan.stripeID} disabled={isUserSubscribedPlan(plan, subscription)}
plan={plan} popular={isPopularPlan(plan)}
subscription={subscription} key={plan.stripeID}
onPlanSelect={onPlanSelect} plan={plan}
/> subscription={subscription}
))} onPlanSelect={onPlanSelect}
{!hasPaidSubscription(subscription) && !hasAddOnBonus(bonusData) && ( />
<FreePlanRow closeModal={closeModal} /> ))}
)} {!hasPaidSubscription(subscription) &&
</Stack> !hasAddOnBonus(bonusData) &&
); freePlan && (
<FreePlanRow
storage={freePlan.storage}
closeModal={closeModal}
/>
)}
</Stack>
);
};
export default Plans; export default Plans;
interface FreePlanRowProps {
storage: number;
closeModal: () => void;
}
const FreePlanRow: React.FC<FreePlanRowProps> = ({ closeModal, storage }) => {
return (
<FreePlanRow_ onClick={closeModal}>
<Box>
<Typography> {t("FREE_PLAN_OPTION_LABEL")}</Typography>
<Typography variant="small" color="text.muted">
{t("free_plan_description", {
storage: formattedStorageByteSize(storage),
})}
</Typography>
</Box>
<IconButton className={"endIcon"}>
<ArrowForward />
</IconButton>
</FreePlanRow_>
);
};
const FreePlanRow_ = styled(SpaceBetweenFlex)(({ theme }) => ({
gap: theme.spacing(1.5),
padding: theme.spacing(1.5, 1),
cursor: "pointer",
"&:hover .endIcon": {
backgroundColor: "rgba(255,255,255,0.08)",
},
}));

View file

@ -19,8 +19,18 @@ enum PaymentActionType {
Update = "update", Update = "update",
} }
export interface FreePlan {
/* Number of bytes available in the free plan */
storage: number;
}
export interface PlansResponse {
freePlan: FreePlan;
plans: Plan[];
}
class billingService { class billingService {
public async getPlans(): Promise<Plan[]> { public async getPlans(): Promise<PlansResponse> {
const token = getToken(); const token = getToken();
try { try {
let response; let response;
@ -37,8 +47,7 @@ class billingService {
}, },
); );
} }
const { plans } = response.data; return response.data;
return plans;
} catch (e) { } catch (e) {
log.error("failed to get plans", e); log.error("failed to get plans", e);
} }

View file

@ -14,6 +14,7 @@ export interface Subscription {
price: string; price: string;
period: PLAN_PERIOD; period: PLAN_PERIOD;
} }
export interface Plan { export interface Plan {
id: string; id: string;
androidID: string; androidID: string;

View file

@ -1,5 +1,11 @@
import { t } from "i18next"; import { t } from "i18next";
/**
* Localized unit keys.
*
* For each of these, there is expected to be a localized key under
* "storage_unit". e.g. "storage_unit.tb".
*/
const units = ["b", "kb", "mb", "gb", "tb"]; const units = ["b", "kb", "mb", "gb", "tb"];
/** /**
@ -21,13 +27,16 @@ export const bytesInGB = (bytes: number, precision = 0): string =>
* Defaults to 2. * Defaults to 2.
*/ */
export function formattedByteSize(bytes: number, precision = 2): string { export function formattedByteSize(bytes: number, precision = 2): string {
if (bytes === 0 || isNaN(bytes)) { if (bytes <= 0) return `0 ${t("storage_unit.mb")}`;
return "0 MB";
}
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.min(
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; Math.floor(Math.log(bytes) / Math.log(1024)),
return (bytes / Math.pow(1024, i)).toFixed(precision) + " " + sizes[i]; units.length - 1,
);
const quantity = bytes / Math.pow(1024, i);
const unit = units[i];
return `${quantity.toFixed(precision)} ${t(`storage_unit.${unit}`)}`;
} }
interface FormattedStorageByteSizeOptions { interface FormattedStorageByteSizeOptions {
@ -50,7 +59,7 @@ interface FormattedStorageByteSizeOptions {
* displaying the "storage size" (in different contexts) as opposed to, say, a * displaying the "storage size" (in different contexts) as opposed to, say, a
* generic "file size". * generic "file size".
* *
* @param options * @param options {@link FormattedStorageByteSizeOptions}.
* *
* @return A user visible string, including the localized unit suffix. * @return A user visible string, including the localized unit suffix.
*/ */
@ -58,21 +67,27 @@ export const formattedStorageByteSize = (
bytes: number, bytes: number,
options?: FormattedStorageByteSizeOptions, options?: FormattedStorageByteSizeOptions,
): string => { ): string => {
if (bytes <= 0) { if (bytes <= 0) return `0 ${t("storage_unit.mb")}`;
return `0 ${t("storage_unit.mb")}`;
} const i = Math.min(
const i = Math.floor(Math.log(bytes) / Math.log(1024)); Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
let quantity = bytes / Math.pow(1024, i); let quantity = bytes / Math.pow(1024, i);
let unit = units[i]; let unit = units[i];
if (quantity > 100 && unit !== "GB") { // Round up bytes, KBs and MBs to the bigger unit whenever they'll come of
// as more than 0.1.
if (quantity > 100 && i < units.length - 2) {
quantity /= 1024; quantity /= 1024;
unit = units[i + 1]; unit = units[i + 1];
} }
quantity = Number(quantity.toFixed(1)); quantity = Number(quantity.toFixed(1));
// Truncate or round storage sizes to trim off unnecessary and potentially
// obscuring precision when they are larger that 10 GB.
if (bytes >= 10 * 1024 * 1024 * 1024 /* 10 GB */) { if (bytes >= 10 * 1024 * 1024 * 1024 /* 10 GB */) {
if (options?.round) { if (options?.round) {
quantity = Math.ceil(quantity); quantity = Math.ceil(quantity);

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Erhalte 2 Monate kostenlos bei Jahresabonnements", "TWO_MONTHS_FREE": "Erhalte 2 Monate kostenlos bei Jahresabonnements",
"POPULAR": "Beliebt", "POPULAR": "Beliebt",
"FREE_PLAN_OPTION_LABEL": "Mit kostenloser Testversion fortfahren", "FREE_PLAN_OPTION_LABEL": "Mit kostenloser Testversion fortfahren",
"FREE_PLAN_DESCRIPTION": "1 GB für 1 Jahr", "free_plan_description": "{{storage}} für 1 Jahr",
"CURRENT_USAGE": "Aktuelle Nutzung ist <strong>{{usage}}</strong>", "CURRENT_USAGE": "Aktuelle Nutzung ist <strong>{{usage}}</strong>",
"WEAK_DEVICE": "Dein Browser ist nicht leistungsstark genug, um deine Bilder zu verschlüsseln. Versuche, dich an einem Computer bei Ente anzumelden, oder lade dir die Ente-App für dein Gerät (Handy oder Desktop) herunter.", "WEAK_DEVICE": "Dein Browser ist nicht leistungsstark genug, um deine Bilder zu verschlüsseln. Versuche, dich an einem Computer bei Ente anzumelden, oder lade dir die Ente-App für dein Gerät (Handy oder Desktop) herunter.",
"DRAG_AND_DROP_HINT": "Oder ziehe Dateien per Drag-and-Drop in das Ente-Fenster", "DRAG_AND_DROP_HINT": "Oder ziehe Dateien per Drag-and-Drop in das Ente-Fenster",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Get 2 months free on yearly plans", "TWO_MONTHS_FREE": "Get 2 months free on yearly plans",
"POPULAR": "Popular", "POPULAR": "Popular",
"FREE_PLAN_OPTION_LABEL": "Continue with free trial", "FREE_PLAN_OPTION_LABEL": "Continue with free trial",
"FREE_PLAN_DESCRIPTION": "1 GB for 1 year", "free_plan_description": "{{storage}} for 1 year",
"CURRENT_USAGE": "Current usage is <strong>{{usage}}</strong>", "CURRENT_USAGE": "Current usage is <strong>{{usage}}</strong>",
"WEAK_DEVICE": "The web browser you're using is not powerful enough to encrypt your photos. Please try to log in to Ente on your computer, or download the Ente mobile/desktop app.", "WEAK_DEVICE": "The web browser you're using is not powerful enough to encrypt your photos. Please try to log in to Ente on your computer, or download the Ente mobile/desktop app.",
"DRAG_AND_DROP_HINT": "Or drag and drop into the Ente window", "DRAG_AND_DROP_HINT": "Or drag and drop into the Ente window",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Obtén 2 meses gratis en planes anuales", "TWO_MONTHS_FREE": "Obtén 2 meses gratis en planes anuales",
"POPULAR": "Popular", "POPULAR": "Popular",
"FREE_PLAN_OPTION_LABEL": "Continuar con el plan gratuito", "FREE_PLAN_OPTION_LABEL": "Continuar con el plan gratuito",
"FREE_PLAN_DESCRIPTION": "1 GB por 1 año", "free_plan_description": "{{storage}} por 1 año",
"CURRENT_USAGE": "El uso actual es <strong>{{usage}}</strong>", "CURRENT_USAGE": "El uso actual es <strong>{{usage}}</strong>",
"WEAK_DEVICE": "El navegador web que está utilizando no es lo suficientemente poderoso para cifrar sus fotos. Por favor, intente iniciar sesión en ente en su computadora, o descargue la aplicación ente para móvil/escritorio.", "WEAK_DEVICE": "El navegador web que está utilizando no es lo suficientemente poderoso para cifrar sus fotos. Por favor, intente iniciar sesión en ente en su computadora, o descargue la aplicación ente para móvil/escritorio.",
"DRAG_AND_DROP_HINT": "O arrastre y suelte en la ventana ente", "DRAG_AND_DROP_HINT": "O arrastre y suelte en la ventana ente",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Obtenir 2 mois gratuits sur les plans annuels", "TWO_MONTHS_FREE": "Obtenir 2 mois gratuits sur les plans annuels",
"POPULAR": "Populaire", "POPULAR": "Populaire",
"FREE_PLAN_OPTION_LABEL": "Poursuivre avec la version d'essai gratuite", "FREE_PLAN_OPTION_LABEL": "Poursuivre avec la version d'essai gratuite",
"FREE_PLAN_DESCRIPTION": "1 Go pour 1 an", "free_plan_description": "{{storage}} pour 1 an",
"CURRENT_USAGE": "L'utilisation actuelle est de <strong>{{usage}}</strong>", "CURRENT_USAGE": "L'utilisation actuelle est de <strong>{{usage}}</strong>",
"WEAK_DEVICE": "Le navigateur que vous utilisez n'est pas assez puissant pour chiffrer vos photos. Veuillez essayer de vous connecter à Ente sur votre ordinateur, ou télécharger l'appli Ente mobile/ordinateur.", "WEAK_DEVICE": "Le navigateur que vous utilisez n'est pas assez puissant pour chiffrer vos photos. Veuillez essayer de vous connecter à Ente sur votre ordinateur, ou télécharger l'appli Ente mobile/ordinateur.",
"DRAG_AND_DROP_HINT": "Sinon glissez déposez dans la fenêtre Ente", "DRAG_AND_DROP_HINT": "Sinon glissez déposez dans la fenêtre Ente",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Ottieni 2 mesi gratis sui piani annuali", "TWO_MONTHS_FREE": "Ottieni 2 mesi gratis sui piani annuali",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "1 GB per 1 anno", "free_plan_description": "{{storage}} per 1 anno",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Krijg 2 maanden gratis op jaarlijkse abonnementen", "TWO_MONTHS_FREE": "Krijg 2 maanden gratis op jaarlijkse abonnementen",
"POPULAR": "Populair", "POPULAR": "Populair",
"FREE_PLAN_OPTION_LABEL": "Doorgaan met gratis account", "FREE_PLAN_OPTION_LABEL": "Doorgaan met gratis account",
"FREE_PLAN_DESCRIPTION": "1 GB voor 1 jaar", "free_plan_description": "{{storage}} voor 1 jaar",
"CURRENT_USAGE": "Huidig gebruik is <strong>{{usage}}</strong>", "CURRENT_USAGE": "Huidig gebruik is <strong>{{usage}}</strong>",
"WEAK_DEVICE": "De webbrowser die u gebruikt is niet krachtig genoeg om uw foto's te versleutelen. Probeer in te loggen op uw computer, of download de Ente mobiel/desktop app.", "WEAK_DEVICE": "De webbrowser die u gebruikt is niet krachtig genoeg om uw foto's te versleutelen. Probeer in te loggen op uw computer, of download de Ente mobiel/desktop app.",
"DRAG_AND_DROP_HINT": "Of sleep en plaats in het Ente venster", "DRAG_AND_DROP_HINT": "Of sleep en plaats in het Ente venster",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Obtenha 2 meses gratuitos em planos anuais", "TWO_MONTHS_FREE": "Obtenha 2 meses gratuitos em planos anuais",
"POPULAR": "Popular", "POPULAR": "Popular",
"FREE_PLAN_OPTION_LABEL": "Continuar com teste gratuito", "FREE_PLAN_OPTION_LABEL": "Continuar com teste gratuito",
"FREE_PLAN_DESCRIPTION": "1 GB por 1 ano", "free_plan_description": "{{storage}} por 1 ano",
"CURRENT_USAGE": "O uso atual é <strong>{{usage}}</strong>", "CURRENT_USAGE": "O uso atual é <strong>{{usage}}</strong>",
"WEAK_DEVICE": "O navegador da web que você está usando não é poderoso o suficiente para criptografar suas fotos. Por favor, tente entrar para o ente no computador ou baixe o aplicativo móvel.", "WEAK_DEVICE": "O navegador da web que você está usando não é poderoso o suficiente para criptografar suas fotos. Por favor, tente entrar para o ente no computador ou baixe o aplicativo móvel.",
"DRAG_AND_DROP_HINT": "Ou arraste e solte na janela ente", "DRAG_AND_DROP_HINT": "Ou arraste e solte na janela ente",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "Получите 2 месяца бесплатно по годовым планам", "TWO_MONTHS_FREE": "Получите 2 месяца бесплатно по годовым планам",
"POPULAR": "Популярный", "POPULAR": "Популярный",
"FREE_PLAN_OPTION_LABEL": "Продолжайте пользоваться бесплатной пробной версией", "FREE_PLAN_OPTION_LABEL": "Продолжайте пользоваться бесплатной пробной версией",
"FREE_PLAN_DESCRIPTION": "1 ГБ на 1 год", "free_plan_description": "{{storage}} на 1 год",
"CURRENT_USAGE": "Текущее использование составляет <strong>{{usage}}</strong>", "CURRENT_USAGE": "Текущее использование составляет <strong>{{usage}}</strong>",
"WEAK_DEVICE": "Используемый вами веб-браузер недостаточно мощный, чтобы зашифровать ваши фотографии. Пожалуйста, попробуйте войти в Ente на своем компьютере или загрузить мобильное/настольное приложение Ente.", "WEAK_DEVICE": "Используемый вами веб-браузер недостаточно мощный, чтобы зашифровать ваши фотографии. Пожалуйста, попробуйте войти в Ente на своем компьютере или загрузить мобильное/настольное приложение Ente.",
"DRAG_AND_DROP_HINT": "Или перетащите в основное окно", "DRAG_AND_DROP_HINT": "Или перетащите в основное окно",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "", "TWO_MONTHS_FREE": "",
"POPULAR": "", "POPULAR": "",
"FREE_PLAN_OPTION_LABEL": "", "FREE_PLAN_OPTION_LABEL": "",
"FREE_PLAN_DESCRIPTION": "", "free_plan_description": "",
"CURRENT_USAGE": "", "CURRENT_USAGE": "",
"WEAK_DEVICE": "", "WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "", "DRAG_AND_DROP_HINT": "",

View file

@ -449,7 +449,7 @@
"TWO_MONTHS_FREE": "在年度计划上免费获得 2 个月", "TWO_MONTHS_FREE": "在年度计划上免费获得 2 个月",
"POPULAR": "流行的", "POPULAR": "流行的",
"FREE_PLAN_OPTION_LABEL": "继续免费试用", "FREE_PLAN_OPTION_LABEL": "继续免费试用",
"FREE_PLAN_DESCRIPTION": "1 GB 1年", "free_plan_description": "{{storage}} 1年",
"CURRENT_USAGE": "当前使用量是 <strong>{{usage}}</strong>", "CURRENT_USAGE": "当前使用量是 <strong>{{usage}}</strong>",
"WEAK_DEVICE": "您使用的网络浏览器功能不够强大,无法加密您的照片。 请尝试在电脑上登录Ente或下载Ente移动/桌面应用程序。", "WEAK_DEVICE": "您使用的网络浏览器功能不够强大,无法加密您的照片。 请尝试在电脑上登录Ente或下载Ente移动/桌面应用程序。",
"DRAG_AND_DROP_HINT": "或者拖动并拖动到 Ente 窗口", "DRAG_AND_DROP_HINT": "或者拖动并拖动到 Ente 窗口",

View file

@ -7,7 +7,6 @@ export enum LS_KEYS {
ORIGINAL_KEY_ATTRIBUTES = "originalKeyAttributes", ORIGINAL_KEY_ATTRIBUTES = "originalKeyAttributes",
SUBSCRIPTION = "subscription", SUBSCRIPTION = "subscription",
FAMILY_DATA = "familyData", FAMILY_DATA = "familyData",
PLANS = "plans",
IS_FIRST_LOGIN = "isFirstLogin", IS_FIRST_LOGIN = "isFirstLogin",
JUST_SIGNED_UP = "justSignedUp", JUST_SIGNED_UP = "justSignedUp",
SHOW_BACK_BUTTON = "showBackButton", SHOW_BACK_BUTTON = "showBackButton",