feat(core): pricing plans ai subscription ui (#6449)

This commit is contained in:
CatsJuice 2024-04-03 08:04:30 +00:00
parent e7de20f648
commit 3e9e2ce93b
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
24 changed files with 999 additions and 246 deletions

View File

@ -248,6 +248,9 @@ type ServerConfigType {
"""credentials requirement"""
credentialsRequirement: CredentialsRequirementType!
"""enable telemetry"""
enableTelemetry: Boolean!
"""enabled server features"""
features: [ServerFeature!]!

View File

@ -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>
);
};

View File

@ -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'),

View File

@ -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:

View File

@ -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',
},
});

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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,
},
],
]);
}

View File

@ -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} />} />;
};

View File

@ -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,
});

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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 />}
/>
}
/>
);
};

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 = {