Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multistepsignup #1104

Merged
merged 9 commits into from
May 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions components/forms/signupFormWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useRouter } from 'next/router';
import React, { useContext, useEffect, useRef, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import toast from 'react-hot-toast';
import StepWizard, { StepWizardProps } from 'react-step-wizard';
import { useSWRConfig } from 'swr';
import { debounce } from 'throttle-debounce';
import { AppContext } from '../../contexts/appContext';
import LoadingSpinner from '../page/loadingSpinner';
import FormTemplate from './formTemplate';

export default function SignupFormWizard() {
const { cache } = useSWRConfig();
const [email, setEmail] = useState<string>('');
const { mutateUser, setShouldAttemptAuth } = useContext(AppContext);
const [password, setPassword] = useState<string>('');
const recaptchaRef = useRef<ReCAPTCHA>(null);
const router = useRouter();
const [username, setUsername] = useState<string>('');

function onSubmit(event: React.FormEvent) {
event.preventDefault();

if (password.length < 8 || password.length > 50) {
toast.dismiss();
toast.error('Password must be between 8 and 50 characters');

return;
}

if (username.match(/[^-a-zA-Z0-9_]/)) {
toast.dismiss();
toast.error('Username can only contain letters, numbers, underscores, and hyphens');

return;
}

toast.dismiss();
toast.loading('Registering...');

const tutorialCompletedAt = window.localStorage.getItem('tutorialCompletedAt') || '0';
const utm_source = window.localStorage.getItem('utm_source') || '';

fetch('/api/signup', {
method: 'POST',
body: JSON.stringify({
email: email,
name: username,
password: password,
tutorialCompletedAt: parseInt(tutorialCompletedAt),
utm_source: utm_source
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
}).then(async res => {
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}

if (res.status === 200) {
const resObj = await res.json();

if (resObj.sentMessage) {
toast.dismiss();
toast.error('An account with this email already exists! Please check your email to set your password.');
} else {
// clear cache
for (const key of cache.keys()) {
cache.delete(key);
}

toast.dismiss();
toast.success('Registered! Please confirm your email.');

// clear localstorage value
window.localStorage.removeItem('tutorialCompletedAt');
mutateUser();
setShouldAttemptAuth(true);
sessionStorage.clear();
router.push('/confirm-email');
}
} else {
throw res.text();
}
}).catch(async err => {
console.error(err);
toast.dismiss();
toast.error(JSON.parse(await err)?.error);
});
}

const [isValidUsername, setIsValidUsername] = useState<boolean>(true);
const [wizard, setWizard] = useState<StepWizardProps>();
const isLoadingExistsCheck = useRef<boolean>(false);
// let's check if username exists already when user types
const checkUsername = async (username: string) => {
if (username.length < 3 || username.length > 50) {
setIsValidUsername(false);

return;
}

const res = await fetch(`/api/user/exists?name=${username}`);

isLoadingExistsCheck.current = false;
setIsValidUsername(res.status === 404);
};

// debounce the checkUsername function
const debouncedCheckUsername = useRef(
debounce(500, checkUsername)
).current;

// check if username is valid
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
isLoadingExistsCheck.current = true;
debouncedCheckUsername(e.target.value);
};

useEffect(() => {
if (username.length < 3 || username.length > 50) {
setIsValidUsername(false);

return;
}

if (username.match(/[^-a-zA-Z0-9_]/)) {
setIsValidUsername(false);

return;
}
}, [username]);

return (

<FormTemplate>
<form className='flex flex-col gap-4' onSubmit={onSubmit}>
<StepWizard instance={setWizard}

>
<div className='flex flex-col gap-4'>
<label className='block text-sm font-bold ' htmlFor='username'>
What should we call you?
</label>
<input required onChange={e => handleUsernameChange(e)} className='shadow appearance-none border rounded w-full py-2 px-3 leading-tight focus:outline-none focus:shadow-outline' id='username' type='text' placeholder='Username' />
<button type='button' disabled={username.length === 0 || !isValidUsername} onClick={() => (wizard as any)?.nextStep()}
className='bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline cursor-pointer disabled:opacity-50'>Next</button>
{ username.length >= 3 && (
<div className='flex items-center gap-2'>
{username.length > 0 && !isLoadingExistsCheck.current && (
<span className={`text-sm ${isValidUsername ? 'text-green-600' : 'text-red-600'}`}>
{isValidUsername ? '✅' : '❌'}
</span>
)}
<span className='text-sm'>
{ isLoadingExistsCheck.current ? <LoadingSpinner size='small' /> : (
isValidUsername ? 'Username is available' : 'Username is not available'
)}
</span>
</div>
)}
</div>
<div className='flex flex-col gap-2'>
<p className='text-center text-lg'>
Nice to meet you, <span className='font-bold'>{username}</span>!
</p>
<p className='text-center text-md'>
Your Thinky.gg journey is about to launch! 🚀
</p>
<label className='block text-sm font-bold ' htmlFor='email'>
Email
</label>
<input required onChange={e => setEmail(e.target.value)} value={email} className='shadow appearance-none border rounded w-full py-2 px-3 leading-tight focus:outline-none focus:shadow-outline' id='email' type='email' placeholder='Email' />
<div className='flex flex-col gap-4'>
<label className='block text-sm font-bold ' htmlFor='password'>
Password
</label>
<input required onChange={e => setPassword(e.target.value)} className='shadow appearance-none border rounded w-full py-2 px-3 leading-tight focus:outline-none focus:shadow-outline' id='password' type='password' placeholder='******************' />
<div className='flex items-center justify-between gap-1'>
<button type='button' onClick={() => (wizard as any)?.previousStep()} className='bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline cursor-pointer'>Prev</button>
<button type='submit' className='bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline cursor-pointer'>Let&apos;s play!</button>
</div>
</div>
<div className='flex items-center justify-between gap-1'>
<input type='checkbox' id='terms_agree_checkbox' required />
<label htmlFor='terms_agree_checkbox' className='text-xs p-1'>
I agree to the <a className='underline' href='https://docs.google.com/document/d/e/2PACX-1vR4E-RcuIpXSrRtR3T3y9begevVF_yq7idcWWx1A-I9w_VRcHhPTkW1A7DeUx2pGOcyuKifEad3Qokn/pub' rel='noreferrer' target='_blank'>terms of service</a> and reviewed the <a className='underline' href='https://docs.google.com/document/d/e/2PACX-1vSNgV3NVKlsgSOEsnUltswQgE8atWe1WCLUY5fQUVjEdu_JZcVlRkZcpbTOewwe3oBNa4l7IJlOnUIB/pub' rel='noreferrer' target='_blank'>privacy policy</a>.
</label>
</div>
</div>
</StepWizard>
</form>
</FormTemplate>
);
}
19 changes: 18 additions & 1 deletion components/page/loadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import React from 'react';

export default function LoadingSpinner() {
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
}

export default function LoadingSpinner({ size }: LoadingSpinnerProps) {
if (size === 'small') {
return <svg className='text-gray-300 animate-spin' viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'
width='24' height='24'>
<path
d='M32 3C35.8083 3 39.5794 3.75011 43.0978 5.20749C46.6163 6.66488 49.8132 8.80101 52.5061 11.4939C55.199 14.1868 57.3351 17.3837 58.7925 20.9022C60.2499 24.4206 61 28.1917 61 32C61 35.8083 60.2499 39.5794 58.7925 43.0978C57.3351 46.6163 55.199 49.8132 52.5061 52.5061C49.8132 55.199 46.6163 57.3351 43.0978 58.7925C39.5794 60.2499 35.8083 61 32 61C28.1917 61 24.4206 60.2499 20.9022 58.7925C17.3837 57.3351 14.1868 55.199 11.4939 52.5061C8.801 49.8132 6.66487 46.6163 5.20749 43.0978C3.7501 39.5794 3 35.8083 3 32C3 28.1917 3.75011 24.4206 5.2075 20.9022C6.66489 17.3837 8.80101 14.1868 11.4939 11.4939C14.1868 8.80099 17.3838 6.66487 20.9022 5.20749C24.4206 3.7501 28.1917 3 32 3L32 3Z'
stroke='currentColor' strokeWidth='5' strokeLinecap='round' strokeLinejoin='round' />
<path
d='M32 3C36.5778 3 41.0906 4.08374 45.1692 6.16256C49.2477 8.24138 52.7762 11.2562 55.466 14.9605C58.1558 18.6647 59.9304 22.9531 60.6448 27.4748C61.3591 31.9965 60.9928 36.6232 59.5759 40.9762'
stroke='currentColor' strokeWidth='5' strokeLinecap='round' strokeLinejoin='round' className='text-gray-900' />
</svg>
;
}

return (
<div className='flex justify-center'>
<svg aria-hidden='true' className='w-8 h-8 animate-spin text-gray-600 fill-blue-600' viewBox='0 0 100 101' fill='none' xmlns='http://www.w3.org/2000/svg'>
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"react-select": "^5.8.0",
"react-share": "^5.1.0",
"react-simple-star-rating": "^5.1.7",
"react-step-wizard": "^5.3.11",
"react-textarea-autosize": "^8.5.3",
"react-tooltip": "^5.26.4",
"recharts": "^2.12.6",
Expand Down
2 changes: 1 addition & 1 deletion pages/[subdomain]/confirm-email/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function ConfirmPage() {
Haven&apos;t received the email? <br />Check your spam folder or <Link href={'/settings'} className='underline font-bold'>click here to resend</Link> (or update your email).
</p>
<p className='text-lg mt-8'>
Once you have confirmed your email, you will be redirected
Once you have confirmed your email, you will be redirected automatically.
</p>
</div>
</Page>
Expand Down
43 changes: 30 additions & 13 deletions pages/[subdomain]/signup/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
import SignupFormWizard from '@root/components/forms/signupFormWizard';
import GameLogoAndLabel from '@root/components/gameLogoAndLabel';
import { GameId } from '@root/constants/GameId';
import { GetServerSidePropsContext } from 'next';
import { getUserFromToken } from '@root/lib/withAuth';
import { GetServerSidePropsContext, NextApiRequest } from 'next';
import Link from 'next/link';
import React from 'react';
import SignupForm from '../../../components/forms/signupForm';
import Page from '../../../components/page/page';
import redirectToHome from '../../../helpers/redirectToHome';

export async function getServerSideProps(context: GetServerSidePropsContext) {
const redirect = await redirectToHome(context);
const token = context.req?.cookies?.token;
const reqUser = token ? await getUserFromToken(token, context.req as NextApiRequest) : null;

if (redirect.redirect) {
return redirect;
if (reqUser) {
return {
redirect: {
destination: '/',
permanent: false,
},
};
}

return {
props: {
recaptchaPublicKey: process.env.RECAPTCHA_PUBLIC_KEY || '',
},
};
}

/* istanbul ignore next */
export default function SignUp({ recaptchaPublicKey }: {recaptchaPublicKey?: string}) {
export default function SignUp() {
return (
<Page title={'Sign Up'}>
<>
Expand All @@ -34,12 +39,24 @@ export default function SignUp({ recaptchaPublicKey }: {recaptchaPublicKey?: str
<div>Create a Thinky.gg account and start playing!</div><div>Your Thinky.gg account works across all games on the site.</div>
</div>
</div>
<SignupForm recaptchaPublicKey={recaptchaPublicKey} />
<div className='text-center mb-4'>
{'Already have an account? '}
<Link href='/login' passHref className='underline'>
<SignupFormWizard />
<div className='flex flex-col gap-4 items-center'>
<div className='flex flex-wrap items-center justify-between'>
<Link
className='inline-block align-baseline font-bold text-sm hover:text-blue-400'
href='/play-as-guest'
>
Play as Guest
</Link>
</div>
<div className='text-center mb-4'>
{'Already have an account? '}
<Link href='/login' passHref className='underline'>
Log In
</Link>
</Link>

</div>

</div>
</>
</Page>
Expand Down
27 changes: 1 addition & 26 deletions pages/api/signup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,37 +68,12 @@ export default apiWrapper({ POST: {
email: ValidType('string'),
name: ValidType('string'),
password: ValidType('string'),
recaptchaToken: ValidType('string', false),
tutorialCompletedAt: ValidNumber(false),
},
} }, async (req: NextApiRequestWrapper, res: NextApiResponse) => {
await dbConnect();

const { email, name, password, tutorialCompletedAt, recaptchaToken, guest, utm_source } = req.body;

const RECAPTCHA_SECRET = process.env.RECAPTCHA_SECRET || '';

if (RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0) {
if (!recaptchaToken) {
return res.status(400).json({ error: 'Please fill out recaptcha' });
}

const recaptchaResponse = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${RECAPTCHA_SECRET}&response=${recaptchaToken}`,
});

const recaptchaData = await recaptchaResponse.json();

if (!recaptchaResponse.ok || !recaptchaData?.success) {
const errorMessage = `Error validating recaptcha [Status: ${recaptchaResponse.status}], [Data: ${JSON.stringify(recaptchaData)}]`;

logger.error(errorMessage);

return res.status(400).json({ error: errorMessage });
}
}
const { email, name, password, tutorialCompletedAt, guest, utm_source } = req.body;

let trimmedEmail: string, trimmedName: string, passwordValue: string;

Expand Down
16 changes: 16 additions & 0 deletions pages/api/user/exists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import apiWrapper, { NextApiRequestWrapper, ValidType } from '@root/helpers/apiWrapper';
import { UserModel } from '@root/models/mongoose';
import { NextApiResponse } from 'next';

export default apiWrapper({
GET: {
query: {
name: ValidType('string', true),
}
}
}, async (req: NextApiRequestWrapper, res: NextApiResponse) => {
const { name } = req.query as { name: string };
const userExists = await UserModel.exists({ name });

return res.status(userExists ? 200 : 404).json({ exists: userExists });
});
2 changes: 1 addition & 1 deletion styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -587,4 +587,4 @@ body {
*/
#headlessui-portal-root > [data-headlessui-portal]:nth-child(2) > div > div > div:first-child {
user-select: none;
}
}
Loading
Loading