mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-23 04:42:24 +03:00
feat(core): payment plans skeleton (#4715)
This commit is contained in:
parent
e8a88da9e4
commit
eaa90c9fb6
@ -1,115 +1,23 @@
|
||||
import { RadioButton, RadioButtonGroup } from '@affine/component';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
checkoutMutation,
|
||||
pricesQuery,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||
import { DoneIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQuery } from '@affine/workspace/affine/gql';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import {
|
||||
type SubscriptionMutator,
|
||||
useUserSubscription,
|
||||
} from '../../../../../hooks/use-subscription';
|
||||
import { BulledListIcon } from './icons/bulled-list';
|
||||
import { useUserSubscription } from '../../../../../hooks/use-subscription';
|
||||
import { PlanLayout } from './layout';
|
||||
import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card';
|
||||
import { PlansSkeleton } from './skeleton';
|
||||
import * as styles from './style.css';
|
||||
|
||||
interface FixedPrice {
|
||||
type: 'fixed';
|
||||
plan: SubscriptionPlan;
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
discount?: string;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
interface DynamicPrice {
|
||||
type: 'dynamic';
|
||||
plan: SubscriptionPlan;
|
||||
contact: boolean;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
// TODO: i18n all things
|
||||
const planDetail = new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
|
||||
[
|
||||
SubscriptionPlan.Free,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Free,
|
||||
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',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Pro,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
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',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Team,
|
||||
{
|
||||
type: 'dynamic',
|
||||
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.',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Enterprise,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Enterprise,
|
||||
contact: true,
|
||||
benefits: [
|
||||
'Solutions & best practices for dedicated needs.',
|
||||
'Embedable & interrogations with IT support.',
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const Settings = () => {
|
||||
const [subscription, mutateSubscription] = useUserSubscription();
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const planDetail = getPlanDetail();
|
||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
@ -136,7 +44,6 @@ const Settings = () => {
|
||||
);
|
||||
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const currentRecurring = subscription?.recurring;
|
||||
|
||||
const yearlyDiscount = (
|
||||
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
|
||||
@ -168,324 +75,66 @@ const Settings = () => {
|
||||
};
|
||||
}, [recurring]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title="Plans"
|
||||
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>
|
||||
) : (
|
||||
<p>
|
||||
This is the Pricing plans of AFFiNE Cloud. You can sign up or sign
|
||||
in to your account first.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<RadioButtonGroup
|
||||
className={styles.recurringRadioGroup}
|
||||
value={recurring}
|
||||
onValueChange={setRecurring}
|
||||
>
|
||||
{Object.values(SubscriptionRecurring).map(plan => (
|
||||
<RadioButton key={plan} value={plan}>
|
||||
{plan}
|
||||
{plan === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||
<span className={styles.radioButtonDiscount}>
|
||||
{yearlyDiscount}% off
|
||||
</span>
|
||||
)}
|
||||
</RadioButton>
|
||||
))}
|
||||
</RadioButtonGroup>
|
||||
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
||||
{Array.from(planDetail.values()).map(detail => {
|
||||
const isCurrent =
|
||||
loggedIn &&
|
||||
detail.plan === currentPlan &&
|
||||
(currentPlan === SubscriptionPlan.Free
|
||||
? true
|
||||
: currentRecurring === recurring);
|
||||
return (
|
||||
<div
|
||||
data-current={isCurrent}
|
||||
key={detail.plan}
|
||||
className={isCurrent ? styles.currentPlanCard : styles.planCard}
|
||||
>
|
||||
<div className={styles.planTitle}>
|
||||
<p>
|
||||
{detail.plan}{' '}
|
||||
{'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}>
|
||||
per month
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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={mutateSubscription} />
|
||||
) : currentRecurring !== recurring &&
|
||||
currentPlan === detail.plan ? (
|
||||
<ChangeRecurring
|
||||
// @ts-expect-error must exist
|
||||
from={currentRecurring}
|
||||
to={recurring as SubscriptionRecurring}
|
||||
onSubscriptionUpdate={mutateSubscription}
|
||||
/>
|
||||
) : (
|
||||
<Upgrade
|
||||
recurring={recurring as SubscriptionRecurring}
|
||||
onSubscriptionUpdate={mutateSubscription}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SignupAction>
|
||||
{detail.plan === SubscriptionPlan.Free
|
||||
? 'Sign up free'
|
||||
: 'Buy Pro'}
|
||||
</SignupAction>
|
||||
)
|
||||
}
|
||||
</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-primary-color)" />
|
||||
) : (
|
||||
<DoneIcon color="var(--affine-primary-color)" />
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<a
|
||||
className={styles.allPlansLink}
|
||||
href="https://affine.pro/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See all plans →{/* TODO: icon */}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
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>
|
||||
) : (
|
||||
<p>
|
||||
This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to
|
||||
your account first.
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const Downgrade = ({
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: cancelSubscriptionMutation,
|
||||
});
|
||||
const getRecurringLabel = (recurring: SubscriptionRecurring) =>
|
||||
({
|
||||
[SubscriptionRecurring.Monthly]: 'Monthly',
|
||||
[SubscriptionRecurring.Yearly]: 'Annually',
|
||||
})[recurring];
|
||||
|
||||
const downgrade = useCallback(() => {
|
||||
trigger(null, {
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.cancelSubscription);
|
||||
},
|
||||
});
|
||||
}, [trigger, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={downgrade /* TODO: poppup confirmation modal instead */}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
const tabs = (
|
||||
<RadioButtonGroup
|
||||
className={styles.recurringRadioGroup}
|
||||
value={recurring}
|
||||
onValueChange={setRecurring}
|
||||
>
|
||||
Downgrade
|
||||
</Button>
|
||||
{Object.values(SubscriptionRecurring).map(recurring => (
|
||||
<RadioButton key={recurring} value={recurring}>
|
||||
{getRecurringLabel(recurring)}
|
||||
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||
<span className={styles.radioButtonDiscount}>
|
||||
{yearlyDiscount}% off
|
||||
</span>
|
||||
)}
|
||||
</RadioButton>
|
||||
))}
|
||||
</RadioButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const Upgrade = ({
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
recurring: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
});
|
||||
|
||||
const newTabRef = useRef<Window | null>(null);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
newTabRef.current = null;
|
||||
onSubscriptionUpdate();
|
||||
}, [onSubscriptionUpdate]);
|
||||
|
||||
const upgrade = useCallback(() => {
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.focus();
|
||||
} else {
|
||||
trigger(
|
||||
{ recurring },
|
||||
{
|
||||
onSuccess: data => {
|
||||
// FIXME: safari prevents from opening new tab by window api
|
||||
// TODO(@xp): what if electron?
|
||||
const newTab = window.open(
|
||||
data.checkout,
|
||||
'_blank',
|
||||
'noopener noreferrer'
|
||||
);
|
||||
|
||||
if (newTab) {
|
||||
newTabRef.current = newTab;
|
||||
|
||||
newTab.addEventListener('close', onClose);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [trigger, recurring, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.removeEventListener('close', onClose);
|
||||
newTabRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
const scroll = (
|
||||
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
||||
{Array.from(planDetail.values()).map(detail => {
|
||||
return (
|
||||
<PlanCard
|
||||
key={detail.plan}
|
||||
onSubscriptionUpdate={mutateSubscription}
|
||||
{...{ detail, subscription, recurring }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={upgrade}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeRecurring = ({
|
||||
from: _from /* TODO: from can be useful when showing confirmation modal */,
|
||||
to,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
from: SubscriptionRecurring;
|
||||
to: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: updateSubscriptionMutation,
|
||||
});
|
||||
|
||||
const change = useCallback(() => {
|
||||
trigger(
|
||||
{ recurring: to },
|
||||
{
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.updateSubscriptionRecurring);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, onSubscriptionUpdate, to]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={change /* TODO: popup confirmation modal instead */}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Change to {to} Billing
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactSales = () => {
|
||||
return (
|
||||
// TODO: add action
|
||||
<Button className={styles.planAction} type="primary">
|
||||
Contact Sales
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentPlan = () => {
|
||||
return <Button className={styles.planAction}>Current Plan</Button>;
|
||||
};
|
||||
|
||||
const SignupAction = ({ children }: PropsWithChildren) => {
|
||||
// TODO: add login action
|
||||
return (
|
||||
<Button className={styles.planAction} type="primary">
|
||||
{children}
|
||||
</Button>
|
||||
<PlanLayout scrollRef={scrollWrapper} {...{ subtitle, tabs, scroll }} />
|
||||
);
|
||||
};
|
||||
|
||||
export const AFFiNECloudPlans = () => {
|
||||
return (
|
||||
// TODO: loading skeleton
|
||||
// TODO: Error Boundary
|
||||
<Suspense>
|
||||
<Suspense fallback={<PlansSkeleton />}>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -0,0 +1,36 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const plansLayoutRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
});
|
||||
|
||||
export const scrollArea = style({
|
||||
marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
|
||||
paddingLeft: 'var(--setting-modal-gap-x)',
|
||||
width: 'var(--setting-modal-width)',
|
||||
overflowX: 'auto',
|
||||
scrollSnapType: 'x mandatory',
|
||||
paddingBottom: '21px',
|
||||
|
||||
'::-webkit-scrollbar': {
|
||||
display: 'block',
|
||||
height: '5px',
|
||||
background: 'transparent',
|
||||
},
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
background: 'var(--affine-icon-secondary)',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
});
|
||||
|
||||
export const allPlansLink = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
color: 'var(--affine-link-color)',
|
||||
background: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
});
|
@ -0,0 +1,52 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { ArrowRightBigIcon } from '@blocksuite/icons';
|
||||
import type { HtmlHTMLAttributes, ReactNode } 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 = () => (
|
||||
<a
|
||||
className={styles.allPlansLink}
|
||||
href="https://affine.pro/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See all plans
|
||||
{<ArrowRightBigIcon width="16" height="16" />}
|
||||
</a>
|
||||
);
|
||||
|
||||
export const PlanLayout = ({
|
||||
subtitle,
|
||||
tabs,
|
||||
scroll,
|
||||
title = 'Pricing Plans',
|
||||
footer = <SeeAllLink />,
|
||||
scrollRef,
|
||||
}: PlanLayoutProps) => {
|
||||
return (
|
||||
<div className={styles.plansLayoutRoot}>
|
||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||
<SettingHeader
|
||||
style={{ marginBottom: '0px' }}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
{tabs}
|
||||
<div ref={scrollRef} className={styles.scrollArea}>
|
||||
{scroll}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,381 @@
|
||||
import type {
|
||||
Subscription,
|
||||
SubscriptionMutator,
|
||||
} from '@affine/core/hooks/use-subscription';
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
checkoutMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useMutation } from '@affine/workspace/affine/gql';
|
||||
import { DoneIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
|
||||
import { BulledListIcon } from './icons/bulled-list';
|
||||
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;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}
|
||||
|
||||
export function getPlanDetail() {
|
||||
// const t = useAFFiNEI18N();
|
||||
|
||||
// TODO: i18n all things
|
||||
return new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
|
||||
[
|
||||
SubscriptionPlan.Free,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Free,
|
||||
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',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Pro,
|
||||
{
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
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',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Team,
|
||||
{
|
||||
type: 'dynamic',
|
||||
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.',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
SubscriptionPlan.Enterprise,
|
||||
{
|
||||
type: 'dynamic',
|
||||
plan: SubscriptionPlan.Enterprise,
|
||||
contact: true,
|
||||
benefits: [
|
||||
'Solutions & best practices for dedicated needs.',
|
||||
'Embedable & interrogations with IT support.',
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export const PlanCard = ({
|
||||
detail,
|
||||
subscription,
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
}: PlanCardProps) => {
|
||||
const loggedIn = useCurrentLoginStatus() === 'authenticated';
|
||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||
const currentRecurring = subscription?.recurring;
|
||||
|
||||
const isCurrent =
|
||||
loggedIn &&
|
||||
detail.plan === currentPlan &&
|
||||
(currentPlan === SubscriptionPlan.Free
|
||||
? true
|
||||
: currentRecurring === recurring);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-current={isCurrent}
|
||||
key={detail.plan}
|
||||
className={isCurrent ? styles.currentPlanCard : styles.planCard}
|
||||
>
|
||||
<div className={styles.planTitle}>
|
||||
<p>
|
||||
{detail.plan}{' '}
|
||||
{'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}>per month</span>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentPlan = () => {
|
||||
return <Button className={styles.planAction}>Current Plan</Button>;
|
||||
};
|
||||
|
||||
const Downgrade = ({
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: cancelSubscriptionMutation,
|
||||
});
|
||||
|
||||
const downgrade = useCallback(() => {
|
||||
trigger(null, {
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.cancelSubscription);
|
||||
},
|
||||
});
|
||||
}, [trigger, onSubscriptionUpdate]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={downgrade /* TODO: poppup confirmation modal instead */}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Downgrade
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactSales = () => {
|
||||
return (
|
||||
// TODO: add action
|
||||
<Button className={styles.planAction} type="primary">
|
||||
Contact Sales
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const Upgrade = ({
|
||||
recurring,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
recurring: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
});
|
||||
|
||||
const newTabRef = useRef<Window | null>(null);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
newTabRef.current = null;
|
||||
onSubscriptionUpdate();
|
||||
}, [onSubscriptionUpdate]);
|
||||
|
||||
const upgrade = useCallback(() => {
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.focus();
|
||||
} else {
|
||||
trigger(
|
||||
{ recurring },
|
||||
{
|
||||
onSuccess: data => {
|
||||
// FIXME: safari prevents from opening new tab by window api
|
||||
// TODO(@xp): what if electron?
|
||||
const newTab = window.open(
|
||||
data.checkout,
|
||||
'_blank',
|
||||
'noopener noreferrer'
|
||||
);
|
||||
|
||||
if (newTab) {
|
||||
newTabRef.current = newTab;
|
||||
|
||||
newTab.addEventListener('close', onClose);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [trigger, recurring, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (newTabRef.current) {
|
||||
newTabRef.current.removeEventListener('close', onClose);
|
||||
newTabRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={upgrade}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeRecurring = ({
|
||||
from: _from /* TODO: from can be useful when showing confirmation modal */,
|
||||
to,
|
||||
onSubscriptionUpdate,
|
||||
}: {
|
||||
from: SubscriptionRecurring;
|
||||
to: SubscriptionRecurring;
|
||||
onSubscriptionUpdate: SubscriptionMutator;
|
||||
}) => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: updateSubscriptionMutation,
|
||||
});
|
||||
|
||||
const change = useCallback(() => {
|
||||
trigger(
|
||||
{ recurring: to },
|
||||
{
|
||||
onSuccess: data => {
|
||||
onSubscriptionUpdate(data.updateSubscriptionRecurring);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [trigger, onSubscriptionUpdate, to]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
type="primary"
|
||||
onClick={change /* TODO: popup confirmation modal instead */}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
>
|
||||
Change to {to} Billing
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const SignupAction = ({ children }: PropsWithChildren) => {
|
||||
// TODO: add login action
|
||||
return (
|
||||
<Button className={styles.planAction} type="primary">
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const plansWrapper = style({
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
});
|
||||
|
||||
export const planItemCard = style({
|
||||
width: '258px',
|
||||
height: '426px',
|
||||
flexShrink: '0',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
});
|
||||
|
||||
export const planItemHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
});
|
||||
export const planItemContent = style({
|
||||
flexGrow: '1',
|
||||
height: 0,
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { Skeleton } from '@mui/material';
|
||||
|
||||
import { PlanLayout } from './layout';
|
||||
import * as styles from './skeleton.css';
|
||||
|
||||
/**
|
||||
* Customize Skeleton component with rounded border radius
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
const RoundedSkeleton = ({
|
||||
radius = 8,
|
||||
...props
|
||||
}: {
|
||||
radius?: number;
|
||||
} & React.ComponentProps<typeof Skeleton>) => (
|
||||
<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
|
||||
// set to `24px` for now to avoid blinking
|
||||
<Skeleton variant="rounded" width="256px" height="24px" />
|
||||
);
|
||||
|
||||
const PlanItemSkeleton = () => (
|
||||
<div className={styles.planItemCard}>
|
||||
<header className={styles.planItemHeader}>
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="60px" />
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="28px" />
|
||||
</header>
|
||||
|
||||
<main className={styles.planItemContent}>
|
||||
<RoundedSkeleton variant="rounded" width="100%" height="100%" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScrollSkeleton = () => (
|
||||
<div className={styles.plansWrapper}>
|
||||
<PlanItemSkeleton />
|
||||
<PlanItemSkeleton />
|
||||
<PlanItemSkeleton />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PlansSkeleton = () => {
|
||||
return (
|
||||
<PlanLayout
|
||||
subtitle={<SubtitleSkeleton />}
|
||||
tabs={<TabsSkeleton />}
|
||||
scroll={<ScrollSkeleton />}
|
||||
/>
|
||||
);
|
||||
};
|
@ -14,16 +14,10 @@ export const radioButtonDiscount = style({
|
||||
});
|
||||
|
||||
export const planCardsWrapper = style({
|
||||
marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
|
||||
paddingLeft: 'var(--setting-modal-gap-x)',
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x) + 300px)',
|
||||
width: 'var(--setting-modal-width)',
|
||||
marginTop: '24px',
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x))',
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
scrollSnapType: 'x mandatory',
|
||||
// TODO: should display the horizontal scrollbar, ensure the box-shadow is not clipped
|
||||
paddingBottom: '21px',
|
||||
gap: '16px',
|
||||
width: 'fit-content',
|
||||
});
|
||||
|
||||
export const planCard = style({
|
||||
@ -35,9 +29,6 @@ export const planCard = style({
|
||||
position: 'relative',
|
||||
|
||||
selectors: {
|
||||
'&:not(:last-child)': {
|
||||
marginRight: '16px',
|
||||
},
|
||||
'&::before': {
|
||||
content: '',
|
||||
position: 'absolute',
|
||||
@ -101,27 +92,27 @@ export const planAction = style({
|
||||
export const planBenefits = style({
|
||||
marginTop: '20px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const planBenefit = style({
|
||||
display: 'flex',
|
||||
selectors: {
|
||||
'&:not(:last-child)': {
|
||||
marginBottom: '8px',
|
||||
},
|
||||
},
|
||||
gap: '8px',
|
||||
lineHeight: '20px',
|
||||
alignItems: 'normal',
|
||||
fontSize: '12px',
|
||||
});
|
||||
|
||||
export const planBenefitIcon = style({
|
||||
display: 'inline-block',
|
||||
marginRight: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '20px',
|
||||
});
|
||||
|
||||
export const allPlansLink = style({
|
||||
display: 'block',
|
||||
marginTop: '36px',
|
||||
color: 'var(--affine-primary-color)',
|
||||
background: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
export const planBenefitText = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
@ -54,7 +54,7 @@ export const SettingModal = ({
|
||||
const paddingX = parseInt(computedStyle.paddingLeft, 10);
|
||||
modalContentRef.current?.style.setProperty(
|
||||
'--setting-modal-width',
|
||||
`${contentWidth + marginX * 2 + paddingX * 2}px`
|
||||
`${contentWidth + marginX * 2}px`
|
||||
);
|
||||
modalContentRef.current?.style.setProperty(
|
||||
'--setting-modal-gap-x',
|
||||
|
Loading…
Reference in New Issue
Block a user