mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
feat(core): pricing plans actions (#4724)
This commit is contained in:
+72
-17
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
||||
+19
-13
@@ -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}
|
||||
|
||||
+119
@@ -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>
|
||||
);
|
||||
};
|
||||
+276
-107
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+32
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user