mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(core): support signup set password before goto stripe payment url (#4892)
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { Button } from '@toeverything/components/button';
|
import { Button } from '@toeverything/components/button';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { pushNotificationAtom } from '../notification-center';
|
||||||
import { AuthPageContainer } from './auth-page-container';
|
import { AuthPageContainer } from './auth-page-container';
|
||||||
import { SetPassword } from './set-password';
|
import { SetPassword } from './set-password';
|
||||||
type User = {
|
type User = {
|
||||||
@@ -14,18 +16,27 @@ type User = {
|
|||||||
|
|
||||||
export const ChangePasswordPage: FC<{
|
export const ChangePasswordPage: FC<{
|
||||||
user: User;
|
user: User;
|
||||||
onSetPassword: (password: string) => void;
|
onSetPassword: (password: string) => Promise<void>;
|
||||||
onOpenAffine: () => void;
|
onOpenAffine: () => void;
|
||||||
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [hasSetUp, setHasSetUp] = useState(false);
|
const [hasSetUp, setHasSetUp] = useState(false);
|
||||||
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
|
|
||||||
const onSetPassword = useCallback(
|
const onSetPassword = useCallback(
|
||||||
(passWord: string) => {
|
(passWord: string) => {
|
||||||
propsOnSetPassword(passWord);
|
propsOnSetPassword(passWord)
|
||||||
setHasSetUp(true);
|
.then(() => setHasSetUp(true))
|
||||||
|
.catch(e =>
|
||||||
|
pushNotification({
|
||||||
|
title: t['com.affine.auth.password.set-failed'](),
|
||||||
|
message: String(e),
|
||||||
|
key: Date.now().toString(),
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[propsOnSetPassword]
|
[propsOnSetPassword, t, pushNotification]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { Button } from '@toeverything/components/button';
|
import { Button } from '@toeverything/components/button';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { pushNotificationAtom } from '../notification-center';
|
||||||
import { AuthPageContainer } from './auth-page-container';
|
import { AuthPageContainer } from './auth-page-container';
|
||||||
import { SetPassword } from './set-password';
|
import { SetPassword } from './set-password';
|
||||||
|
|
||||||
@@ -15,18 +17,27 @@ type User = {
|
|||||||
|
|
||||||
export const SetPasswordPage: FC<{
|
export const SetPasswordPage: FC<{
|
||||||
user: User;
|
user: User;
|
||||||
onSetPassword: (password: string) => void;
|
onSetPassword: (password: string) => Promise<void>;
|
||||||
onOpenAffine: () => void;
|
onOpenAffine: () => void;
|
||||||
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [hasSetUp, setHasSetUp] = useState(false);
|
const [hasSetUp, setHasSetUp] = useState(false);
|
||||||
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
|
|
||||||
const onSetPassword = useCallback(
|
const onSetPassword = useCallback(
|
||||||
(passWord: string) => {
|
(passWord: string) => {
|
||||||
propsOnSetPassword(passWord);
|
propsOnSetPassword(passWord)
|
||||||
setHasSetUp(true);
|
.then(() => setHasSetUp(true))
|
||||||
|
.catch(e =>
|
||||||
|
pushNotification({
|
||||||
|
title: t['com.affine.auth.password.set-failed'](),
|
||||||
|
message: String(e),
|
||||||
|
key: Date.now().toString(),
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[propsOnSetPassword]
|
[propsOnSetPassword, pushNotification, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { Button } from '@toeverything/components/button';
|
import { Button } from '@toeverything/components/button';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { pushNotificationAtom } from '../notification-center';
|
||||||
import { AuthPageContainer } from './auth-page-container';
|
import { AuthPageContainer } from './auth-page-container';
|
||||||
import { SetPassword } from './set-password';
|
import { SetPassword } from './set-password';
|
||||||
type User = {
|
type User = {
|
||||||
@@ -14,18 +16,33 @@ type User = {
|
|||||||
|
|
||||||
export const SignUpPage: FC<{
|
export const SignUpPage: FC<{
|
||||||
user: User;
|
user: User;
|
||||||
onSetPassword: (password: string) => void;
|
onSetPassword: (password: string) => Promise<void>;
|
||||||
|
openButtonText?: string;
|
||||||
onOpenAffine: () => void;
|
onOpenAffine: () => void;
|
||||||
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
}> = ({
|
||||||
|
user: { email },
|
||||||
|
onSetPassword: propsOnSetPassword,
|
||||||
|
onOpenAffine,
|
||||||
|
openButtonText,
|
||||||
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [hasSetUp, setHasSetUp] = useState(false);
|
const [hasSetUp, setHasSetUp] = useState(false);
|
||||||
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
|
|
||||||
const onSetPassword = useCallback(
|
const onSetPassword = useCallback(
|
||||||
(passWord: string) => {
|
(passWord: string) => {
|
||||||
propsOnSetPassword(passWord);
|
propsOnSetPassword(passWord)
|
||||||
setHasSetUp(true);
|
.then(() => setHasSetUp(true))
|
||||||
|
.catch(e =>
|
||||||
|
pushNotification({
|
||||||
|
title: t['com.affine.auth.password.set-failed'](),
|
||||||
|
message: String(e),
|
||||||
|
key: Date.now().toString(),
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[propsOnSetPassword]
|
[propsOnSetPassword, pushNotification, t]
|
||||||
);
|
);
|
||||||
const onLater = useCallback(() => {
|
const onLater = useCallback(() => {
|
||||||
setHasSetUp(true);
|
setHasSetUp(true);
|
||||||
@@ -51,7 +68,7 @@ export const SignUpPage: FC<{
|
|||||||
>
|
>
|
||||||
{hasSetUp ? (
|
{hasSetUp ? (
|
||||||
<Button type="primary" size="large" onClick={onOpenAffine}>
|
<Button type="primary" size="large" onClick={onOpenAffine}>
|
||||||
{t['com.affine.auth.open.affine']()}
|
{openButtonText ?? t['com.affine.auth.open.affine']()}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<SetPassword
|
<SetPassword
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { SignUpPage } from '@affine/component/auth-components';
|
||||||
import { AffineShapeIcon } from '@affine/component/page-list';
|
import { AffineShapeIcon } from '@affine/component/page-list';
|
||||||
import type { SubscriptionRecurring } from '@affine/graphql';
|
import type { SubscriptionRecurring } from '@affine/graphql';
|
||||||
import { checkoutMutation, subscriptionQuery } from '@affine/graphql';
|
import {
|
||||||
|
changePasswordMutation,
|
||||||
|
checkoutMutation,
|
||||||
|
subscriptionQuery,
|
||||||
|
} from '@affine/graphql';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||||
import { Button } from '@toeverything/components/button';
|
import { Button } from '@toeverything/components/button';
|
||||||
import { Loading } from '@toeverything/components/loading';
|
import { Loading } from '@toeverything/components/loading';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { type FC, Suspense, useCallback, useEffect, useMemo } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
|
||||||
import {
|
import {
|
||||||
RouteLogic,
|
RouteLogic,
|
||||||
useNavigateHelper,
|
useNavigateHelper,
|
||||||
@@ -15,6 +21,27 @@ import {
|
|||||||
import * as styles from './subscription-redirect.css';
|
import * as styles from './subscription-redirect.css';
|
||||||
import { useSubscriptionSearch } from './use-subscription';
|
import { useSubscriptionSearch } from './use-subscription';
|
||||||
|
|
||||||
|
const usePaymentRedirect = () => {
|
||||||
|
const searchData = useSubscriptionSearch();
|
||||||
|
if (!searchData?.recurring) {
|
||||||
|
throw new Error('Invalid recurring data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurring = searchData.recurring as SubscriptionRecurring;
|
||||||
|
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||||
|
const { trigger: checkoutSubscription } = useMutation({
|
||||||
|
mutation: checkoutMutation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useCallback(() => {
|
||||||
|
checkoutSubscription({ recurring, idempotencyKey })
|
||||||
|
.then(({ checkout }) => {
|
||||||
|
window.open(checkout, '_self', 'norefferer');
|
||||||
|
})
|
||||||
|
.catch(e => console.error(e));
|
||||||
|
}, [recurring, idempotencyKey, checkoutSubscription]);
|
||||||
|
};
|
||||||
|
|
||||||
const CenterLoading = () => {
|
const CenterLoading = () => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
@@ -51,54 +78,65 @@ const SubscriptionExisting = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SubscriptionRedirectInner: FC = () => {
|
const SubscriptionRedirection = ({ redirect }: { redirect: () => void }) => {
|
||||||
const subscriptionData = useSubscriptionSearch();
|
|
||||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
|
||||||
const { data } = useQuery({
|
|
||||||
query: subscriptionQuery,
|
|
||||||
});
|
|
||||||
const { trigger: checkoutSubscription } = useMutation({
|
|
||||||
mutation: checkoutMutation,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subscriptionData) {
|
|
||||||
throw new Error('No subscription data found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.currentUser?.subscription) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This component will be render multiple times, use timeout to avoid multiple effect.
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
const recurring = subscriptionData.recurring as SubscriptionRecurring;
|
redirect();
|
||||||
checkoutSubscription({ recurring, idempotencyKey }).then(
|
|
||||||
({ checkout }) => {
|
|
||||||
window.open(checkout, '_self', 'norefferer');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
|
}, [redirect]);
|
||||||
|
|
||||||
// Just run this once, do not react to changes
|
return <CenterLoading />;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (data.currentUser?.subscription) {
|
const SubscriptionRedirectWithData = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const user = useCurrentUser();
|
||||||
|
const searchData = useSubscriptionSearch();
|
||||||
|
const openPaymentUrl = usePaymentRedirect();
|
||||||
|
|
||||||
|
const { trigger: changePassword } = useMutation({
|
||||||
|
mutation: changePasswordMutation,
|
||||||
|
});
|
||||||
|
const { data: subscriptionData } = useQuery({
|
||||||
|
query: subscriptionQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSetPassword = useCallback(
|
||||||
|
async (password: string) => {
|
||||||
|
await changePassword({
|
||||||
|
token: searchData?.passwordToken ?? '',
|
||||||
|
newPassword: password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[changePassword, searchData]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchData?.withSignUp) {
|
||||||
|
return (
|
||||||
|
<SignUpPage
|
||||||
|
user={user}
|
||||||
|
onSetPassword={onSetPassword}
|
||||||
|
onOpenAffine={openPaymentUrl}
|
||||||
|
openButtonText={t['com.affine.payment.subscription.go-to-subscribe']()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionData.currentUser?.subscription) {
|
||||||
return <SubscriptionExisting />;
|
return <SubscriptionExisting />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CenterLoading />;
|
return <SubscriptionRedirection redirect={openPaymentUrl} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubscriptionRedirect = () => {
|
export const SubscriptionRedirect = () => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<CenterLoading />}>
|
<Suspense fallback={<CenterLoading />}>
|
||||||
<SubscriptionRedirectInner />
|
<SubscriptionRedirectWithData />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const useAuth = () => {
|
|||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
callbackUrl: subscriptionData
|
callbackUrl: subscriptionData
|
||||||
? subscriptionData.redirectUrl
|
? subscriptionData.getRedirectUrl(false)
|
||||||
: '/auth/signIn',
|
: '/auth/signIn',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
},
|
},
|
||||||
@@ -119,7 +119,7 @@ export const useAuth = () => {
|
|||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
callbackUrl: subscriptionData
|
callbackUrl: subscriptionData
|
||||||
? subscriptionData.redirectUrl
|
? subscriptionData.getRedirectUrl(true)
|
||||||
: '/auth/signUp',
|
: '/auth/signUp',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
enum SubscriptionKey {
|
enum SubscriptionKey {
|
||||||
Recurring = 'subscription_recurring',
|
Recurring = 'subscription_recurring',
|
||||||
Plan = 'subscription_plan',
|
Plan = 'subscription_plan',
|
||||||
|
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
|
||||||
|
Token = 'token', // When signup, there should have a token to set password
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubscriptionSearch() {
|
export function useSubscriptionSearch() {
|
||||||
@@ -20,14 +22,23 @@ export function useSubscriptionSearch() {
|
|||||||
|
|
||||||
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
||||||
const plan = searchParams.get(SubscriptionKey.Plan);
|
const plan = searchParams.get(SubscriptionKey.Plan);
|
||||||
|
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
||||||
|
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
||||||
return {
|
return {
|
||||||
recurring,
|
recurring,
|
||||||
plan,
|
plan,
|
||||||
get redirectUrl() {
|
withSignUp,
|
||||||
|
passwordToken,
|
||||||
|
getRedirectUrl(signUp?: boolean) {
|
||||||
const paymentParams = new URLSearchParams([
|
const paymentParams = new URLSearchParams([
|
||||||
[SubscriptionKey.Recurring, recurring ?? ''],
|
[SubscriptionKey.Recurring, recurring ?? ''],
|
||||||
[SubscriptionKey.Plan, plan ?? ''],
|
[SubscriptionKey.Plan, plan ?? ''],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (signUp) {
|
||||||
|
paymentParams.set(SubscriptionKey.SignUp, '1');
|
||||||
|
}
|
||||||
|
|
||||||
return `/auth/subscription-redirect?${paymentParams.toString()}`;
|
return `/auth/subscription-redirect?${paymentParams.toString()}`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,11 +80,11 @@ export const AuthPage = (): ReactElement | null => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onSetPassword = useCallback(
|
const onSetPassword = useCallback(
|
||||||
(password: string) => {
|
async (password: string) => {
|
||||||
changePassword({
|
await changePassword({
|
||||||
token: searchParams.get('token') || '',
|
token: searchParams.get('token') || '',
|
||||||
newPassword: password,
|
newPassword: password,
|
||||||
}).catch(console.error);
|
});
|
||||||
},
|
},
|
||||||
[changePassword, searchParams]
|
[changePassword, searchParams]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"com.affine.auth.page.sent.email.subtitle": "Please set a password of 8-20 characters with both letters and numbers to continue signing up with ",
|
"com.affine.auth.page.sent.email.subtitle": "Please set a password of 8-20 characters with both letters and numbers to continue signing up with ",
|
||||||
"com.affine.auth.page.sent.email.title": "Welcome to AFFiNE Cloud, you are almost there!",
|
"com.affine.auth.page.sent.email.title": "Welcome to AFFiNE Cloud, you are almost there!",
|
||||||
"com.affine.auth.password": "Password",
|
"com.affine.auth.password": "Password",
|
||||||
|
"com.affine.auth.password.set-failed": "Set Password Failed",
|
||||||
"com.affine.auth.password.error": "Invalid password",
|
"com.affine.auth.password.error": "Invalid password",
|
||||||
"com.affine.auth.reset.password": "Reset Password",
|
"com.affine.auth.reset.password": "Reset Password",
|
||||||
"com.affine.auth.reset.password.message": "You will receive an email with a link to reset your password. Please check your inbox.",
|
"com.affine.auth.reset.password.message": "You will receive an email with a link to reset your password. Please check your inbox.",
|
||||||
@@ -767,6 +768,7 @@
|
|||||||
"com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.",
|
"com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.",
|
||||||
"com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support</1>.",
|
"com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support</1>.",
|
||||||
"com.affine.payment.subscription.exist": "You already have a subscription.",
|
"com.affine.payment.subscription.exist": "You already have a subscription.",
|
||||||
|
"com.affine.payment.subscription.go-to-subscribe": "Subscribe AFFiNE",
|
||||||
"com.affine.other-page.nav.official-website": "Official Website",
|
"com.affine.other-page.nav.official-website": "Official Website",
|
||||||
"com.affine.other-page.nav.affine-community": "AFFiNE Community",
|
"com.affine.other-page.nav.affine-community": "AFFiNE Community",
|
||||||
"com.affine.other-page.nav.blog": "Blog",
|
"com.affine.other-page.nav.blog": "Blog",
|
||||||
|
|||||||
Reference in New Issue
Block a user