feat(core): payment plans skeleton (#4715)
This commit is contained in:
@@ -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,324 +75,66 @@ const Settings = () => {
|
|||||||
};
|
};
|
||||||
}, [recurring]);
|
}, [recurring]);
|
||||||
|
|
||||||
return (
|
const subtitle = loggedIn ? (
|
||||||
<>
|
<p>
|
||||||
<SettingHeader
|
You are current on the {currentPlan} plan. If you have any questions,
|
||||||
title="Plans"
|
please contact our <span>{/*TODO: add action*/}customer support</span>.
|
||||||
subtitle={
|
</p>
|
||||||
loggedIn ? (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
You are current on the {currentPlan} plan. If you have any
|
This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to
|
||||||
questions, please contact our{' '}
|
your account first.
|
||||||
<span>{/*TODO: add action*/}customer support</span>.
|
</p>
|
||||||
</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 Downgrade = ({
|
const getRecurringLabel = (recurring: SubscriptionRecurring) =>
|
||||||
onSubscriptionUpdate,
|
({
|
||||||
}: {
|
[SubscriptionRecurring.Monthly]: 'Monthly',
|
||||||
onSubscriptionUpdate: SubscriptionMutator;
|
[SubscriptionRecurring.Yearly]: 'Annually',
|
||||||
}) => {
|
})[recurring];
|
||||||
const { isMutating, trigger } = useMutation({
|
|
||||||
mutation: cancelSubscriptionMutation,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downgrade = useCallback(() => {
|
const tabs = (
|
||||||
trigger(null, {
|
<RadioButtonGroup
|
||||||
onSuccess: data => {
|
className={styles.recurringRadioGroup}
|
||||||
onSubscriptionUpdate(data.cancelSubscription);
|
value={recurring}
|
||||||
},
|
onValueChange={setRecurring}
|
||||||
});
|
|
||||||
}, [trigger, onSubscriptionUpdate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={styles.planAction}
|
|
||||||
type="primary"
|
|
||||||
onClick={downgrade /* TODO: poppup confirmation modal instead */}
|
|
||||||
disabled={isMutating}
|
|
||||||
loading={isMutating}
|
|
||||||
>
|
>
|
||||||
Downgrade
|
{Object.values(SubscriptionRecurring).map(recurring => (
|
||||||
</Button>
|
<RadioButton key={recurring} value={recurring}>
|
||||||
|
{getRecurringLabel(recurring)}
|
||||||
|
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||||
|
<span className={styles.radioButtonDiscount}>
|
||||||
|
{yearlyDiscount}% off
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioButton>
|
||||||
|
))}
|
||||||
|
</RadioButtonGroup>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const Upgrade = ({
|
const scroll = (
|
||||||
recurring,
|
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
||||||
onSubscriptionUpdate,
|
{Array.from(planDetail.values()).map(detail => {
|
||||||
}: {
|
return (
|
||||||
recurring: SubscriptionRecurring;
|
<PlanCard
|
||||||
onSubscriptionUpdate: SubscriptionMutator;
|
key={detail.plan}
|
||||||
}) => {
|
onSubscriptionUpdate={mutateSubscription}
|
||||||
const { isMutating, trigger } = useMutation({
|
{...{ detail, subscription, recurring }}
|
||||||
mutation: checkoutMutation,
|
/>
|
||||||
});
|
);
|
||||||
|
})}
|
||||||
const newTabRef = useRef<Window | null>(null);
|
</div>
|
||||||
|
);
|
||||||
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 (
|
return (
|
||||||
<Button
|
<PlanLayout scrollRef={scrollWrapper} {...{ subtitle, tabs, scroll }} />
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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({
|
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)',
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user