mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-30 14:12:00 +03:00
feat(core): pricing plans ai subscription ui (#6449)
This commit is contained in:
parent
e7de20f648
commit
3e9e2ce93b
@ -248,6 +248,9 @@ type ServerConfigType {
|
||||
"""credentials requirement"""
|
||||
credentialsRequirement: CredentialsRequirementType!
|
||||
|
||||
"""enable telemetry"""
|
||||
enableTelemetry: Boolean!
|
||||
|
||||
"""enabled server features"""
|
||||
features: [ServerFeature!]!
|
||||
|
||||
|
@ -16,7 +16,7 @@ export const SettingHeader = ({
|
||||
return (
|
||||
<div className={settingHeader} {...otherProps}>
|
||||
<div className="title">{title}</div>
|
||||
<div className="subtitle">{subtitle}</div>
|
||||
{subtitle ? <div className="subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -2,16 +2,17 @@ import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const settingHeader = style({
|
||||
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||
paddingBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
marginBottom: '24px',
|
||||
});
|
||||
globalStyle(`${settingHeader} .title`, {
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '24px',
|
||||
marginBottom: '4px',
|
||||
});
|
||||
globalStyle(`${settingHeader} .subtitle`, {
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '16px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
|
@ -13,7 +13,7 @@ import { AboutAffine } from './about';
|
||||
import { AppearanceSettings } from './appearance';
|
||||
import { BillingSettings } from './billing';
|
||||
import { PaymentIcon, UpgradeIcon } from './icons';
|
||||
import { AFFiNECloudPlans } from './plans';
|
||||
import { AFFiNEPricingPlans } from './plans';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
|
||||
interface GeneralSettingListItem {
|
||||
@ -84,7 +84,7 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
|
||||
case 'about':
|
||||
return <AboutAffine />;
|
||||
case 'plans':
|
||||
return <AFFiNECloudPlans />;
|
||||
return <AFFiNEPricingPlans />;
|
||||
case 'billing':
|
||||
return <BillingSettings />;
|
||||
default:
|
||||
|
@ -0,0 +1,109 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const card = style({
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: 16,
|
||||
padding: 36,
|
||||
});
|
||||
|
||||
export const titleBlock = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
});
|
||||
export const titleCaption1 = style({
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '14px',
|
||||
color: cssVar('brandColor'),
|
||||
});
|
||||
export const titleCaption2 = style({
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
letterSpacing: '-2%',
|
||||
});
|
||||
export const title = style({
|
||||
fontWeight: 600,
|
||||
fontSize: '30px',
|
||||
lineHeight: '36px',
|
||||
letterSpacing: '-2%',
|
||||
});
|
||||
|
||||
// action button
|
||||
export const actionBlock = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
alignItems: 'start',
|
||||
marginBottom: 24,
|
||||
});
|
||||
export const purchaseButton = style({
|
||||
minWidth: 160,
|
||||
height: 37,
|
||||
borderRadius: 18,
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '14px',
|
||||
letterSpacing: '-1%',
|
||||
});
|
||||
export const agreement = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 400,
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
globalStyle(`.${agreement} > a`, {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
textDecoration: 'underline',
|
||||
});
|
||||
|
||||
// benefits
|
||||
export const benefits = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
});
|
||||
export const benefitGroup = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
});
|
||||
export const benefitTitle = style({
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
letterSpacing: '-2%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
globalStyle(`.${benefitTitle} > svg`, {
|
||||
color: cssVar('brandColor'),
|
||||
});
|
||||
export const benefitList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
});
|
||||
export const benefitItem = style({
|
||||
fontWeight: 400,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '24px',
|
||||
paddingLeft: 22,
|
||||
position: 'relative',
|
||||
'::before': {
|
||||
content: '""',
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: 'currentColor',
|
||||
position: 'absolute',
|
||||
left: '10px',
|
||||
top: '10px',
|
||||
},
|
||||
});
|
@ -0,0 +1,89 @@
|
||||
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
|
||||
import {
|
||||
type SubscriptionMutator,
|
||||
useUserSubscription,
|
||||
} from '@affine/core/hooks/use-subscription';
|
||||
import { timestampToLocalDate } from '@affine/core/utils';
|
||||
import {
|
||||
type PricesQuery,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { AIPlanLayout } from '../layout';
|
||||
import * as styles from './ai-plan.css';
|
||||
import { AIBenefits } from './benefits';
|
||||
import { AICancel } from './cancel';
|
||||
import { AILogin } from './login';
|
||||
import { AIResume } from './resume';
|
||||
import { AISubscribe } from './subscribe';
|
||||
import type { BaseActionProps } from './types';
|
||||
|
||||
interface AIPlanProps {
|
||||
price?: PricesQuery['prices'][number];
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}
|
||||
export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
|
||||
const plan = SubscriptionPlan.AI;
|
||||
const recurring = SubscriptionRecurring.Yearly;
|
||||
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
|
||||
const [subscription] = useUserSubscription(plan);
|
||||
|
||||
// yearly subscription should always be available
|
||||
if (!price?.yearlyAmount) return null;
|
||||
|
||||
const baseActionProps: BaseActionProps = {
|
||||
plan,
|
||||
price,
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
};
|
||||
const isCancelled = !!subscription?.canceledAt;
|
||||
|
||||
const Action = !loggedIn
|
||||
? AILogin
|
||||
: !subscription
|
||||
? AISubscribe
|
||||
: isCancelled
|
||||
? AIResume
|
||||
: AICancel;
|
||||
|
||||
return (
|
||||
<AIPlanLayout
|
||||
caption={
|
||||
subscription
|
||||
? 'You have purchased AFFiNE AI'
|
||||
: 'You are current on the Basic plan.'
|
||||
}
|
||||
>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.titleBlock}>
|
||||
<section className={styles.titleCaption1}>
|
||||
Turn all your ideas into reality
|
||||
</section>
|
||||
<section className={styles.title}>AFFiNE AI</section>
|
||||
<section className={styles.titleCaption2}>
|
||||
A true multimodal AI copilot.
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.actionBlock}>
|
||||
<Action {...baseActionProps} />
|
||||
{subscription?.nextBillAt ? (
|
||||
<PurchasedTip due={timestampToLocalDate(subscription.nextBillAt)} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AIBenefits />
|
||||
</div>
|
||||
</AIPlanLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const PurchasedTip = ({ due }: { due: string }) => (
|
||||
<div className={styles.agreement}>
|
||||
You have purchased AFFiNE AI. The next payment date is {due}.
|
||||
</div>
|
||||
);
|
@ -0,0 +1,59 @@
|
||||
import { ShapeIcon } from '@blocksuite/icons';
|
||||
|
||||
import * as styles from './ai-plan.css';
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
name: 'Write with you',
|
||||
icon: <ShapeIcon />,
|
||||
items: [
|
||||
'Create quality content from sentences to articles on topics you need',
|
||||
'Rewrite like the professionals',
|
||||
'Change the tones / fix spelling & grammar',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Draw with you',
|
||||
icon: <ShapeIcon />,
|
||||
items: [
|
||||
'Visualize your mind, magically',
|
||||
'Turn your outline into beautiful, engaging presentations',
|
||||
'Summarize your content into structured mind-map',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Plan with you',
|
||||
icon: <ShapeIcon />,
|
||||
items: [
|
||||
'Memorize and tidy up your knowledge',
|
||||
'Auto-sorting and auto-tagging',
|
||||
'Open source & Privacy ensured',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const AIBenefits = () => {
|
||||
// TODO: responsive
|
||||
return (
|
||||
<div className={styles.benefits}>
|
||||
{benefits.map(({ name, icon, items }) => {
|
||||
return (
|
||||
<div key={name} className={styles.benefitGroup}>
|
||||
<div className={styles.benefitTitle}>
|
||||
{icon}
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<ul className={styles.benefitList}>
|
||||
{items.map(item => (
|
||||
<li className={styles.benefitItem} key={item}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,58 @@
|
||||
import { Button, useConfirmModal } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useMutation } from '@affine/core/hooks/use-mutation';
|
||||
import { cancelSubscriptionMutation } from '@affine/graphql';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { purchaseButton } from './ai-plan.css';
|
||||
import type { BaseActionProps } from './types';
|
||||
|
||||
interface AICancelProps extends BaseActionProps {}
|
||||
export const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => {
|
||||
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
|
||||
const { trigger, isMutating } = useMutation({
|
||||
mutation: cancelSubscriptionMutation,
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const cancel = useAsyncCallback(async () => {
|
||||
openConfirmModal({
|
||||
title: 'Cancel Subscription',
|
||||
description:
|
||||
'If you end your subscription now, you can still use AFFiNE AI until the end of this billing period.',
|
||||
reverseFooter: true,
|
||||
confirmButtonOptions: {
|
||||
children: 'Cancel Subscription',
|
||||
type: 'default',
|
||||
},
|
||||
cancelText: 'Keep AFFiNE AI',
|
||||
cancelButtonOptions: {
|
||||
type: 'primary',
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await trigger(
|
||||
{ idempotencyKey, plan },
|
||||
{
|
||||
onSuccess: data => {
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onSubscriptionUpdate(data.cancelSubscription);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [openConfirmModal, trigger, idempotencyKey, plan, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={cancel}
|
||||
loading={isMutating}
|
||||
className={purchaseButton}
|
||||
type="primary"
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { authAtom } from '@affine/core/atoms';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const AILogin = () => {
|
||||
const setOpen = useSetAtom(authAtom);
|
||||
|
||||
const onClickSignIn = useCallback(() => {
|
||||
setOpen(state => ({
|
||||
...state,
|
||||
openModal: true,
|
||||
}));
|
||||
}, [setOpen]);
|
||||
|
||||
return <Button onClick={onClickSignIn}>Login</Button>;
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
import { Button, notify, useConfirmModal } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useMutation } from '@affine/core/hooks/use-mutation';
|
||||
import { resumeSubscriptionMutation } from '@affine/graphql';
|
||||
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { purchaseButton } from './ai-plan.css';
|
||||
import type { BaseActionProps } from './types';
|
||||
|
||||
interface AIResumeProps extends BaseActionProps {}
|
||||
|
||||
export const AIResume = ({ plan, onSubscriptionUpdate }: AIResumeProps) => {
|
||||
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
|
||||
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: resumeSubscriptionMutation,
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const resume = useAsyncCallback(async () => {
|
||||
openConfirmModal({
|
||||
title: 'Resume Auto-Renewal?',
|
||||
description:
|
||||
'Are you sure you want to resume the subscription for AFFiNE AI? This means your payment method will be charged automatically at the end of each billing cycle, starting from the next billing cycle.',
|
||||
confirmButtonOptions: {
|
||||
children: 'Confirm',
|
||||
type: 'primary',
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await trigger(
|
||||
{ idempotencyKey, plan },
|
||||
{
|
||||
onSuccess: data => {
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onSubscriptionUpdate(data.resumeSubscription);
|
||||
notify({
|
||||
icon: (
|
||||
<SingleSelectSelectSolidIcon
|
||||
color={cssVar('processingColor')}
|
||||
/>
|
||||
),
|
||||
title: 'Subscription Updated',
|
||||
message: 'You will be charged in the next billing cycle.',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [openConfirmModal, trigger, idempotencyKey, plan, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
loading={isMutating}
|
||||
onClick={resume}
|
||||
className={purchaseButton}
|
||||
type="primary"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
);
|
||||
};
|
@ -0,0 +1,80 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useMutation } from '@affine/core/hooks/use-mutation';
|
||||
import { createCheckoutSessionMutation } from '@affine/graphql';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { purchaseButton } from './ai-plan.css';
|
||||
import type { BaseActionProps } from './types';
|
||||
|
||||
interface AISubscribeProps extends BaseActionProps {}
|
||||
|
||||
export const AISubscribe = ({
|
||||
price,
|
||||
plan,
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
}: AISubscribeProps) => {
|
||||
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
|
||||
|
||||
const newTabRef = useRef<Window | null>(null);
|
||||
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: createCheckoutSessionMutation,
|
||||
});
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
newTabRef.current = null;
|
||||
onSubscriptionUpdate();
|
||||
}, [onSubscriptionUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.removeEventListener('close', onClose);
|
||||
newTabRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const subscribe = useAsyncCallback(async () => {
|
||||
await trigger(
|
||||
{
|
||||
input: {
|
||||
recurring,
|
||||
idempotencyKey,
|
||||
plan,
|
||||
coupon: null,
|
||||
successCallbackLink: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: data => {
|
||||
const newTab = window.open(
|
||||
data.createCheckoutSession,
|
||||
'_blank',
|
||||
'noopener noreferrer'
|
||||
);
|
||||
if (newTab) {
|
||||
newTabRef.current = newTab;
|
||||
newTab.addEventListener('close', onClose);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [idempotencyKey, onClose, plan, recurring, trigger]);
|
||||
|
||||
if (!price.yearlyAmount) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
loading={isMutating}
|
||||
onClick={subscribe}
|
||||
className={purchaseButton}
|
||||
type="primary"
|
||||
>
|
||||
${(price.yearlyAmount / 100).toFixed(2)} / Year
|
||||
</Button>
|
||||
);
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription';
|
||||
import type {
|
||||
PricesQuery,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export interface BaseActionProps {
|
||||
price: PricesQuery['prices'][number];
|
||||
recurring: SubscriptionRecurring;
|
||||
plan: SubscriptionPlan;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
// TODO: we don't handle i18n for now
|
||||
// it's better to manage all equity at server side
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { AfFiNeIcon } from '@blocksuite/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type Benefits = Record<
|
||||
string,
|
||||
Array<{
|
||||
icon?: ReactNode;
|
||||
title: ReactNode;
|
||||
}>
|
||||
>;
|
||||
interface BasePrice {
|
||||
plan: SubscriptionPlan;
|
||||
name: string;
|
||||
description: string;
|
||||
benefits: Benefits;
|
||||
}
|
||||
export interface FixedPrice extends BasePrice {
|
||||
type: 'fixed';
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
discount?: string;
|
||||
titleRenderer: (
|
||||
recurring: SubscriptionRecurring,
|
||||
detail: FixedPrice
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
export interface DynamicPrice extends BasePrice {
|
||||
type: 'dynamic';
|
||||
contact: boolean;
|
||||
titleRenderer: (
|
||||
recurring: SubscriptionRecurring,
|
||||
detail: DynamicPrice
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
const freeBenefits: Benefits = {
|
||||
'Include in FOSS': [
|
||||
{ title: 'Unlimited Local Workspaces.' },
|
||||
{ title: 'Unlimited use and Customization.' },
|
||||
{ title: 'Unlimited Doc and Edgeless editing.' },
|
||||
],
|
||||
'Include in Basic': [
|
||||
{ title: '10 GB of Cloud Storage.' },
|
||||
{ title: '10 MB of Maximum file size.' },
|
||||
{ title: 'Up to 3 members per Workspace.' },
|
||||
{ title: '7-days Cloud Time Machine file version history.' },
|
||||
{ title: 'Up to 3 login devices.' },
|
||||
],
|
||||
};
|
||||
|
||||
const proBenefits: Benefits = {
|
||||
'Include in Pro': [
|
||||
{ title: 'Everything in AFFiNE FOSS & Basic.', icon: <AfFiNeIcon /> },
|
||||
{ title: '100 GB of Cloud Storage.' },
|
||||
{ title: '100 MB of Maximum file size.' },
|
||||
{ title: 'Up to 10 members per Workspace.' },
|
||||
{ title: '30-days Cloud Time Machine file version history.' },
|
||||
{ title: 'Add comments on Doc and Edgeless.' },
|
||||
{ title: 'Community Support.' },
|
||||
{ title: 'Real-time Syncing & Collaboration for more people.' },
|
||||
],
|
||||
};
|
||||
|
||||
const teamBenefits: Benefits = {
|
||||
'Both in Team & Enterprise': [
|
||||
{ title: 'Everything in AFFiNE Pro.', icon: <AfFiNeIcon /> },
|
||||
{ title: 'Advanced Permission control, Page history and Review mode.' },
|
||||
{ title: 'Pay for seats, fits all team size.' },
|
||||
{ title: 'Email & Slack Support.' },
|
||||
],
|
||||
'Enterprise only': [
|
||||
{ title: 'SSO Authorization.' },
|
||||
{ title: 'Solutions & Best Practices for Dedicated needs.' },
|
||||
{ title: 'Embed-able & Integrations with IT support.' },
|
||||
],
|
||||
};
|
||||
|
||||
export function getPlanDetail() {
|
||||
return new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
|
||||
[
|
||||
SubscriptionPlan.Free,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Free,
|
||||
price: '0',
|
||||
yearlyPrice: '0',
|
||||
name: 'FOSS + Basic',
|
||||
description: 'Open-Source under MIT license.',
|
||||
titleRenderer: () => 'Free forever',
|
||||
benefits: freeBenefits,
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Pro,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
price: '1',
|
||||
yearlyPrice: '1',
|
||||
name: 'Pro',
|
||||
description: 'For family and small teams.',
|
||||
titleRenderer: (recurring, detail) => {
|
||||
const price =
|
||||
recurring === SubscriptionRecurring.Yearly
|
||||
? detail.yearlyPrice
|
||||
: detail.price;
|
||||
return `$${price} per month`;
|
||||
},
|
||||
benefits: proBenefits,
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Team,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Team,
|
||||
contact: true,
|
||||
name: 'Team / Enterprise',
|
||||
description: 'Best for scalable teams.',
|
||||
titleRenderer: () => 'Contact Sales',
|
||||
benefits: teamBenefits,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { notify, RadioButton, RadioButtonGroup } from '@affine/component';
|
||||
import { notify, Switch } from '@affine/component';
|
||||
import {
|
||||
pricesQuery,
|
||||
SubscriptionPlan,
|
||||
@ -15,9 +15,10 @@ import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bunda
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import { useQuery } from '../../../../../hooks/use-query';
|
||||
import { useUserSubscription } from '../../../../../hooks/use-subscription';
|
||||
import { PlanLayout } from './layout';
|
||||
import type { FixedPrice } from './plan-card';
|
||||
import { getPlanDetail, PlanCard } from './plan-card';
|
||||
import { AIPlan } from './ai/ai-plan';
|
||||
import { type FixedPrice, getPlanDetail } from './cloud-plans';
|
||||
import { CloudPlanLayout, PlanLayout } from './layout';
|
||||
import { PlanCard } from './plan-card';
|
||||
import { PlansSkeleton } from './skeleton';
|
||||
import * as styles from './style.css';
|
||||
|
||||
@ -38,7 +39,7 @@ const Settings = () => {
|
||||
const [subscription, mutateSubscription] = useUserSubscription();
|
||||
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const planDetail = getPlanDetail(t);
|
||||
const planDetail = getPlanDetail();
|
||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
@ -62,7 +63,7 @@ const Settings = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const [recurring, setRecurring] = useState<string>(
|
||||
const [recurring, setRecurring] = useState<SubscriptionRecurring>(
|
||||
subscription?.recurring ?? SubscriptionRecurring.Yearly
|
||||
);
|
||||
|
||||
@ -100,7 +101,7 @@ const Settings = () => {
|
||||
};
|
||||
}, [recurring]);
|
||||
|
||||
const subtitle = loggedIn ? (
|
||||
const cloudCaption = loggedIn ? (
|
||||
isCanceled ? (
|
||||
<p>
|
||||
{t['com.affine.payment.subtitle-canceled']({
|
||||
@ -133,30 +134,38 @@ const Settings = () => {
|
||||
<p>{t['com.affine.payment.subtitle-not-signed-in']()}</p>
|
||||
);
|
||||
|
||||
const tabs = (
|
||||
<RadioButtonGroup
|
||||
className={styles.recurringRadioGroup}
|
||||
value={recurring}
|
||||
onValueChange={setRecurring}
|
||||
>
|
||||
{Object.values(SubscriptionRecurring).map(recurring => (
|
||||
<RadioButton key={recurring} value={recurring}>
|
||||
<span className={styles.radioButtonText}>
|
||||
{getRecurringLabel({ recurring, t })}
|
||||
</span>
|
||||
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||
<span className={styles.radioButtonDiscount}>
|
||||
{t['com.affine.payment.discount-amount']({
|
||||
amount: yearlyDiscount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</RadioButton>
|
||||
))}
|
||||
</RadioButtonGroup>
|
||||
const cloudToggle = (
|
||||
<div className={styles.recurringToggleWrapper}>
|
||||
<div>
|
||||
{recurring === SubscriptionRecurring.Yearly ? (
|
||||
<div className={styles.recurringToggleRecurring}>Yearly</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.recurringToggleRecurring}>
|
||||
<span>Billed Yearly</span>
|
||||
</div>
|
||||
{yearlyDiscount ? (
|
||||
<div className={styles.recurringToggleDiscount}>
|
||||
Saving {yearlyDiscount}%
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={recurring === SubscriptionRecurring.Yearly}
|
||||
onChange={checked =>
|
||||
setRecurring(
|
||||
checked
|
||||
? SubscriptionRecurring.Yearly
|
||||
: SubscriptionRecurring.Monthly
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const scroll = (
|
||||
const cloudScroll = (
|
||||
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
||||
{Array.from(planDetail.values()).map(detail => {
|
||||
return (
|
||||
@ -190,12 +199,35 @@ const Settings = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const cloudSelect = (
|
||||
<div className={styles.cloudSelect}>
|
||||
<b>Hosted by AFFiNE.Pro</b>
|
||||
<span>We host, no technical setup required.</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlanLayout scrollRef={scrollWrapper} {...{ subtitle, tabs, scroll }} />
|
||||
<PlanLayout
|
||||
cloud={
|
||||
<CloudPlanLayout
|
||||
caption={cloudCaption}
|
||||
select={cloudSelect}
|
||||
toggle={cloudToggle}
|
||||
scroll={cloudScroll}
|
||||
scrollRef={scrollWrapper}
|
||||
/>
|
||||
}
|
||||
ai={
|
||||
<AIPlan
|
||||
price={prices.find(p => p.plan === SubscriptionPlan.AI)}
|
||||
onSubscriptionUpdate={mutateSubscription}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AFFiNECloudPlans = () => {
|
||||
export const AFFiNEPricingPlans = () => {
|
||||
return (
|
||||
<SWRErrorBoundary FallbackComponent={PlansErrorBoundary}>
|
||||
<Suspense fallback={<PlansSkeleton />}>
|
||||
@ -208,11 +240,6 @@ export const AFFiNECloudPlans = () => {
|
||||
const PlansErrorBoundary = ({ resetErrorBoundary }: FallbackProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const title = t['com.affine.payment.title']();
|
||||
const subtitle = '';
|
||||
const tabs = '';
|
||||
const footer = '';
|
||||
|
||||
const scroll = (
|
||||
<div className={styles.errorTip}>
|
||||
<span>{t['com.affine.payment.plans-error-tip']()}</span>
|
||||
@ -222,5 +249,5 @@ const PlansErrorBoundary = ({ resetErrorBoundary }: FallbackProps) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
return <PlanLayout {...{ title, subtitle, tabs, scroll, footer }} />;
|
||||
return <PlanLayout cloud={<CloudPlanLayout scroll={scroll} />} />;
|
||||
};
|
||||
|
@ -43,3 +43,30 @@ export const allPlansLink = style({
|
||||
borderColor: 'transparent',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const collapsibleHeader = style({
|
||||
display: 'flex',
|
||||
marginBottom: 8,
|
||||
});
|
||||
export const collapsibleHeaderContent = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
});
|
||||
export const collapsibleHeaderTitle = style({
|
||||
fontWeight: 600,
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '22px',
|
||||
});
|
||||
export const collapsibleHeaderCaption = style({
|
||||
fontWeight: 400,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const affineCloudHeader = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
});
|
||||
|
@ -1,22 +1,20 @@
|
||||
import { Divider, IconButton } from '@affine/component';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowRightBigIcon } from '@blocksuite/icons';
|
||||
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import type { HtmlHTMLAttributes, ReactNode } from 'react';
|
||||
import {
|
||||
type HtmlHTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './layout.css';
|
||||
|
||||
export interface PlanLayoutProps
|
||||
extends Omit<HtmlHTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
title?: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
tabs: ReactNode;
|
||||
scroll: ReactNode;
|
||||
footer?: ReactNode;
|
||||
scrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const SeeAllLink = () => {
|
||||
export const SeeAllLink = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
@ -32,24 +30,86 @@ const SeeAllLink = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const PlanLayout = ({
|
||||
subtitle,
|
||||
tabs,
|
||||
scroll,
|
||||
interface PricingCollapsibleProps
|
||||
extends Omit<HtmlHTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
title?: ReactNode;
|
||||
caption?: ReactNode;
|
||||
}
|
||||
const PricingCollapsible = ({
|
||||
title,
|
||||
footer = <SeeAllLink />,
|
||||
scrollRef,
|
||||
}: PlanLayoutProps) => {
|
||||
caption,
|
||||
children,
|
||||
}: PricingCollapsibleProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const toggle = useCallback(() => setOpen(prev => !prev), []);
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<section className={styles.collapsibleHeader}>
|
||||
<div className={styles.collapsibleHeaderContent}>
|
||||
<div className={styles.collapsibleHeaderTitle}>{title}</div>
|
||||
<div className={styles.collapsibleHeaderCaption}>{caption}</div>
|
||||
</div>
|
||||
<IconButton onClick={toggle}>
|
||||
<ArrowUpSmallIcon
|
||||
style={{
|
||||
transform: open ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||
transition: 'transform 0.23s ease',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</section>
|
||||
<Collapsible.Content>{children}</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PlanLayoutProps {
|
||||
cloud?: ReactNode;
|
||||
ai?: ReactNode;
|
||||
}
|
||||
|
||||
export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.plansLayoutRoot}>
|
||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||
<SettingHeader
|
||||
style={{ marginBottom: '0px' }}
|
||||
title={title ?? t['com.affine.payment.title']()}
|
||||
subtitle={subtitle}
|
||||
title={t['com.affine.payment.title']()}
|
||||
/>
|
||||
{tabs}
|
||||
{cloud}
|
||||
{ai ? (
|
||||
<>
|
||||
<Divider />
|
||||
{ai}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PlanCardProps {
|
||||
title?: ReactNode;
|
||||
caption?: ReactNode;
|
||||
select?: ReactNode;
|
||||
toggle?: ReactNode;
|
||||
scroll?: ReactNode;
|
||||
scrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
export const CloudPlanLayout = ({
|
||||
title = 'AFFiNE Cloud',
|
||||
caption,
|
||||
select,
|
||||
toggle,
|
||||
scroll,
|
||||
scrollRef,
|
||||
}: PlanCardProps) => {
|
||||
return (
|
||||
<PricingCollapsible title={title} caption={caption}>
|
||||
<div className={styles.affineCloudHeader}>
|
||||
<div>{select}</div>
|
||||
<div>{toggle}</div>
|
||||
</div>
|
||||
<ScrollArea.Root>
|
||||
<ScrollArea.Viewport ref={scrollRef} className={styles.scrollArea}>
|
||||
{scroll}
|
||||
@ -62,7 +122,22 @@ export const PlanLayout = ({
|
||||
<ScrollArea.Thumb className={styles.scrollThumb}></ScrollArea.Thumb>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
{footer}
|
||||
</div>
|
||||
</PricingCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export interface AIPlanLayoutProps {
|
||||
title?: ReactNode;
|
||||
caption?: ReactNode;
|
||||
}
|
||||
export const AIPlanLayout = ({
|
||||
title = 'AFFiNE AI',
|
||||
caption,
|
||||
children,
|
||||
}: PropsWithChildren<AIPlanLayoutProps>) => {
|
||||
return (
|
||||
<PricingCollapsible title={title} caption={caption}>
|
||||
{children}
|
||||
</PricingCollapsible>
|
||||
);
|
||||
};
|
||||
|
@ -5,10 +5,10 @@ import type {
|
||||
Subscription,
|
||||
SubscriptionMutator,
|
||||
} from '@affine/core/hooks/use-subscription';
|
||||
import type { SubscriptionRecurring } from '@affine/graphql';
|
||||
import {
|
||||
createCheckoutSessionMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
@ -26,30 +26,14 @@ import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-l
|
||||
import { useMutation } from '../../../../../hooks/use-mutation';
|
||||
import { mixpanel } from '../../../../../utils';
|
||||
import { CancelAction, ResumeAction } from './actions';
|
||||
import { BulledListIcon } from './icons/bulled-list';
|
||||
import type { DynamicPrice, FixedPrice } from './cloud-plans';
|
||||
import { ConfirmLoadingModal } from './modals';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export interface FixedPrice {
|
||||
type: 'fixed';
|
||||
plan: SubscriptionPlan;
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
discount?: string;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
export interface DynamicPrice {
|
||||
type: 'dynamic';
|
||||
plan: SubscriptionPlan;
|
||||
contact: boolean;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
detail: FixedPrice | DynamicPrice;
|
||||
subscription?: Subscription | null;
|
||||
recurring: string;
|
||||
recurring: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
onNotify: (info: {
|
||||
detail: FixedPrice | DynamicPrice;
|
||||
@ -57,79 +41,15 @@ interface PlanCardProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
|
||||
return new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
|
||||
[
|
||||
SubscriptionPlan.Free,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Free,
|
||||
price: '0',
|
||||
yearlyPrice: '0',
|
||||
benefits: [
|
||||
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' }),
|
||||
t['com.affine.payment.benefit-7']({ capacity: '7' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Pro,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
price: '1',
|
||||
yearlyPrice: '1',
|
||||
benefits: [
|
||||
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: '100M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '10' }),
|
||||
t['com.affine.payment.benefit-7']({ capacity: '30' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Team,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Team,
|
||||
contact: true,
|
||||
benefits: [
|
||||
t['com.affine.payment.dynamic-benefit-1'](),
|
||||
t['com.affine.payment.dynamic-benefit-2'](),
|
||||
t['com.affine.payment.dynamic-benefit-3'](),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Enterprise,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Enterprise,
|
||||
contact: true,
|
||||
benefits: [
|
||||
t['com.affine.payment.dynamic-benefit-4'](),
|
||||
t['com.affine.payment.dynamic-benefit-5'](),
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export const PlanCard = (props: PlanCardProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { detail, subscription, recurring } = props;
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
|
||||
const isCurrent = loggedIn && detail.plan === currentPlan;
|
||||
const isCurrent =
|
||||
loggedIn &&
|
||||
detail.plan === currentPlan &&
|
||||
recurring === subscription?.recurring;
|
||||
const isPro = detail.plan === SubscriptionPlan.Pro;
|
||||
|
||||
return (
|
||||
@ -138,56 +58,39 @@ export const PlanCard = (props: PlanCardProps) => {
|
||||
key={detail.plan}
|
||||
className={isPro ? styles.proPlanCard : styles.planCard}
|
||||
>
|
||||
<div className={styles.planCardBorderMock} />
|
||||
<div className={styles.planTitle}>
|
||||
<p>
|
||||
<span className={isCurrent ? styles.proPlanTitle : ''}>
|
||||
{detail.plan}
|
||||
</span>{' '}
|
||||
{'discount' in detail &&
|
||||
recurring === SubscriptionRecurring.Yearly && (
|
||||
<span className={styles.discountLabel}>
|
||||
{detail.discount}% off
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.planPriceWrapper}>
|
||||
<p>
|
||||
{detail.type === 'dynamic' ? (
|
||||
<span className={styles.planPriceDesc}>Coming soon...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.planPrice}>
|
||||
$
|
||||
{recurring === SubscriptionRecurring.Monthly
|
||||
? detail.price
|
||||
: detail.yearlyPrice}
|
||||
</span>
|
||||
<span className={styles.planPriceDesc}>
|
||||
{t['com.affine.payment.price-description.per-month']()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div style={{ paddingBottom: 12 }}>
|
||||
<section className={styles.planTitleName}>{detail.name}</section>
|
||||
<section className={styles.planTitleDescription}>
|
||||
{detail.description}
|
||||
</section>
|
||||
<section className={styles.planTitleTitle}>
|
||||
{detail.titleRenderer(recurring, detail as any)}
|
||||
</section>
|
||||
</div>
|
||||
<ActionButton {...props} />
|
||||
</div>
|
||||
<div className={styles.planBenefits}>
|
||||
{detail.benefits.map((content, i) => (
|
||||
<div key={i} className={styles.planBenefit}>
|
||||
<div className={styles.planBenefitIcon}>
|
||||
{detail.type === 'dynamic' ? (
|
||||
<BulledListIcon color="var(--affine-processing-color)" />
|
||||
) : (
|
||||
<DoneIcon
|
||||
width="16"
|
||||
height="16"
|
||||
color="var(--affine-processing-color)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.planBenefitText}>{content}</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.entries(detail.benefits).map(([groupName, benefitList]) => {
|
||||
return (
|
||||
<ul className={styles.planBenefitGroup} key={groupName}>
|
||||
<section className={styles.planBenefitGroupTitle}>
|
||||
{groupName}:
|
||||
</section>
|
||||
{benefitList.map(({ icon, title }, index) => {
|
||||
return (
|
||||
<li className={styles.planBenefit} key={index}>
|
||||
<div className={styles.planBenefitIcon}>
|
||||
{icon ?? <DoneIcon />}
|
||||
</div>
|
||||
<div className={styles.planBenefitText}>{title}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
|
||||
import { PlanLayout } from './layout';
|
||||
import { CloudPlanLayout, PlanLayout } from './layout';
|
||||
import * as styles from './skeleton.css';
|
||||
|
||||
/**
|
||||
@ -17,10 +17,6 @@ const RoundedSkeleton = ({
|
||||
<Skeleton {...props} style={{ borderRadius: `${radius}px` }} />
|
||||
);
|
||||
|
||||
const SubtitleSkeleton = () => (
|
||||
<Skeleton variant="text" width="100%" height="20px" />
|
||||
);
|
||||
|
||||
const TabsSkeleton = () => (
|
||||
// TODO: height should be `32px` by design
|
||||
// but the RadioGroup component is not matching with the design currently
|
||||
@ -52,9 +48,15 @@ const ScrollSkeleton = () => (
|
||||
export const PlansSkeleton = () => {
|
||||
return (
|
||||
<PlanLayout
|
||||
subtitle={<SubtitleSkeleton />}
|
||||
tabs={<TabsSkeleton />}
|
||||
scroll={<ScrollSkeleton />}
|
||||
cloud={
|
||||
<CloudPlanLayout
|
||||
toggle={
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="32px" />
|
||||
}
|
||||
select={<TabsSkeleton />}
|
||||
scroll={<ScrollSkeleton />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,10 +1,26 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const wrapper = style({
|
||||
width: '100%',
|
||||
});
|
||||
export const recurringRadioGroup = style({
|
||||
width: '256px',
|
||||
export const recurringToggleWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
minHeight: 40,
|
||||
});
|
||||
// export const recurringToggleLabel = style({});
|
||||
export const recurringToggleRecurring = style({
|
||||
fontWeight: 400,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
export const recurringToggleDiscount = style({
|
||||
fontWeight: 600,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('brandColor'),
|
||||
});
|
||||
export const radioButtonDiscount = style({
|
||||
marginLeft: '4px',
|
||||
@ -18,6 +34,13 @@ export const radioButtonText = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const cloudSelect = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
});
|
||||
globalStyle(`.${cloudSelect} > span`, { color: cssVar('textSecondaryColor') });
|
||||
export const planCardsWrapper = style({
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)',
|
||||
display: 'flex',
|
||||
@ -29,9 +52,10 @@ export const planCard = style({
|
||||
minHeight: '426px',
|
||||
minWidth: '258px',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.23s ease',
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '',
|
||||
@ -39,27 +63,43 @@ export const planCard = style({
|
||||
right: 'calc(100% + var(--setting-modal-gap-x))',
|
||||
scrollSnapAlign: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const proPlanCard = style([
|
||||
planCard,
|
||||
{
|
||||
borderWidth: '1px',
|
||||
borderColor: cssVar('brandColor'),
|
||||
boxShadow: cssVar('shadow2'),
|
||||
position: 'relative',
|
||||
'::after': {
|
||||
content: '',
|
||||
position: 'absolute',
|
||||
inset: '-1px',
|
||||
borderRadius: 'inherit',
|
||||
boxShadow: `0px 0px 0px 2px ${cssVar('brandColor')}`,
|
||||
opacity: 0.3,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
'&[data-current="true"]': {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
export const planCardBorderMock = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
|
||||
'::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 'inherit',
|
||||
border: `2px solid transparent`,
|
||||
// TODO: brandColor with opacity, dark mode compatibility needed
|
||||
background: `linear-gradient(180deg, ${cssVar('brandColor')}, #1E96EB33) border-box`,
|
||||
['WebkitMask']: `linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0)`,
|
||||
[`WebkitMaskComposite`]: `destination-out`,
|
||||
maskComposite: `exclude`,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.23s ease',
|
||||
},
|
||||
|
||||
selectors: {
|
||||
[`.${planCard}[data-current="true"] &::after`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const proPlanCard = style([planCard, {}]);
|
||||
export const proPlanTitle = style({
|
||||
backgroundColor: cssVar('brandColor'),
|
||||
color: cssVar('white'),
|
||||
@ -82,8 +122,36 @@ export const planTitle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
padding: '12px 16px',
|
||||
background: cssVar('backgroundOverlayPanelColor'),
|
||||
borderRadius: 'inherit',
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottom: '1px solid ' + cssVar('borderColor'),
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
});
|
||||
export const planTitleSpotlight = style({});
|
||||
globalStyle(`.${planTitle} > :not(.${planTitleSpotlight})`, {
|
||||
position: 'relative',
|
||||
});
|
||||
export const planTitleName = style({
|
||||
fontWeight: 600,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
});
|
||||
export const planTitleDescription = style({
|
||||
fontWeight: 400,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
marginBottom: 8,
|
||||
});
|
||||
export const planTitleTitle = style({
|
||||
fontWeight: 600,
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '20px',
|
||||
});
|
||||
export const planPriceWrapper = style({
|
||||
minHeight: '28px',
|
||||
@ -103,28 +171,43 @@ export const planAction = style({
|
||||
width: '100%',
|
||||
});
|
||||
export const planBenefits = style({
|
||||
marginTop: '20px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
});
|
||||
export const planBenefitGroup = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
export const planBenefitGroupTitle = style({
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
export const planBenefit = style({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
lineHeight: '20px',
|
||||
alignItems: 'normal',
|
||||
fontSize: '12px',
|
||||
});
|
||||
export const planBenefitIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '20px',
|
||||
});
|
||||
globalStyle(`.${planBenefitIcon} > svg`, {
|
||||
color: cssVar('brandColor'),
|
||||
});
|
||||
export const planBenefitText = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
export const downgradeContentWrapper = style({
|
||||
padding: '12px 0 20px 0px',
|
||||
|
@ -1,5 +1,8 @@
|
||||
mutation cancelSubscription($idempotencyKey: String!) {
|
||||
cancelSubscription(idempotencyKey: $idempotencyKey) {
|
||||
mutation cancelSubscription(
|
||||
$idempotencyKey: String!
|
||||
$plan: SubscriptionPlan = Pro
|
||||
) {
|
||||
cancelSubscription(idempotencyKey: $idempotencyKey, plan: $plan) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
|
@ -96,8 +96,8 @@ export const cancelSubscriptionMutation = {
|
||||
definitionName: 'cancelSubscription',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation cancelSubscription($idempotencyKey: String!) {
|
||||
cancelSubscription(idempotencyKey: $idempotencyKey) {
|
||||
mutation cancelSubscription($idempotencyKey: String!, $plan: SubscriptionPlan = Pro) {
|
||||
cancelSubscription(idempotencyKey: $idempotencyKey, plan: $plan) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
@ -614,8 +614,8 @@ export const resumeSubscriptionMutation = {
|
||||
definitionName: 'resumeSubscription',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation resumeSubscription($idempotencyKey: String!) {
|
||||
resumeSubscription(idempotencyKey: $idempotencyKey) {
|
||||
mutation resumeSubscription($idempotencyKey: String!, $plan: SubscriptionPlan = Pro) {
|
||||
resumeSubscription(idempotencyKey: $idempotencyKey, plan: $plan) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
@ -768,10 +768,11 @@ export const updateSubscriptionMutation = {
|
||||
definitionName: 'updateSubscriptionRecurring',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation updateSubscription($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
|
||||
mutation updateSubscription($idempotencyKey: String!, $plan: SubscriptionPlan = Pro, $recurring: SubscriptionRecurring!) {
|
||||
updateSubscriptionRecurring(
|
||||
recurring: $recurring
|
||||
idempotencyKey: $idempotencyKey
|
||||
plan: $plan
|
||||
recurring: $recurring
|
||||
) {
|
||||
id
|
||||
plan
|
||||
|
@ -1,5 +1,8 @@
|
||||
mutation resumeSubscription($idempotencyKey: String!) {
|
||||
resumeSubscription(idempotencyKey: $idempotencyKey) {
|
||||
mutation resumeSubscription(
|
||||
$idempotencyKey: String!
|
||||
$plan: SubscriptionPlan = Pro
|
||||
) {
|
||||
resumeSubscription(idempotencyKey: $idempotencyKey, plan: $plan) {
|
||||
id
|
||||
status
|
||||
nextBillAt
|
||||
|
@ -1,10 +1,12 @@
|
||||
mutation updateSubscription(
|
||||
$recurring: SubscriptionRecurring!
|
||||
$idempotencyKey: String!
|
||||
$plan: SubscriptionPlan = Pro
|
||||
$recurring: SubscriptionRecurring!
|
||||
) {
|
||||
updateSubscriptionRecurring(
|
||||
recurring: $recurring
|
||||
idempotencyKey: $idempotencyKey
|
||||
plan: $plan
|
||||
recurring: $recurring
|
||||
) {
|
||||
id
|
||||
plan
|
||||
|
@ -173,6 +173,7 @@ export type AllBlobSizesQuery = {
|
||||
|
||||
export type CancelSubscriptionMutationVariables = Exact<{
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
plan?: InputMaybe<SubscriptionPlan>;
|
||||
}>;
|
||||
|
||||
export type CancelSubscriptionMutation = {
|
||||
@ -619,6 +620,7 @@ export type RemoveAvatarMutation = {
|
||||
|
||||
export type ResumeSubscriptionMutationVariables = Exact<{
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
plan?: InputMaybe<SubscriptionPlan>;
|
||||
}>;
|
||||
|
||||
export type ResumeSubscriptionMutation = {
|
||||
@ -758,8 +760,9 @@ export type SubscriptionQuery = {
|
||||
};
|
||||
|
||||
export type UpdateSubscriptionMutationVariables = Exact<{
|
||||
recurring: SubscriptionRecurring;
|
||||
idempotencyKey: Scalars['String']['input'];
|
||||
plan?: InputMaybe<SubscriptionPlan>;
|
||||
recurring: SubscriptionRecurring;
|
||||
}>;
|
||||
|
||||
export type UpdateSubscriptionMutation = {
|
||||
|
Loading…
Reference in New Issue
Block a user