feat(core): payment plans skeleton (#4715)

This commit is contained in:
Cats Juice
2023-10-25 16:16:50 +08:00
committed by GitHub
parent e8a88da9e4
commit eaa90c9fb6
8 changed files with 629 additions and 431 deletions

View File

@@ -1,115 +1,23 @@
import { RadioButton, RadioButtonGroup } from '@affine/component'; import { RadioButton, RadioButtonGroup } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { import {
cancelSubscriptionMutation,
checkoutMutation,
pricesQuery, pricesQuery,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
updateSubscriptionMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql'; import { useQuery } from '@affine/workspace/affine/gql';
import { DoneIcon } from '@blocksuite/icons'; import { Suspense, useEffect, useRef, useState } from 'react';
import { Button } from '@toeverything/components/button';
import {
type PropsWithChildren,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { import { useUserSubscription } from '../../../../../hooks/use-subscription';
type SubscriptionMutator, import { PlanLayout } from './layout';
useUserSubscription, import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card';
} from '../../../../../hooks/use-subscription'; import { PlansSkeleton } from './skeleton';
import { BulledListIcon } from './icons/bulled-list';
import * as styles from './style.css'; 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 Settings = () => {
const [subscription, mutateSubscription] = useUserSubscription(); const [subscription, mutateSubscription] = useUserSubscription();
const loggedIn = useCurrentLoginStatus() === 'authenticated'; const loggedIn = useCurrentLoginStatus() === 'authenticated';
const planDetail = getPlanDetail();
const scrollWrapper = useRef<HTMLDivElement>(null); const scrollWrapper = useRef<HTMLDivElement>(null);
const { const {
@@ -136,7 +44,6 @@ const Settings = () => {
); );
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = subscription?.recurring;
const yearlyDiscount = ( const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
@@ -168,35 +75,34 @@ const Settings = () => {
}; };
}, [recurring]); }, [recurring]);
return ( const subtitle = loggedIn ? (
<>
<SettingHeader
title="Plans"
subtitle={
loggedIn ? (
<p> <p>
You are current on the {currentPlan} plan. If you have any You are current on the {currentPlan} plan. If you have any questions,
questions, please contact our{' '} please contact our <span>{/*TODO: add action*/}customer support</span>.
<span>{/*TODO: add action*/}customer support</span>.
</p> </p>
) : ( ) : (
<p> <p>
This is the Pricing plans of AFFiNE Cloud. You can sign up or sign This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to
in to your account first. your account first.
</p> </p>
) );
}
/> const getRecurringLabel = (recurring: SubscriptionRecurring) =>
<div className={styles.wrapper}> ({
[SubscriptionRecurring.Monthly]: 'Monthly',
[SubscriptionRecurring.Yearly]: 'Annually',
})[recurring];
const tabs = (
<RadioButtonGroup <RadioButtonGroup
className={styles.recurringRadioGroup} className={styles.recurringRadioGroup}
value={recurring} value={recurring}
onValueChange={setRecurring} onValueChange={setRecurring}
> >
{Object.values(SubscriptionRecurring).map(plan => ( {Object.values(SubscriptionRecurring).map(recurring => (
<RadioButton key={plan} value={plan}> <RadioButton key={recurring} value={recurring}>
{plan} {getRecurringLabel(recurring)}
{plan === SubscriptionRecurring.Yearly && yearlyDiscount && ( {recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
<span className={styles.radioButtonDiscount}> <span className={styles.radioButtonDiscount}>
{yearlyDiscount}% off {yearlyDiscount}% off
</span> </span>
@@ -204,288 +110,31 @@ const Settings = () => {
</RadioButton> </RadioButton>
))} ))}
</RadioButtonGroup> </RadioButtonGroup>
);
const scroll = (
<div className={styles.planCardsWrapper} ref={scrollWrapper}> <div className={styles.planCardsWrapper} ref={scrollWrapper}>
{Array.from(planDetail.values()).map(detail => { {Array.from(planDetail.values()).map(detail => {
const isCurrent =
loggedIn &&
detail.plan === currentPlan &&
(currentPlan === SubscriptionPlan.Free
? true
: currentRecurring === recurring);
return ( return (
<div <PlanCard
data-current={isCurrent}
key={detail.plan} 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} onSubscriptionUpdate={mutateSubscription}
{...{ detail, subscription, recurring }}
/> />
) : (
<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> </div>
<a
className={styles.allPlansLink}
href="https://affine.pro/pricing"
target="_blank"
rel="noopener noreferrer"
>
See all plans →{/* TODO: icon */}
</a>
</div>
</>
); );
};
const Downgrade = ({
onSubscriptionUpdate,
}: {
onSubscriptionUpdate: SubscriptionMutator;
}) => {
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const downgrade = useCallback(() => {
trigger(null, {
onSuccess: data => {
onSubscriptionUpdate(data.cancelSubscription);
},
});
}, [trigger, onSubscriptionUpdate]);
return ( return (
<Button <PlanLayout scrollRef={scrollWrapper} {...{ subtitle, tabs, scroll }} />
className={styles.planAction}
type="primary"
onClick={downgrade /* TODO: poppup confirmation modal instead */}
disabled={isMutating}
loading={isMutating}
>
Downgrade
</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 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>
); );
}; };
export const AFFiNECloudPlans = () => { export const AFFiNECloudPlans = () => {
return ( return (
// TODO: loading skeleton
// TODO: Error Boundary // TODO: Error Boundary
<Suspense> <Suspense fallback={<PlansSkeleton />}>
<Settings /> <Settings />
</Suspense> </Suspense>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,16 +14,10 @@ export const radioButtonDiscount = style({
}); });
export const planCardsWrapper = style({ export const planCardsWrapper = style({
marginLeft: 'calc(-1 * var(--setting-modal-gap-x))', paddingRight: 'calc(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',
display: 'flex', display: 'flex',
overflowX: 'auto', gap: '16px',
scrollSnapType: 'x mandatory', width: 'fit-content',
// TODO: should display the horizontal scrollbar, ensure the box-shadow is not clipped
paddingBottom: '21px',
}); });
export const planCard = style({ export const planCard = style({
@@ -35,9 +29,6 @@ export const planCard = style({
position: 'relative', position: 'relative',
selectors: { selectors: {
'&:not(:last-child)': {
marginRight: '16px',
},
'&::before': { '&::before': {
content: '', content: '',
position: 'absolute', position: 'absolute',
@@ -101,27 +92,27 @@ export const planAction = style({
export const planBenefits = style({ export const planBenefits = style({
marginTop: '20px', marginTop: '20px',
fontSize: 'var(--affine-font-xs)', fontSize: 'var(--affine-font-xs)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}); });
export const planBenefit = style({ export const planBenefit = style({
display: 'flex', display: 'flex',
selectors: { gap: '8px',
'&:not(:last-child)': { lineHeight: '20px',
marginBottom: '8px', alignItems: 'normal',
}, fontSize: '12px',
},
}); });
export const planBenefitIcon = style({ export const planBenefitIcon = style({
display: 'inline-block', display: 'flex',
marginRight: '8px', alignItems: 'center',
height: '20px',
}); });
export const allPlansLink = style({ export const planBenefitText = style({
display: 'block', display: 'flex',
marginTop: '36px', flexDirection: 'column',
color: 'var(--affine-primary-color)', alignItems: 'center',
background: 'transparent',
borderColor: 'transparent',
fontSize: 'var(--affine-font-xs)',
}); });

View File

@@ -54,7 +54,7 @@ export const SettingModal = ({
const paddingX = parseInt(computedStyle.paddingLeft, 10); const paddingX = parseInt(computedStyle.paddingLeft, 10);
modalContentRef.current?.style.setProperty( modalContentRef.current?.style.setProperty(
'--setting-modal-width', '--setting-modal-width',
`${contentWidth + marginX * 2 + paddingX * 2}px` `${contentWidth + marginX * 2}px`
); );
modalContentRef.current?.style.setProperty( modalContentRef.current?.style.setProperty(
'--setting-modal-gap-x', '--setting-modal-gap-x',