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 { Button } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { pushNotificationAtom } from '../notification-center';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import { SetPassword } from './set-password';
|
||||
type User = {
|
||||
@@ -14,18 +16,27 @@ type User = {
|
||||
|
||||
export const ChangePasswordPage: FC<{
|
||||
user: User;
|
||||
onSetPassword: (password: string) => void;
|
||||
onSetPassword: (password: string) => Promise<void>;
|
||||
onOpenAffine: () => void;
|
||||
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [hasSetUp, setHasSetUp] = useState(false);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(passWord: string) => {
|
||||
propsOnSetPassword(passWord);
|
||||
setHasSetUp(true);
|
||||
propsOnSetPassword(passWord)
|
||||
.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 (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { pushNotificationAtom } from '../notification-center';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import { SetPassword } from './set-password';
|
||||
|
||||
@@ -15,18 +17,27 @@ type User = {
|
||||
|
||||
export const SetPasswordPage: FC<{
|
||||
user: User;
|
||||
onSetPassword: (password: string) => void;
|
||||
onSetPassword: (password: string) => Promise<void>;
|
||||
onOpenAffine: () => void;
|
||||
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [hasSetUp, setHasSetUp] = useState(false);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(passWord: string) => {
|
||||
propsOnSetPassword(passWord);
|
||||
setHasSetUp(true);
|
||||
propsOnSetPassword(passWord)
|
||||
.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 (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { pushNotificationAtom } from '../notification-center';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import { SetPassword } from './set-password';
|
||||
type User = {
|
||||
@@ -14,18 +16,33 @@ type User = {
|
||||
|
||||
export const SignUpPage: FC<{
|
||||
user: User;
|
||||
onSetPassword: (password: string) => void;
|
||||
onSetPassword: (password: string) => Promise<void>;
|
||||
openButtonText?: string;
|
||||
onOpenAffine: () => void;
|
||||
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||
}> = ({
|
||||
user: { email },
|
||||
onSetPassword: propsOnSetPassword,
|
||||
onOpenAffine,
|
||||
openButtonText,
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [hasSetUp, setHasSetUp] = useState(false);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(passWord: string) => {
|
||||
propsOnSetPassword(passWord);
|
||||
setHasSetUp(true);
|
||||
propsOnSetPassword(passWord)
|
||||
.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(() => {
|
||||
setHasSetUp(true);
|
||||
@@ -51,7 +68,7 @@ export const SignUpPage: FC<{
|
||||
>
|
||||
{hasSetUp ? (
|
||||
<Button type="primary" size="large" onClick={onOpenAffine}>
|
||||
{t['com.affine.auth.open.affine']()}
|
||||
{openButtonText ?? t['com.affine.auth.open.affine']()}
|
||||
</Button>
|
||||
) : (
|
||||
<SetPassword
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { SignUpPage } from '@affine/component/auth-components';
|
||||
import { AffineShapeIcon } from '@affine/component/page-list';
|
||||
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 { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Loading } from '@toeverything/components/loading';
|
||||
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 {
|
||||
RouteLogic,
|
||||
useNavigateHelper,
|
||||
@@ -15,6 +21,27 @@ import {
|
||||
import * as styles from './subscription-redirect.css';
|
||||
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 = () => {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
@@ -51,54 +78,65 @@ const SubscriptionExisting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const SubscriptionRedirectInner: FC = () => {
|
||||
const subscriptionData = useSubscriptionSearch();
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
const { data } = useQuery({
|
||||
query: subscriptionQuery,
|
||||
});
|
||||
const { trigger: checkoutSubscription } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
});
|
||||
|
||||
const SubscriptionRedirection = ({ redirect }: { redirect: () => void }) => {
|
||||
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 recurring = subscriptionData.recurring as SubscriptionRecurring;
|
||||
checkoutSubscription({ recurring, idempotencyKey }).then(
|
||||
({ checkout }) => {
|
||||
window.open(checkout, '_self', 'norefferer');
|
||||
}
|
||||
);
|
||||
redirect();
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [redirect]);
|
||||
|
||||
// Just run this once, do not react to changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <CenterLoading />;
|
||||
};
|
||||
|
||||
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 <CenterLoading />;
|
||||
return <SubscriptionRedirection redirect={openPaymentUrl} />;
|
||||
};
|
||||
|
||||
export const SubscriptionRedirect = () => {
|
||||
return (
|
||||
<Suspense fallback={<CenterLoading />}>
|
||||
<SubscriptionRedirectInner />
|
||||
<SubscriptionRedirectWithData />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useAuth = () => {
|
||||
{
|
||||
email: email,
|
||||
callbackUrl: subscriptionData
|
||||
? subscriptionData.redirectUrl
|
||||
? subscriptionData.getRedirectUrl(false)
|
||||
: '/auth/signIn',
|
||||
redirect: false,
|
||||
},
|
||||
@@ -119,7 +119,7 @@ export const useAuth = () => {
|
||||
{
|
||||
email: email,
|
||||
callbackUrl: subscriptionData
|
||||
? subscriptionData.redirectUrl
|
||||
? subscriptionData.getRedirectUrl(true)
|
||||
: '/auth/signUp',
|
||||
redirect: false,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useSearchParams } from 'react-router-dom';
|
||||
enum SubscriptionKey {
|
||||
Recurring = 'subscription_recurring',
|
||||
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() {
|
||||
@@ -20,14 +22,23 @@ export function useSubscriptionSearch() {
|
||||
|
||||
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
||||
const plan = searchParams.get(SubscriptionKey.Plan);
|
||||
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
||||
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
||||
return {
|
||||
recurring,
|
||||
plan,
|
||||
get redirectUrl() {
|
||||
withSignUp,
|
||||
passwordToken,
|
||||
getRedirectUrl(signUp?: boolean) {
|
||||
const paymentParams = new URLSearchParams([
|
||||
[SubscriptionKey.Recurring, recurring ?? ''],
|
||||
[SubscriptionKey.Plan, plan ?? ''],
|
||||
]);
|
||||
|
||||
if (signUp) {
|
||||
paymentParams.set(SubscriptionKey.SignUp, '1');
|
||||
}
|
||||
|
||||
return `/auth/subscription-redirect?${paymentParams.toString()}`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -80,11 +80,11 @@ export const AuthPage = (): ReactElement | null => {
|
||||
);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(password: string) => {
|
||||
changePassword({
|
||||
async (password: string) => {
|
||||
await changePassword({
|
||||
token: searchParams.get('token') || '',
|
||||
newPassword: password,
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[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.title": "Welcome to AFFiNE Cloud, you are almost there!",
|
||||
"com.affine.auth.password": "Password",
|
||||
"com.affine.auth.password.set-failed": "Set Password Failed",
|
||||
"com.affine.auth.password.error": "Invalid 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.",
|
||||
@@ -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.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.go-to-subscribe": "Subscribe AFFiNE",
|
||||
"com.affine.other-page.nav.official-website": "Official Website",
|
||||
"com.affine.other-page.nav.affine-community": "AFFiNE Community",
|
||||
"com.affine.other-page.nav.blog": "Blog",
|
||||
|
||||
Reference in New Issue
Block a user