Skip to content

Commit 564f6df

Browse files
Merge pull request #114 from rootstrap/feat/sign-up-screen
feat(sign-up): add sign up screen & form
2 parents 05f9f2f + 8c51136 commit 564f6df

File tree

10 files changed

+321
-49
lines changed

10 files changed

+321
-49
lines changed

src/api/auth/use-login.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@ const login = async (variables: Variables) => {
2121
url: '/v1/users/sign_in',
2222
method: 'POST',
2323
data: {
24-
user: {
25-
email: variables.email,
26-
password: variables.password,
27-
},
28-
},
29-
headers: {
30-
'Content-Type': 'application/json',
24+
user: variables,
3125
},
3226
});
3327
return data;

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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: variables,
35+
},
36+
});
37+
38+
return data;
39+
};
40+
41+
export const useSignUp = createMutation<Response, Variables>({
42+
mutationFn: (variables) => signUp(variables),
43+
});

src/api/common/interceptors.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,59 @@
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+
const AUTHORIZATION_HEADER = 'Authorization';
13+
14+
const CONTENT_TYPE = 'Content-Type';
15+
const MULTIPART_FORM_DATA = 'multipart/form-data';
16+
817
export default function interceptors() {
9-
const token = useAuth.getState().token;
1018
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
11-
if (config.data) {
19+
const token = useAuth.getState().token;
20+
21+
const { headers, data } = config;
22+
23+
if (headers && headers[CONTENT_TYPE] !== MULTIPART_FORM_DATA && data) {
1224
config.data = toSnakeCase(config.data);
1325
}
26+
1427
if (token) {
15-
config.headers.Authorization = `Bearer ${token}`;
28+
const { access, client: _client, uid, bearer, expiry } = token;
29+
30+
config.headers[AUTHORIZATION_HEADER] = bearer;
31+
config.headers[ACCESS_TOKEN] = access;
32+
config.headers[CLIENT_HEADER] = _client;
33+
config.headers[UID_HEADER] = uid;
34+
config.headers[EXPIRY_HEADER] = expiry;
1635
}
36+
1737
return config;
1838
});
1939

2040
client.interceptors.response.use(
2141
(response) => {
42+
const { data, headers } = response;
2243
response.data = toCamelCase(response.data);
44+
45+
const token = headers[ACCESS_TOKEN];
46+
const _client = headers[CLIENT_HEADER];
47+
const uid = headers[UID_HEADER];
48+
const expiry = headers[EXPIRY_HEADER];
49+
const bearer = headers[AUTHORIZATION_HEADER];
50+
51+
if (token) {
52+
signIn({ access: token, client: _client, uid, expiry, bearer });
53+
}
54+
55+
response.data = toCamelCase(data);
56+
2357
return response;
2458
},
2559
(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: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,38 @@
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';
56
import z from 'zod';
67

8+
import { translate } from '@/core';
79
import { Button, ControlledInput, Text, View } from '@/ui';
810

911
const MIN_CHARS = 6;
1012
const schema = z.object({
1113
email: z
12-
.string({ required_error: 'Email is required' })
13-
.email('Invalid email format'),
14+
.string({
15+
required_error: translate('auth.signIn.validation.emailRequired'),
16+
})
17+
.email(translate('auth.signIn.validation.invalidEmail')),
1418
password: z
1519
.string({
16-
required_error: 'Password is required',
20+
required_error: translate('auth.signIn.validation.passwordRequired'),
1721
})
18-
.min(MIN_CHARS, 'Password must be at least 6 characters'),
22+
.min(MIN_CHARS, translate('auth.signIn.validation.passwordMinChars')),
1923
});
2024

2125
export type FormType = z.infer<typeof schema>;
2226

2327
export type LoginFormProps = {
28+
isLoading?: boolean;
2429
onSubmit?: SubmitHandler<FormType>;
2530
};
2631

27-
export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
32+
export const LoginForm = ({
33+
onSubmit = () => {},
34+
isLoading = false,
35+
}: LoginFormProps) => {
2836
const { handleSubmit, control } = useForm<FormType>({
2937
resolver: zodResolver(schema),
3038
});
@@ -34,32 +42,50 @@ export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
3442
behavior="padding"
3543
keyboardVerticalOffset={10}
3644
>
37-
<View className="flex-1 justify-center p-4">
38-
<Text testID="form-title" className="pb-6 text-center text-2xl">
39-
Sign In
45+
<View className="flex-1 justify-center gap-8 p-4">
46+
<Text testID="form-title" className="text-center text-2xl">
47+
{translate('auth.signIn.title')}
4048
</Text>
49+
<View>
50+
<ControlledInput
51+
testID="email-input"
52+
autoCapitalize="none"
53+
autoComplete="email"
54+
control={control}
55+
name="email"
56+
label={translate('auth.signIn.fields.email')}
57+
/>
58+
<ControlledInput
59+
testID="password-input"
60+
control={control}
61+
name="password"
62+
label={translate('auth.signIn.fields.password')}
63+
placeholder="***"
64+
secureTextEntry={true}
65+
/>
4166

42-
<ControlledInput
43-
testID="email-input"
44-
autoCapitalize="none"
45-
autoComplete="email"
46-
control={control}
47-
name="email"
48-
label="Email"
49-
/>
50-
<ControlledInput
51-
testID="password-input"
52-
control={control}
53-
name="password"
54-
label="Password"
55-
placeholder="***"
56-
secureTextEntry={true}
57-
/>
58-
<Button
59-
testID="login-button"
60-
label="Login"
61-
onPress={handleSubmit(onSubmit)}
62-
/>
67+
<Button
68+
testID="login-button"
69+
label={translate('auth.signIn.buttons.login')}
70+
onPress={handleSubmit(onSubmit)}
71+
loading={isLoading}
72+
/>
73+
<Text>
74+
{translate('auth.signIn.newAccount')}{' '}
75+
<Link href="/sign-up" disabled={isLoading}>
76+
<Text className="font-bold text-black">
77+
{translate('auth.signIn.buttons.signUp')}
78+
</Text>
79+
</Link>
80+
</Text>
81+
<Link href="/forgot-password" disabled={isLoading} asChild>
82+
<Button
83+
variant="link"
84+
className="font-bold text-black"
85+
label={translate('auth.signIn.forgotPasswordButton')}
86+
/>
87+
</Link>
88+
</View>
6389
</View>
6490
</KeyboardAvoidingView>
6591
);

0 commit comments

Comments
 (0)