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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 629 additions and 431 deletions

View File

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

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

View File

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