Skip to content

Commit f6a963e

Browse files
committed
feat(sign-up): add sign up screen & form
1 parent 7c98604 commit f6a963e

File tree

11 files changed

+8223
-11284
lines changed

11 files changed

+8223
-11284
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@
130130
"eslint": "^8.57.0",
131131
"eslint-config-expo": "^7.1.2",
132132
"eslint-config-prettier": "^9.1.0",
133+
"eslint-import-resolver-typescript": "^3.7.0",
133134
"eslint-plugin-i18n-json": "^4.0.0",
135+
"eslint-plugin-import": "^2.31.0",
134136
"eslint-plugin-jest": "^28.8.3",
135137
"eslint-plugin-prettier": "^5.2.1",
136138
"eslint-plugin-react": "^7.37.2",

pnpm-lock.yaml

Lines changed: 7985 additions & 11270 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/auth/use-login.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ const login = async (variables: Variables) => {
2626
password: variables.password,
2727
},
2828
},
29-
headers: {
30-
'Content-Type': 'application/json',
31-
},
3229
});
3330
return data;
3431
};

src/api/auth/use-sign-up.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createMutation } from 'react-query-kit';
2+
3+
import { client } from '../common';
4+
5+
type Variables = {
6+
email: string;
7+
name: string;
8+
password: string;
9+
passwordConfirmation: string;
10+
};
11+
12+
type Response = {
13+
status: string;
14+
data: {
15+
id: string;
16+
email: string;
17+
name: string;
18+
provider: string;
19+
uid: string;
20+
allowPasswordChange: boolean;
21+
createdAt: string;
22+
updatedAt: string;
23+
nickname?: string;
24+
image?: string;
25+
birthday?: string;
26+
};
27+
};
28+
29+
const signUp = async (variables: Variables) => {
30+
const { data } = await client({
31+
url: '/v1/users',
32+
method: 'POST',
33+
data: {
34+
user: {
35+
email: variables.email,
36+
password: variables.password,
37+
password_confirmation: variables.passwordConfirmation,
38+
name: variables.name,
39+
},
40+
},
41+
});
42+
43+
return data;
44+
};
45+
46+
export const useSignUp = createMutation<Response, Variables>({
47+
mutationFn: (variables) => signUp(variables),
48+
});

src/api/common/interceptors.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,57 @@
11
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
22

3-
import { useAuth } from '@/core';
3+
import { signIn, useAuth } from '@/core';
44

55
import { client } from './client';
66
import { toCamelCase, toSnakeCase } from './utils';
77

8+
const ACCESS_TOKEN = 'access-token';
9+
const CLIENT_HEADER = 'client';
10+
const UID_HEADER = 'uid';
11+
const EXPIRY_HEADER = 'expiry';
12+
13+
const CONTENT_TYPE = 'Content-Type';
14+
const MULTIPART_FORM_DATA = 'multipart/form-data';
15+
816
export default function interceptors() {
917
const token = useAuth.getState().token;
18+
1019
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
11-
if (config.data) {
20+
const { headers, data } = config;
21+
22+
if (headers && headers[CONTENT_TYPE] !== MULTIPART_FORM_DATA && data) {
1223
config.data = toSnakeCase(config.data);
1324
}
25+
1426
if (token) {
15-
config.headers.Authorization = `Bearer ${token}`;
27+
const { access, client: _client, uid } = token;
28+
29+
config.headers.Authorization = `Bearer ${access}`;
30+
config.headers[ACCESS_TOKEN] = access;
31+
config.headers[CLIENT_HEADER] = _client;
32+
config.headers[UID_HEADER] = uid;
33+
config.headers[EXPIRY_HEADER] = token[EXPIRY_HEADER];
1634
}
35+
1736
return config;
1837
});
1938

2039
client.interceptors.response.use(
2140
(response) => {
41+
const { data, headers } = response;
2242
response.data = toCamelCase(response.data);
43+
44+
const token = headers[ACCESS_TOKEN];
45+
const _client = headers[CLIENT_HEADER];
46+
const uid = headers[UID_HEADER];
47+
const expiry = headers[EXPIRY_HEADER];
48+
49+
if (token) {
50+
signIn({ access: token, client: _client, uid, expiry });
51+
}
52+
53+
response.data = toCamelCase(data);
54+
2355
return response;
2456
},
2557
(error: AxiosError) => Promise.reject(error),

src/app/_layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export default function RootLayout() {
3535
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
3636
<Stack.Screen name="forgot-password" />
3737
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
38+
<Stack.Screen
39+
name="sign-up"
40+
options={{ headerBackTitleVisible: false }}
41+
/>
3842
<Stack.Screen
3943
name="www"
4044
options={{

src/app/sign-in.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ import { showMessage } from 'react-native-flash-message';
55
import { useLogin } from '@/api/auth/use-login';
66
import type { LoginFormProps } from '@/components/login-form';
77
import { LoginForm } from '@/components/login-form';
8-
import { useAuth } from '@/core';
98
import { FocusAwareStatusBar } from '@/ui';
109

1110
export default function Login() {
1211
const router = useRouter();
13-
const signIn = useAuth.use.signIn();
14-
const { mutate: login } = useLogin({
15-
onSuccess: (data) => {
16-
signIn({ access: data.accessToken, refresh: data.refreshToken });
12+
13+
const { mutate: login, isPending } = useLogin({
14+
onSuccess: () => {
1715
router.push('/');
1816
},
1917
onError: (error) => showMessage({ message: error.message, type: 'danger' }),
@@ -25,7 +23,7 @@ export default function Login() {
2523
return (
2624
<>
2725
<FocusAwareStatusBar />
28-
<LoginForm onSubmit={onSubmit} />
26+
<LoginForm onSubmit={onSubmit} isLoading={isPending} />
2927
</>
3028
);
3129
}

src/app/sign-up.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useRouter } from 'expo-router';
2+
import React from 'react';
3+
import { showMessage } from 'react-native-flash-message';
4+
5+
import { useSignUp } from '@/api/auth/use-sign-up';
6+
import type { SignUpFormProps } from '@/components/sign-up-form';
7+
import { SignUpForm } from '@/components/sign-up-form';
8+
import { FocusAwareStatusBar } from '@/ui';
9+
10+
export default function SignIn() {
11+
const router = useRouter();
12+
13+
const { mutate: signUp, isPending } = useSignUp({
14+
onSuccess: () => {
15+
router.push('/');
16+
},
17+
onError: (error) => showMessage({ message: error.message, type: 'danger' }),
18+
});
19+
20+
const onSubmit: SignUpFormProps['onSubmit'] = (data) => {
21+
signUp(data);
22+
};
23+
24+
return (
25+
<>
26+
<FocusAwareStatusBar />
27+
<SignUpForm onSubmit={onSubmit} isPending={isPending} />
28+
</>
29+
);
30+
}

src/components/login-form.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { Link } from 'expo-router';
23
import type { SubmitHandler } from 'react-hook-form';
34
import { useForm } from 'react-hook-form';
45
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
@@ -21,10 +22,14 @@ const schema = z.object({
2122
export type FormType = z.infer<typeof schema>;
2223

2324
export type LoginFormProps = {
25+
isLoading?: boolean;
2426
onSubmit?: SubmitHandler<FormType>;
2527
};
2628

27-
export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
29+
export const LoginForm = ({
30+
onSubmit = () => {},
31+
isLoading = false,
32+
}: LoginFormProps) => {
2833
const { handleSubmit, control } = useForm<FormType>({
2934
resolver: zodResolver(schema),
3035
});
@@ -55,11 +60,20 @@ export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
5560
placeholder="***"
5661
secureTextEntry={true}
5762
/>
63+
5864
<Button
5965
testID="login-button"
6066
label="Login"
6167
onPress={handleSubmit(onSubmit)}
68+
loading={isLoading}
6269
/>
70+
71+
<Text>
72+
Don't have an account?{' '}
73+
<Link href="/sign-up" disabled={isLoading}>
74+
<Text className="font-bold text-black">Sign up</Text>
75+
</Link>
76+
</Text>
6377
</View>
6478
</KeyboardAvoidingView>
6579
);

src/components/sign-up-form.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { type SubmitHandler, useForm } from 'react-hook-form';
3+
import { KeyboardAvoidingView } from 'react-native';
4+
import z from 'zod';
5+
6+
import { Button, ControlledInput,Text, View } from '@/ui';
7+
8+
const MIN_PASSWORD_LENGTH = 6;
9+
10+
const passwordSchema = z
11+
.string({ required_error: 'Password is required' })
12+
.min(MIN_PASSWORD_LENGTH, 'Password must be at least 6 characters');
13+
14+
const schema = z
15+
.object({
16+
email: z
17+
.string({ required_error: 'Email is required' })
18+
.email('Invalid email format'),
19+
name: z.string({ required_error: 'Name is required' }),
20+
password: passwordSchema,
21+
passwordConfirmation: z.string({
22+
required_error: 'Password confirmation is required',
23+
}),
24+
})
25+
.refine((data) => data.password === data.passwordConfirmation, {
26+
message: 'Passwords do not match',
27+
path: ['passwordConfirmation'],
28+
});
29+
30+
export type FormType = z.infer<typeof schema>;
31+
32+
export type SignUpFormProps = {
33+
onSubmit?: SubmitHandler<FormType>;
34+
isPending?: boolean;
35+
};
36+
37+
export const SignUpForm = ({
38+
onSubmit = () => {},
39+
isPending = false,
40+
}: SignUpFormProps) => {
41+
const { handleSubmit, control } = useForm<FormType>({
42+
resolver: zodResolver(schema),
43+
});
44+
45+
return (
46+
<KeyboardAvoidingView
47+
className="flex-1"
48+
behavior="padding"
49+
keyboardVerticalOffset={10}
50+
>
51+
<View className="flex-1 justify-center p-4">
52+
<Text testID="form-title" className="pb-6 text-center text-2xl">
53+
Sign Up
54+
</Text>
55+
56+
<ControlledInput
57+
testID="email-input"
58+
autoCapitalize="none"
59+
autoComplete="email"
60+
control={control}
61+
name="email"
62+
label="Email"
63+
/>
64+
<ControlledInput
65+
testID="name-input"
66+
control={control}
67+
name="name"
68+
label="Name"
69+
/>
70+
<ControlledInput
71+
testID="password-input"
72+
control={control}
73+
name="password"
74+
label="Password"
75+
placeholder="***"
76+
secureTextEntry={true}
77+
/>
78+
<ControlledInput
79+
testID="password-confirmation-input"
80+
control={control}
81+
name="passwordConfirmation"
82+
label="Password Confirmation"
83+
placeholder="***"
84+
secureTextEntry={true}
85+
/>
86+
87+
<Button
88+
testID="sign-up-button"
89+
label="Sign Up"
90+
onPress={handleSubmit(onSubmit)}
91+
loading={isPending}
92+
disabled={isPending}
93+
/>
94+
</View>
95+
</KeyboardAvoidingView>
96+
);
97+
};

0 commit comments

Comments
 (0)