feat(core): support signup set password before goto stripe payment url (#4892)

This commit is contained in:
Joooye_34
2023-11-09 19:58:16 +08:00
committed by GitHub
parent 405167854b
commit af72bf0f69
8 changed files with 143 additions and 53 deletions

View File

@@ -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 (

View File

@@ -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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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