mirror of
https://github.com/binwiederhier/ntfy.git
synced 2024-10-02 05:04:08 +02:00
338cab1660
In many languages there is more than one plural form of nouns and rules for choosing the correct one are often far more complex than in English. Luckily both react-i18next and Weblate provide built-in support for translating and selecting plural forms in accordance with grammatical rules of any given language. In order to enable plural forms `{count: n}` option is added to relevant `t()` calls. In translations files "_one" and "_other" suffix is added to English labels such that Weblate can detect which entries represent a set of plural forms and show appropriate language-specific form on the translation page. E.g. in Polish there are 2 plural forms and hence 3 resulting suffixes: "_one", "_few", "_many". Note on transition period: in the absence of expected suffixed variants react-i18next will use non-suffixed one (if present) so existing translations will continue to work just fine even if they happen to be grammatically imperfect. Translators can provide proper plural forms in once this change is merged and Weblate will then replace non-suffixed labels with the suffixed ones.
366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
import * as React from 'react';
|
|
import {useContext, useEffect, useState} from 'react';
|
|
import Dialog from '@mui/material/Dialog';
|
|
import DialogContent from '@mui/material/DialogContent';
|
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material";
|
|
import theme from "./theme";
|
|
import Button from "@mui/material/Button";
|
|
import accountApi, {SubscriptionInterval} from "../app/AccountApi";
|
|
import session from "../app/Session";
|
|
import routes from "./routes";
|
|
import Card from "@mui/material/Card";
|
|
import Typography from "@mui/material/Typography";
|
|
import {AccountContext} from "./App";
|
|
import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils";
|
|
import {Trans, useTranslation} from "react-i18next";
|
|
import List from "@mui/material/List";
|
|
import {Check, Close} from "@mui/icons-material";
|
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
|
import ListItemText from "@mui/material/ListItemText";
|
|
import Box from "@mui/material/Box";
|
|
import {NavLink} from "react-router-dom";
|
|
import {UnauthorizedError} from "../app/errors";
|
|
import DialogContentText from "@mui/material/DialogContentText";
|
|
import DialogActions from "@mui/material/DialogActions";
|
|
|
|
const UpgradeDialog = (props) => {
|
|
const { t } = useTranslation();
|
|
const { account } = useContext(AccountContext); // May be undefined!
|
|
const [error, setError] = useState("");
|
|
const [tiers, setTiers] = useState(null);
|
|
const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR);
|
|
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
|
|
const [loading, setLoading] = useState(false);
|
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
|
|
|
useEffect(() => {
|
|
const fetchTiers = async () => {
|
|
setTiers(await accountApi.billingTiers());
|
|
}
|
|
fetchTiers(); // Dangle
|
|
}, []);
|
|
|
|
if (!tiers) {
|
|
return <></>;
|
|
}
|
|
|
|
const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier})));
|
|
const newTier = tiersMap[newTierCode]; // May be undefined
|
|
const currentTier = account?.tier; // May be undefined
|
|
const currentInterval = account?.billing?.interval; // May be undefined
|
|
const currentTierCode = currentTier?.code; // May be undefined
|
|
|
|
// Figure out buttons, labels and the submit action
|
|
let submitAction, submitButtonLabel, banner;
|
|
if (!account) {
|
|
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
|
|
submitAction = Action.REDIRECT_SIGNUP;
|
|
banner = null;
|
|
} else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) {
|
|
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
|
|
submitAction = null;
|
|
banner = (currentTierCode) ? Banner.PRORATION_INFO : null;
|
|
} else if (!currentTierCode) {
|
|
submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
|
|
submitAction = Action.CREATE_SUBSCRIPTION;
|
|
banner = null;
|
|
} else if (!newTierCode) {
|
|
submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
|
|
submitAction = Action.CANCEL_SUBSCRIPTION;
|
|
banner = Banner.CANCEL_WARNING;
|
|
} else {
|
|
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
|
|
submitAction = Action.UPDATE_SUBSCRIPTION;
|
|
banner = Banner.PRORATION_INFO;
|
|
}
|
|
|
|
// Exceptional conditions
|
|
if (loading) {
|
|
submitAction = null;
|
|
} else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) {
|
|
submitAction = null;
|
|
banner = Banner.RESERVATIONS_WARNING;
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (submitAction === Action.REDIRECT_SIGNUP) {
|
|
window.location.href = routes.signup;
|
|
return;
|
|
}
|
|
try {
|
|
setLoading(true);
|
|
if (submitAction === Action.CREATE_SUBSCRIPTION) {
|
|
const response = await accountApi.createBillingSubscription(newTierCode, interval);
|
|
window.location.href = response.redirect_url;
|
|
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
|
|
await accountApi.updateBillingSubscription(newTierCode, interval);
|
|
} else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
|
|
await accountApi.deleteBillingSubscription();
|
|
}
|
|
props.onCancel();
|
|
} catch (e) {
|
|
console.log(`[UpgradeDialog] Error changing billing subscription`, e);
|
|
if (e instanceof UnauthorizedError) {
|
|
session.resetAndRedirect(routes.login);
|
|
} else {
|
|
setError(e.message);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Figure out discount
|
|
let discount = 0, upto = false;
|
|
if (newTier?.prices) {
|
|
discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100);
|
|
} else {
|
|
let n = 0;
|
|
for (const t of tiers) {
|
|
if (t.prices) {
|
|
const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100);
|
|
if (tierDiscount > discount) {
|
|
discount = tierDiscount;
|
|
n++;
|
|
}
|
|
}
|
|
}
|
|
upto = n > 1;
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={props.open}
|
|
onClose={props.onCancel}
|
|
maxWidth="lg"
|
|
fullScreen={fullScreen}
|
|
>
|
|
<DialogTitle>
|
|
<div style={{ display: "flex", flexDirection: "row" }}>
|
|
<div style={{ flexGrow: 1 }}>{t("account_upgrade_dialog_title")}</div>
|
|
<div style={{
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
marginTop: "4px"
|
|
}}>
|
|
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_monthly")}</Typography>
|
|
<Switch
|
|
checked={interval === SubscriptionInterval.YEAR}
|
|
onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)}
|
|
/>
|
|
<Typography component="span" variant="subtitle1">{t("account_upgrade_dialog_interval_yearly")}</Typography>
|
|
{discount > 0 &&
|
|
<Chip
|
|
label={upto ? t("account_upgrade_dialog_interval_yearly_discount_save_up_to", { discount: discount }) : t("account_upgrade_dialog_interval_yearly_discount_save", { discount: discount })}
|
|
color="primary"
|
|
size="small"
|
|
variant={interval === SubscriptionInterval.YEAR ? "filled" : "outlined"}
|
|
sx={{ marginLeft: "5px" }}
|
|
/>
|
|
}
|
|
</div>
|
|
</div>
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<div style={{
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
marginBottom: "8px",
|
|
width: "100%"
|
|
}}>
|
|
{tiers.map(tier =>
|
|
<TierCard
|
|
key={`tierCard${tier.code || '_free'}`}
|
|
tier={tier}
|
|
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
|
|
selected={newTierCode === tier.code} // tier.code may be undefined!
|
|
interval={interval}
|
|
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
|
|
/>
|
|
)}
|
|
</div>
|
|
{banner === Banner.CANCEL_WARNING &&
|
|
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
|
|
<Trans
|
|
i18nKey="account_upgrade_dialog_cancel_warning"
|
|
values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} />
|
|
</Alert>
|
|
}
|
|
{banner === Banner.PRORATION_INFO &&
|
|
<Alert severity="info" sx={{ fontSize: "1rem" }}>
|
|
<Trans i18nKey="account_upgrade_dialog_proration_info" />
|
|
</Alert>
|
|
}
|
|
{banner === Banner.RESERVATIONS_WARNING &&
|
|
<Alert severity="warning" sx={{ fontSize: "1rem" }}>
|
|
<Trans
|
|
i18nKey="account_upgrade_dialog_reservations_warning"
|
|
count={account?.reservations.length - newTier?.limits.reservations}
|
|
components={{
|
|
Link: <NavLink to={routes.settings}/>,
|
|
}}
|
|
/>
|
|
</Alert>
|
|
}
|
|
</DialogContent>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
paddingLeft: '24px',
|
|
paddingBottom: '8px',
|
|
}}>
|
|
<DialogContentText
|
|
component="div"
|
|
aria-live="polite"
|
|
sx={{
|
|
margin: '0px',
|
|
paddingTop: '12px',
|
|
paddingBottom: '4px'
|
|
}}
|
|
>
|
|
{config.billing_contact.indexOf('@') !== -1 &&
|
|
<><Trans i18nKey="account_upgrade_dialog_billing_contact_email" components={{ Link: <Link href={`mailto:${config.billing_contact}`}/> }}/>{" "}</>
|
|
}
|
|
{config.billing_contact.match(`^http?s://`) &&
|
|
<><Trans i18nKey="account_upgrade_dialog_billing_contact_website" components={{ Link: <Link href={config.billing_contact} target="_blank"/> }}/>{" "}</>
|
|
}
|
|
{error}
|
|
</DialogContentText>
|
|
<DialogActions sx={{paddingRight: 2}}>
|
|
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
|
|
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
|
|
</DialogActions>
|
|
</Box>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const TierCard = (props) => {
|
|
const { t } = useTranslation();
|
|
const tier = props.tier;
|
|
|
|
let cardStyle, labelStyle, labelText;
|
|
if (props.selected) {
|
|
cardStyle = { background: "#eee", border: "3px solid #338574" };
|
|
labelStyle = { background: "#338574", color: "white" };
|
|
labelText = t("account_upgrade_dialog_tier_selected_label");
|
|
} else if (props.current) {
|
|
cardStyle = { border: "3px solid #eee" };
|
|
labelStyle = { background: "#eee", color: "black" };
|
|
labelText = t("account_upgrade_dialog_tier_current_label");
|
|
} else {
|
|
cardStyle = { border: "3px solid transparent" };
|
|
}
|
|
|
|
let monthlyPrice;
|
|
if (!tier.prices) {
|
|
monthlyPrice = 0;
|
|
} else if (props.interval === SubscriptionInterval.YEAR) {
|
|
monthlyPrice = tier.prices.year/12;
|
|
} else if (props.interval === SubscriptionInterval.MONTH) {
|
|
monthlyPrice = tier.prices.month;
|
|
}
|
|
|
|
return (
|
|
<Box sx={{
|
|
m: "7px",
|
|
minWidth: "240px",
|
|
flexGrow: 1,
|
|
flexShrink: 1,
|
|
flexBasis: 0,
|
|
borderRadius: "5px",
|
|
"&:first-of-type": { ml: 0 },
|
|
"&:last-of-type": { mr: 0 },
|
|
...cardStyle
|
|
}}>
|
|
<Card sx={{ height: "100%" }}>
|
|
<CardActionArea sx={{ height: "100%" }}>
|
|
<CardContent onClick={props.onClick} sx={{ height: "100%" }}>
|
|
{labelStyle &&
|
|
<div style={{
|
|
position: "absolute",
|
|
top: "0",
|
|
right: "15px",
|
|
padding: "2px 10px",
|
|
borderRadius: "3px",
|
|
...labelStyle
|
|
}}>{labelText}</div>
|
|
}
|
|
<Typography variant="subtitle1" component="div">
|
|
{tier.name || t("account_basics_tier_free")}
|
|
</Typography>
|
|
<div>
|
|
<Typography component="span" variant="h4" sx={{ fontWeight: 500, marginRight: "3px" }}>{formatPrice(monthlyPrice)}</Typography>
|
|
{monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}</>}
|
|
</div>
|
|
<List dense>
|
|
{tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
|
|
{tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
|
|
<Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
|
|
<Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
|
|
<Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
|
|
<Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature>
|
|
</List>
|
|
{tier.prices && props.interval === SubscriptionInterval.MONTH &&
|
|
<Typography variant="body2" color="gray">
|
|
{t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })}
|
|
</Typography>
|
|
}
|
|
{tier.prices && props.interval === SubscriptionInterval.YEAR &&
|
|
<Typography variant="body2" color="gray">
|
|
{t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })}
|
|
</Typography>
|
|
}
|
|
</CardContent>
|
|
</CardActionArea>
|
|
</Card>
|
|
</Box>
|
|
|
|
);
|
|
}
|
|
|
|
const Feature = (props) => {
|
|
return <FeatureItem feature={true}>{props.children}</FeatureItem>;
|
|
}
|
|
|
|
const NoFeature = (props) => {
|
|
return <FeatureItem feature={false}>{props.children}</FeatureItem>;
|
|
}
|
|
|
|
const FeatureItem = (props) => {
|
|
return (
|
|
<ListItem disableGutters sx={{m: 0, p: 0}}>
|
|
<ListItemIcon sx={{minWidth: "24px"}}>
|
|
{props.feature && <Check fontSize="small" sx={{ color: "#338574" }}/>}
|
|
{!props.feature && <Close fontSize="small" sx={{ color: "gray" }}/>}
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
sx={{mt: "2px", mb: "2px"}}
|
|
primary={
|
|
<Typography variant="body1">
|
|
{props.children}
|
|
</Typography>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
|
|
);
|
|
};
|
|
|
|
const Action = {
|
|
REDIRECT_SIGNUP: 1,
|
|
CREATE_SUBSCRIPTION: 2,
|
|
UPDATE_SUBSCRIPTION: 3,
|
|
CANCEL_SUBSCRIPTION: 4
|
|
};
|
|
|
|
const Banner = {
|
|
CANCEL_WARNING: 1,
|
|
PRORATION_INFO: 2,
|
|
RESERVATIONS_WARNING: 3
|
|
};
|
|
|
|
export default UpgradeDialog;
|