mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(core): pricing plans actions (#4724)
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { RadioButton, RadioButtonGroup } from '@affine/component';
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import {
|
||||
pricesQuery,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useQuery } from '@affine/workspace/affine/gql';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
@@ -14,10 +18,25 @@ import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card';
|
||||
import { PlansSkeleton } from './skeleton';
|
||||
import * as styles from './style.css';
|
||||
|
||||
const getRecurringLabel = ({
|
||||
recurring,
|
||||
t,
|
||||
}: {
|
||||
recurring: SubscriptionRecurring;
|
||||
t: ReturnType<typeof useAFFiNEI18N>;
|
||||
}) => {
|
||||
return recurring === SubscriptionRecurring.Monthly
|
||||
? t['com.affine.payment.recurring-monthly']()
|
||||
: t['com.affine.payment.recurring-yearly']();
|
||||
};
|
||||
|
||||
const Settings = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [subscription, mutateSubscription] = useUserSubscription();
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const planDetail = getPlanDetail();
|
||||
const planDetail = getPlanDetail(t);
|
||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
@@ -44,6 +63,9 @@ const Settings = () => {
|
||||
);
|
||||
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const isCanceled = !!subscription?.canceledAt;
|
||||
const currentRecurring =
|
||||
subscription?.recurring ?? SubscriptionRecurring.Monthly;
|
||||
|
||||
const yearlyDiscount = (
|
||||
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
|
||||
@@ -76,23 +98,39 @@ const Settings = () => {
|
||||
}, [recurring]);
|
||||
|
||||
const subtitle = loggedIn ? (
|
||||
<p>
|
||||
You are current on the {currentPlan} plan. If you have any questions,
|
||||
please contact our <span>{/*TODO: add action*/}customer support</span>.
|
||||
</p>
|
||||
isCanceled ? (
|
||||
<p>
|
||||
{t['com.affine.payment.subtitle-canceled']({
|
||||
plan: `${getRecurringLabel({
|
||||
recurring: currentRecurring,
|
||||
t,
|
||||
})} ${currentPlan}`,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Trans
|
||||
plan={currentPlan}
|
||||
i18nKey="com.affine.payment.subtitle-active"
|
||||
values={{ currentPlan }}
|
||||
>
|
||||
You are current on the {{ currentPlan }} plan. If you have any
|
||||
questions, please contact our
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
style={{ color: 'var(--affine-link-color)' }}
|
||||
>
|
||||
customer support
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p>
|
||||
This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to
|
||||
your account first.
|
||||
</p>
|
||||
<p>{t['com.affine.payment.subtitle-not-signed-in']()}</p>
|
||||
);
|
||||
|
||||
const getRecurringLabel = (recurring: SubscriptionRecurring) =>
|
||||
({
|
||||
[SubscriptionRecurring.Monthly]: 'Monthly',
|
||||
[SubscriptionRecurring.Yearly]: 'Annually',
|
||||
})[recurring];
|
||||
|
||||
const tabs = (
|
||||
<RadioButtonGroup
|
||||
className={styles.recurringRadioGroup}
|
||||
@@ -101,10 +139,12 @@ const Settings = () => {
|
||||
>
|
||||
{Object.values(SubscriptionRecurring).map(recurring => (
|
||||
<RadioButton key={recurring} value={recurring}>
|
||||
{getRecurringLabel(recurring)}
|
||||
{getRecurringLabel({ recurring, t })}
|
||||
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||
<span className={styles.radioButtonDiscount}>
|
||||
{yearlyDiscount}% off
|
||||
{t['com.affine.payment.discount-amount']({
|
||||
amount: yearlyDiscount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</RadioButton>
|
||||
@@ -119,6 +159,21 @@ const Settings = () => {
|
||||
<PlanCard
|
||||
key={detail.plan}
|
||||
onSubscriptionUpdate={mutateSubscription}
|
||||
onNotify={({ detail, recurring }) => {
|
||||
pushNotification({
|
||||
type: 'success',
|
||||
title: t['com.affine.payment.updated-notify-title'](),
|
||||
message: t['com.affine.payment.updated-notify-msg']({
|
||||
plan:
|
||||
detail.plan === SubscriptionPlan.Free
|
||||
? SubscriptionPlan.Free
|
||||
: getRecurringLabel({
|
||||
recurring: recurring as SubscriptionRecurring,
|
||||
t,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
{...{ detail, subscription, recurring }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowRightBigIcon } from '@blocksuite/icons';
|
||||
import type { HtmlHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
@@ -14,32 +15,37 @@ export interface PlanLayoutProps
|
||||
scrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const SeeAllLink = () => (
|
||||
<a
|
||||
className={styles.allPlansLink}
|
||||
href="https://affine.pro/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See all plans
|
||||
{<ArrowRightBigIcon width="16" height="16" />}
|
||||
</a>
|
||||
);
|
||||
const SeeAllLink = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<a
|
||||
className={styles.allPlansLink}
|
||||
href="https://affine.pro/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t['com.affine.payment.see-all-plans']()}
|
||||
{<ArrowRightBigIcon width="16" height="16" />}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const PlanLayout = ({
|
||||
subtitle,
|
||||
tabs,
|
||||
scroll,
|
||||
title = 'Pricing Plans',
|
||||
title,
|
||||
footer = <SeeAllLink />,
|
||||
scrollRef,
|
||||
}: PlanLayoutProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.plansLayoutRoot}>
|
||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||
<SettingHeader
|
||||
style={{ marginBottom: '0px' }}
|
||||
title={title}
|
||||
title={title ?? t['com.affine.payment.title']()}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
{tabs}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import {
|
||||
ConfirmModal,
|
||||
type ConfirmModalProps,
|
||||
Modal,
|
||||
} from '@toeverything/components/modal';
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import * as styles from './style.css';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export const ConfirmLoadingModal = ({
|
||||
type,
|
||||
loading,
|
||||
open,
|
||||
content,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
...props
|
||||
}: {
|
||||
type: 'resume' | 'change';
|
||||
loading?: boolean;
|
||||
content?: ReactNode;
|
||||
} & ConfirmModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const confirmed = useRef(false);
|
||||
|
||||
const title = t[`com.affine.payment.modal.${type}.title`]();
|
||||
const confirmText = t[`com.affine.payment.modal.${type}.confirm`]();
|
||||
const cancelText = t[`com.affine.payment.modal.${type}.cancel`]();
|
||||
const contentText =
|
||||
type !== 'change' ? t[`com.affine.payment.modal.${type}.content`]() : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && open && confirmed.current) {
|
||||
onOpenChange?.(false);
|
||||
confirmed.current = false;
|
||||
}
|
||||
}, [loading, open, onOpenChange]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={title}
|
||||
cancelText={cancelText}
|
||||
confirmButtonOptions={{
|
||||
type: 'primary',
|
||||
children: confirmText,
|
||||
loading,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirm={() => {
|
||||
confirmed.current = true;
|
||||
onConfirm?.();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{content ?? contentText}
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Downgrade modal, confirm & cancel button are reversed
|
||||
* @param param0
|
||||
*/
|
||||
export const DowngradeModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCancel,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t['com.affine.payment.modal.downgrade.title']()}
|
||||
open={open}
|
||||
contentOptions={{}}
|
||||
width={480}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className={styles.downgradeContentWrapper}>
|
||||
<p className={styles.downgradeContent}>
|
||||
{t['com.affine.payment.modal.downgrade.content']()}
|
||||
</p>
|
||||
<p className={styles.downgradeCaption}>
|
||||
{t['com.affine.payment.modal.downgrade.caption']()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className={styles.downgradeFooter}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange?.(false);
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
{t['com.affine.payment.modal.downgrade.cancel']()}
|
||||
</Button>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => onOpenChange?.(false)} type="primary">
|
||||
{t['com.affine.payment.modal.downgrade.confirm']()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -5,19 +5,32 @@ import type {
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
checkoutMutation,
|
||||
resumeSubscriptionMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation } from '@affine/workspace/affine/gql';
|
||||
import { DoneIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtom } from 'jotai';
|
||||
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { openPaymentDisableAtom } from '../../../../../atoms';
|
||||
import { authAtom } from '../../../../../atoms/index';
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import { BulledListIcon } from './icons/bulled-list';
|
||||
import { ConfirmLoadingModal, DowngradeModal } from './modals';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export interface FixedPrice {
|
||||
@@ -41,12 +54,13 @@ interface PlanCardProps {
|
||||
subscription?: Subscription | null;
|
||||
recurring: string;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
onNotify: (info: {
|
||||
detail: FixedPrice | DynamicPrice;
|
||||
recurring: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function getPlanDetail() {
|
||||
// const t = useAFFiNEI18N();
|
||||
|
||||
// TODO: i18n all things
|
||||
export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
|
||||
return new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
|
||||
[
|
||||
SubscriptionPlan.Free,
|
||||
@@ -56,12 +70,12 @@ export function getPlanDetail() {
|
||||
price: '0',
|
||||
yearlyPrice: '0',
|
||||
benefits: [
|
||||
'Unlimited local workspace',
|
||||
'Unlimited login devices',
|
||||
'Unlimited blocks',
|
||||
'AFFiNE Cloud Storage 10GB',
|
||||
'The maximum file size is 10M',
|
||||
'Number of members per Workspace ≤ 3',
|
||||
t['com.affine.payment.benefit-1'](),
|
||||
t['com.affine.payment.benefit-2'](),
|
||||
t['com.affine.payment.benefit-3'](),
|
||||
t['com.affine.payment.benefit-4']({ capacity: '10GB' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '10M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '3' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -73,12 +87,12 @@ export function getPlanDetail() {
|
||||
price: '1',
|
||||
yearlyPrice: '1',
|
||||
benefits: [
|
||||
'Unlimited local workspace',
|
||||
'Unlimited login devices',
|
||||
'Unlimited blocks',
|
||||
'AFFiNE Cloud Storage 100GB',
|
||||
'The maximum file size is 500M',
|
||||
'Number of members per Workspace ≤ 10',
|
||||
t['com.affine.payment.benefit-1'](),
|
||||
t['com.affine.payment.benefit-2'](),
|
||||
t['com.affine.payment.benefit-3'](),
|
||||
t['com.affine.payment.benefit-4']({ capacity: '100GB' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '500M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '10' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -89,9 +103,9 @@ export function getPlanDetail() {
|
||||
plan: SubscriptionPlan.Team,
|
||||
contact: true,
|
||||
benefits: [
|
||||
'Best team workspace for collaboration and knowledge distilling.',
|
||||
'Focusing on what really matters with team project management and automation.',
|
||||
'Pay for seats, fits all team size.',
|
||||
t['com.affine.payment.dynamic-benefit-1'](),
|
||||
t['com.affine.payment.dynamic-benefit-2'](),
|
||||
t['com.affine.payment.dynamic-benefit-3'](),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -102,20 +116,16 @@ export function getPlanDetail() {
|
||||
plan: SubscriptionPlan.Enterprise,
|
||||
contact: true,
|
||||
benefits: [
|
||||
'Solutions & best practices for dedicated needs.',
|
||||
'Embedable & interrogations with IT support.',
|
||||
t['com.affine.payment.dynamic-benefit-4'](),
|
||||
t['com.affine.payment.dynamic-benefit-5'](),
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export const PlanCard = ({
|
||||
detail,
|
||||
subscription,
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
}: PlanCardProps) => {
|
||||
export const PlanCard = (props: PlanCardProps) => {
|
||||
const { detail, subscription, recurring } = props;
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const currentRecurring = subscription?.recurring;
|
||||
@@ -160,49 +170,7 @@ export const PlanCard = ({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{
|
||||
// branches:
|
||||
// if contact => 'Contact Sales'
|
||||
// if not signed in:
|
||||
// if free => 'Sign up free'
|
||||
// else => 'Buy Pro'
|
||||
// else
|
||||
// if isCurrent => 'Current Plan'
|
||||
// else if free => 'Downgrade'
|
||||
// else if currentRecurring !== recurring => 'Change to {recurring} Billing'
|
||||
// else => 'Upgrade'
|
||||
// TODO: should replace with components with proper actions
|
||||
detail.type === 'dynamic' ? (
|
||||
<ContactSales />
|
||||
) : loggedIn ? (
|
||||
detail.plan === currentPlan &&
|
||||
(currentRecurring === recurring ||
|
||||
(!currentRecurring && detail.plan === SubscriptionPlan.Free)) ? (
|
||||
<CurrentPlan />
|
||||
) : detail.plan === SubscriptionPlan.Free ? (
|
||||
<Downgrade onSubscriptionUpdate={onSubscriptionUpdate} />
|
||||
) : currentRecurring !== recurring &&
|
||||
currentPlan === detail.plan ? (
|
||||
<ChangeRecurring
|
||||
// @ts-expect-error must exist
|
||||
from={currentRecurring}
|
||||
to={recurring as SubscriptionRecurring}
|
||||
onSubscriptionUpdate={onSubscriptionUpdate}
|
||||
/>
|
||||
) : (
|
||||
<Upgrade
|
||||
recurring={recurring as SubscriptionRecurring}
|
||||
onSubscriptionUpdate={onSubscriptionUpdate}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SignupAction>
|
||||
{detail.plan === SubscriptionPlan.Free
|
||||
? 'Sign up free'
|
||||
: 'Buy Pro'}
|
||||
</SignupAction>
|
||||
)
|
||||
}
|
||||
<ActionButton {...props} />
|
||||
</div>
|
||||
<div className={styles.planBenefits}>
|
||||
{detail.benefits.map((content, i) => (
|
||||
@@ -226,15 +194,111 @@ export const PlanCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButton = ({
|
||||
detail,
|
||||
subscription,
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
onNotify,
|
||||
}: PlanCardProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const currentRecurring = subscription?.recurring;
|
||||
|
||||
const mutateAndNotify = useCallback(
|
||||
(sub: Parameters<SubscriptionMutator>[0]) => {
|
||||
onSubscriptionUpdate?.(sub);
|
||||
onNotify?.({ detail, recurring });
|
||||
},
|
||||
[onSubscriptionUpdate, onNotify, detail, recurring]
|
||||
);
|
||||
|
||||
// branches:
|
||||
// if contact => 'Contact Sales'
|
||||
// if not signed in:
|
||||
// if free => 'Sign up free'
|
||||
// else => 'Buy Pro'
|
||||
// else
|
||||
// if isCurrent
|
||||
// if canceled => 'Resume'
|
||||
// else => 'Current Plan'
|
||||
// if isCurrent => 'Current Plan'
|
||||
// else if free => 'Downgrade'
|
||||
// else if currentRecurring !== recurring => 'Change to {recurring} Billing'
|
||||
// else => 'Upgrade'
|
||||
|
||||
// contact
|
||||
if (detail.type === 'dynamic') {
|
||||
return <ContactSales />;
|
||||
}
|
||||
|
||||
// not signed in
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<SignUpAction>
|
||||
{detail.plan === SubscriptionPlan.Free
|
||||
? t['com.affine.payment.sign-up-free']()
|
||||
: t['com.affine.payment.buy-pro']()}
|
||||
</SignUpAction>
|
||||
);
|
||||
}
|
||||
|
||||
const isCanceled = !!subscription?.canceledAt;
|
||||
const isFree = detail.plan === SubscriptionPlan.Free;
|
||||
const isCurrent =
|
||||
detail.plan === currentPlan &&
|
||||
(isFree ? true : currentRecurring === recurring);
|
||||
|
||||
// is current
|
||||
if (isCurrent) {
|
||||
return isCanceled ? (
|
||||
<ResumeAction onSubscriptionUpdate={mutateAndNotify} />
|
||||
) : (
|
||||
<CurrentPlan />
|
||||
);
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
return (
|
||||
<Downgrade disabled={isCanceled} onSubscriptionUpdate={mutateAndNotify} />
|
||||
);
|
||||
}
|
||||
|
||||
return currentPlan === detail.plan ? (
|
||||
<ChangeRecurring
|
||||
from={currentRecurring as SubscriptionRecurring}
|
||||
to={recurring as SubscriptionRecurring}
|
||||
due={subscription?.nextBillAt || ''}
|
||||
onSubscriptionUpdate={mutateAndNotify}
|
||||
disabled={isCanceled}
|
||||
/>
|
||||
) : (
|
||||
<Upgrade
|
||||
recurring={recurring as SubscriptionRecurring}
|
||||
onSubscriptionUpdate={mutateAndNotify}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentPlan = () => {
|
||||
return <Button className={styles.planAction}>Current Plan</Button>;
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<Button className={styles.planAction}>
|
||||
{t['com.affine.payment.current-plan']()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const Downgrade = ({
|
||||
disabled,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: cancelSubscriptionMutation,
|
||||
});
|
||||
@@ -247,25 +311,43 @@ const Downgrade = ({
|
||||
});
|
||||
}, [trigger, onSubscriptionUpdate]);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? t['com.affine.payment.downgraded-tooltip']()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={downgrade /* TODO: poppup confirmation modal instead */}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Downgrade
|
||||
</Button>
|
||||
<>
|
||||
<Tooltip content={tooltipContent} rootOptions={{ delayDuration: 0 }}>
|
||||
<div className={styles.planAction}>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled || isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.downgrade']()}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<DowngradeModal open={open} onCancel={downgrade} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactSales = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
// TODO: add action
|
||||
<Button className={styles.planAction} type="primary">
|
||||
Contact Sales
|
||||
</Button>
|
||||
<a
|
||||
className={styles.planAction}
|
||||
href="https://6dxre9ihosp.typeform.com/to/uZeBtpPm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button className={styles.planAction} type="primary">
|
||||
{t['com.affine.payment.contact-sales']()}
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -276,6 +358,7 @@ const Upgrade = ({
|
||||
recurring: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
});
|
||||
@@ -330,27 +413,35 @@ const Upgrade = ({
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={upgrade}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={upgrade}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.upgrade']()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeRecurring = ({
|
||||
from: _from /* TODO: from can be useful when showing confirmation modal */,
|
||||
from,
|
||||
to,
|
||||
disabled,
|
||||
due,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
from: SubscriptionRecurring;
|
||||
to: SubscriptionRecurring;
|
||||
disabled?: boolean;
|
||||
due: string;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: updateSubscriptionMutation,
|
||||
});
|
||||
@@ -366,24 +457,102 @@ const ChangeRecurring = ({
|
||||
);
|
||||
}, [trigger, onSubscriptionUpdate, to]);
|
||||
|
||||
const changeCurringContent = (
|
||||
<Trans values={{ from, to, due }} className={styles.downgradeContent}>
|
||||
You are changing your <span className={styles.textEmphasis}>{from}</span>{' '}
|
||||
subscription to <span className={styles.textEmphasis}>{to}</span>{' '}
|
||||
subscription. This change will take effect in the next billing cycle, with
|
||||
an effective date of <span className={styles.textEmphasis}>{due}</span>.
|
||||
</Trans>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={change /* TODO: popup confirmation modal instead */}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Change to {to} Billing
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled || isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
{t['com.affine.payment.change-to']({ to })}
|
||||
</Button>
|
||||
|
||||
<ConfirmLoadingModal
|
||||
type={'change'}
|
||||
loading={isMutating}
|
||||
open={open}
|
||||
onConfirm={change}
|
||||
onOpenChange={setOpen}
|
||||
content={changeCurringContent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SignupAction = ({ children }: PropsWithChildren) => {
|
||||
// TODO: add login action
|
||||
const SignUpAction = ({ children }: PropsWithChildren) => {
|
||||
const setOpen = useSetAtom(authAtom);
|
||||
|
||||
const onClickSignIn = useCallback(async () => {
|
||||
setOpen(state => ({
|
||||
...state,
|
||||
openModal: true,
|
||||
}));
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<Button className={styles.planAction} type="primary">
|
||||
<Button
|
||||
onClick={onClickSignIn}
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeAction = ({
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: resumeSubscriptionMutation,
|
||||
});
|
||||
|
||||
const resume = useCallback(() => {
|
||||
trigger(null, {
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.resumeSubscription);
|
||||
},
|
||||
});
|
||||
}, [trigger, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => setOpen(true)}
|
||||
loading={isMutating}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{hovered
|
||||
? t['com.affine.payment.resume-renewal']()
|
||||
: t['com.affine.payment.current-plan']()}
|
||||
</Button>
|
||||
|
||||
<ConfirmLoadingModal
|
||||
type={'resume'}
|
||||
open={open}
|
||||
onConfirm={resume}
|
||||
onOpenChange={setOpen}
|
||||
loading={isMutating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export const radioButtonDiscount = style({
|
||||
});
|
||||
|
||||
export const planCardsWrapper = style({
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x))',
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)',
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
width: 'fit-content',
|
||||
@@ -116,3 +116,34 @@ export const planBenefitText = style({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const downgradeContentWrapper = style({
|
||||
padding: '12px 0 20px 0px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const downgradeContent = style({
|
||||
fontSize: '15px',
|
||||
lineHeight: '24px',
|
||||
fontWeight: 400,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const downgradeCaption = style({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const downgradeFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '20px',
|
||||
paddingTop: '20px',
|
||||
});
|
||||
|
||||
export const textEmphasis = style({
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export const SettingModal = ({
|
||||
const generalSettingList = useGeneralSettingList();
|
||||
|
||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||
const modalContentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalProps.open) return;
|
||||
@@ -46,19 +47,18 @@ export const SettingModal = ({
|
||||
const onResize = debounce(() => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
if (!modalContentRef.current) return;
|
||||
if (!modalContentRef.current || !modalContentWrapperRef.current) return;
|
||||
|
||||
const wrapperWidth = modalContentWrapperRef.current.offsetWidth;
|
||||
const contentWidth = modalContentRef.current.offsetWidth;
|
||||
const computedStyle = window.getComputedStyle(modalContentRef.current);
|
||||
const marginX = parseInt(computedStyle.marginLeft, 10);
|
||||
const paddingX = parseInt(computedStyle.paddingLeft, 10);
|
||||
|
||||
modalContentRef.current?.style.setProperty(
|
||||
'--setting-modal-width',
|
||||
`${contentWidth + marginX * 2}px`
|
||||
`${wrapperWidth}px`
|
||||
);
|
||||
modalContentRef.current?.style.setProperty(
|
||||
'--setting-modal-gap-x',
|
||||
`${marginX + paddingX}px`
|
||||
`${(wrapperWidth - contentWidth) / 2}px`
|
||||
);
|
||||
});
|
||||
}, 200);
|
||||
@@ -121,33 +121,35 @@ export const SettingModal = ({
|
||||
<div
|
||||
data-testid="setting-modal-content"
|
||||
className={style.wrapper}
|
||||
ref={modalContentRef}
|
||||
ref={modalContentWrapperRef}
|
||||
>
|
||||
<div className={style.content}>
|
||||
{activeTab === 'workspace' && workspaceId ? (
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{generalSettingList.find(v => v.key === activeTab) ? (
|
||||
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
|
||||
) : null}
|
||||
{activeTab === 'account' && loginStatus === 'authenticated' ? (
|
||||
<AccountSetting />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="footer">
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
<div ref={modalContentRef} className={style.centerContainer}>
|
||||
<div className={style.content}>
|
||||
{activeTab === 'workspace' && workspaceId ? (
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{generalSettingList.find(v => v.key === activeTab) ? (
|
||||
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
|
||||
) : null}
|
||||
{activeTab === 'account' && loginStatus === 'authenticated' ? (
|
||||
<AccountSetting />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="footer">
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -3,21 +3,25 @@ import { style } from '@vanilla-extract/css';
|
||||
export const wrapper = style({
|
||||
flexGrow: '1',
|
||||
height: '100%',
|
||||
maxWidth: '560px',
|
||||
margin: '0 auto',
|
||||
padding: '40px 15px 20px 15px',
|
||||
|
||||
// children
|
||||
// margin: '0 auto',
|
||||
padding: '40px 15px 20px 15px',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
'::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export const centerContainer = style({
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
width: '100%',
|
||||
marginBottom: '24px',
|
||||
|
||||
@@ -642,9 +642,52 @@
|
||||
"com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.",
|
||||
"com.affine.auth.sign-out.confirm-modal.cancel": "Cancel",
|
||||
"com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out",
|
||||
"com.affine.payment.recurring-yearly": "Annually",
|
||||
"com.affine.payment.recurring-monthly": "Monthly",
|
||||
"com.affine.payment.title": "Pricing Plans",
|
||||
"com.affine.payment.subtitle-not-signed-in": "This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to your account first.",
|
||||
"com.affine.payment.subtitle-active": "You are current on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support</3>.",
|
||||
"com.affine.payment.subtitle-canceled": "You are currently on the {{plan}} plan. After the current billing period ends, your account will automatically switch to the Free plan.",
|
||||
"com.affine.payment.discount-amount": "{{amount}}% off",
|
||||
"com.affine.payment.sign-up-free": "Sign up free",
|
||||
"com.affine.payment.buy-pro": "Buy Pro",
|
||||
"com.affine.payment.current-plan": "Current Plan",
|
||||
"com.affine.payment.downgrade": "Downgrade",
|
||||
"com.affine.payment.upgrade": "Upgrade",
|
||||
"com.affine.payment.downgraded-tooltip": "You have successfully downgraded. After the current billing period ends, your account will automatically switch to the Free plan.",
|
||||
"com.affine.payment.contact-sales": "Contact Sales",
|
||||
"com.affine.payment.change-to": "Change to {{to}} Billing",
|
||||
"com.affine.payment.resume": "Resume",
|
||||
"com.affine.payment.resume-renewal": "Resume Auto-renewal",
|
||||
"com.affine.payment.benefit-1": "Unlimited local workspace",
|
||||
"com.affine.payment.benefit-2": "Unlimited login devices",
|
||||
"com.affine.payment.benefit-3": "Unlimited blocks",
|
||||
"com.affine.payment.benefit-4": "AFFiNE Cloud Storage {{capacity}}",
|
||||
"com.affine.payment.benefit-5": "The maximum file size is {{capacity}}",
|
||||
"com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}",
|
||||
"com.affine.payment.dynamic-benefit-1": "Best team workspace for collaboration and knowledge distilling.",
|
||||
"com.affine.payment.dynamic-benefit-2": "Focusing on what really matters with team project management and automation.",
|
||||
"com.affine.payment.dynamic-benefit-3": "Pay for seats, fits all team size.",
|
||||
"com.affine.payment.dynamic-benefit-4": "Solutions & best practices for dedicated needs.",
|
||||
"com.affine.payment.dynamic-benefit-5": "Embedable & interrogations with IT support.",
|
||||
"com.affine.payment.see-all-plans": "See all plans",
|
||||
"com.affine.payment.modal.resume.title": "Resume Auto-Renewal?",
|
||||
"com.affine.payment.modal.resume.content": "Are you sure you want to resume the subscription for your pro account? This means your payment method will be charged automatically at the end of each billing cycle, starting from the next billing cycle.",
|
||||
"com.affine.payment.modal.resume.cancel": "Cancel",
|
||||
"com.affine.payment.modal.resume.confirm": "Confirm",
|
||||
"com.affine.payment.modal.downgrade.title": "Are you sure?",
|
||||
"com.affine.payment.modal.downgrade.content": "We're sorry to see you go, but we're always working to improve, and your feedback is welcome. We hope to see you return in the future.",
|
||||
"com.affine.payment.modal.downgrade.caption": "You can still use AFFiNE Cloud Pro until the end of this billing period :)",
|
||||
"com.affine.payment.modal.downgrade.cancel": "Cancel Subscription",
|
||||
"com.affine.payment.modal.downgrade.confirm": "Keep AFFiNE Cloud Pro",
|
||||
"com.affine.payment.modal.change.title": "Change your subscription",
|
||||
"com.affine.payment.modal.change.content": "You are changing your <0>from</0> subscription to <1>to</1> subscription. This change will take effect in the next billing cycle, with an effective date of <2>due</2>.",
|
||||
"com.affine.payment.modal.change.cancel": "Cancel",
|
||||
"com.affine.payment.modal.change.confirm": "Change",
|
||||
"com.affine.payment.updated-notify-title": "Subscription updated",
|
||||
"com.affine.payment.updated-notify-msg": "You have changed your plan to {{plan}} billing.",
|
||||
"com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account",
|
||||
"com.affine.payment.tag-tooltips": "See all plans",
|
||||
"com.affine.payment.title": "Pricing Plans",
|
||||
"com.affine.payment.billing-setting.title": "Billing",
|
||||
"com.affine.payment.billing-setting.subtitle": "Manage your billing information and invoices.",
|
||||
"com.affine.payment.billing-setting.information": "Information",
|
||||
|
||||
Reference in New Issue
Block a user