1454 lines
42 KiB
TypeScript
1454 lines
42 KiB
TypeScript
import {RegisterProcess, ThemeMode} from '@caj/configs/appVar';
|
|
import {appVarActions} from '@caj/configs/appVarReducer';
|
|
import {defaultHeaderStyle} from '@caj/configs/colors';
|
|
import {SlideFromLeftView} from '@caj/helper/animations';
|
|
import {saveVarChanges} from '@caj/helper/appData';
|
|
import {apiBackendRequest, makeRequest} from '@caj/helper/request';
|
|
import {
|
|
accountNameOptions,
|
|
EMail,
|
|
emailOptions,
|
|
passwordOptions,
|
|
userNameOptions,
|
|
XToken,
|
|
} from '@caj/configs/types';
|
|
import {RootScreenNavigationProp, WebBackButtonOptions} from '@caj/Navigation';
|
|
import {RootState, store} from '@caj/redux/store';
|
|
import {useNavigation} from '@react-navigation/native';
|
|
import {
|
|
createNativeStackNavigator,
|
|
NativeStackNavigationProp,
|
|
} from '@react-navigation/native-stack';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Container,
|
|
FormControl,
|
|
IconButton,
|
|
Input,
|
|
ScrollView,
|
|
Text,
|
|
useColorModeValue,
|
|
useTheme,
|
|
useToast,
|
|
VStack,
|
|
WarningOutlineIcon,
|
|
Progress,
|
|
} from 'native-base';
|
|
|
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
|
|
|
import {useEffect, useRef, useState} from 'react';
|
|
import {useDispatch, useSelector} from 'react-redux';
|
|
import reactStringReplace from 'react-string-replace';
|
|
import ConfirmationCodeField from './ConfirmationCodeField';
|
|
import NameDisplay from './NameDisplay';
|
|
import showToast from './Toast';
|
|
import {NativeSyntheticEvent, TextInputFocusEventData} from 'react-native';
|
|
|
|
import PasswordQualityCalculator from '@caj/helper/password-quality-calculator/PasswordQualityCalculator';
|
|
|
|
// [ optional ] list of about 10000 most common passwords, 86kb (gzip 32kb)
|
|
import MostPopularPasswords from '@caj/helper/password-quality-calculator/MostPopularPasswords';
|
|
import MyUserManager from '@caj/user/MyUserManager';
|
|
|
|
import {Buffer} from 'buffer';
|
|
|
|
// Load the popular passwords list
|
|
PasswordQualityCalculator.PopularPasswords.load(MostPopularPasswords);
|
|
|
|
const validateEmail = (email: EMail) => {
|
|
return emailOptions.isAllowed(email);
|
|
};
|
|
|
|
export default function NotLoggedIn() {
|
|
const currentUser = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.selectedAccount,
|
|
);
|
|
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
const theme = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.theme,
|
|
);
|
|
|
|
const navigation = useNavigation<RootScreenNavigationProp>();
|
|
|
|
if (currentUser !== 'none') {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Box alignItems="center" mt={5}>
|
|
<Text color="primary.400">{lang.appName}</Text>
|
|
<Text color="white.600">{lang.appNameDesc}</Text>
|
|
<VStack mt={5} space={4} alignItems="center">
|
|
<Button
|
|
w="72"
|
|
colorScheme="primary"
|
|
rounded="xl"
|
|
_text={{fontSize: 'xl'}}
|
|
onPress={() => {
|
|
navigation.navigate('Register', {screen: 'RegStepOne'});
|
|
}}>
|
|
Sign up
|
|
</Button>
|
|
<Button
|
|
w="72"
|
|
colorScheme="black"
|
|
variant={theme === ThemeMode.Darkest ? 'outline' : 'subtle'}
|
|
rounded="xl"
|
|
_text={{fontSize: 'xl', color: 'white.900'}}
|
|
onPress={() => {
|
|
navigation.navigate('Register', {screen: 'Login'});
|
|
}}>
|
|
Log in
|
|
</Button>
|
|
</VStack>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function RegisterScreenAnim(props: any) {
|
|
return (
|
|
<SlideFromLeftView>
|
|
<RegisterScreen {...props} />
|
|
</SlideFromLeftView>
|
|
);
|
|
}
|
|
|
|
export type LoginStackNavigatorParamList = {
|
|
RegStepOne: undefined;
|
|
RegStepTwo: undefined;
|
|
RegStepFinal: undefined;
|
|
Login: undefined;
|
|
};
|
|
|
|
const LoginStack = createNativeStackNavigator<LoginStackNavigatorParamList>();
|
|
|
|
export type LoginScreenNavigationProp =
|
|
NativeStackNavigationProp<LoginStackNavigatorParamList>;
|
|
|
|
function RegisterScreen() {
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
const theme = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.theme,
|
|
);
|
|
|
|
const navigation = useNavigation<LoginScreenNavigationProp>();
|
|
|
|
return (
|
|
<LoginStack.Navigator
|
|
screenOptions={{
|
|
...WebBackButtonOptions(navigation, theme, 'registration'),
|
|
headerShown: true,
|
|
...defaultHeaderStyle(theme, 'registration'),
|
|
}}>
|
|
<LoginStack.Screen
|
|
name="RegStepOne"
|
|
options={{
|
|
animation: 'slide_from_left',
|
|
title: lang.account.registration.registration,
|
|
}}
|
|
component={StepOne}
|
|
/>
|
|
<LoginStack.Screen
|
|
name="RegStepTwo"
|
|
options={{
|
|
animation: 'slide_from_right',
|
|
title: lang.account.registration.registration,
|
|
}}
|
|
component={StepTwo}
|
|
/>
|
|
<LoginStack.Screen
|
|
name="RegStepFinal"
|
|
options={{
|
|
animation: 'slide_from_right',
|
|
title: lang.account.registration.registration,
|
|
}}
|
|
component={StepFinal}
|
|
/>
|
|
<LoginStack.Screen
|
|
name="Login"
|
|
options={{
|
|
animation: 'slide_from_right',
|
|
title: lang.account.login.title,
|
|
}}
|
|
component={Login}
|
|
/>
|
|
</LoginStack.Navigator>
|
|
);
|
|
}
|
|
|
|
function Agreement() {
|
|
const toast = useToast();
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
|
|
const textColor = useColorModeValue('blue.700', 'cyan.400');
|
|
|
|
let replacedText = reactStringReplace(
|
|
lang.account.registration.info,
|
|
'${TermsOfUse}',
|
|
(match, i) => (
|
|
<Text
|
|
key={match + i}
|
|
color={textColor}
|
|
bold
|
|
onPress={() => {
|
|
showToast(toast, {
|
|
title: lang.account.registration.termsOfUse,
|
|
variant: 'solid',
|
|
status: 'info',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {colorScheme: 'primary'},
|
|
});
|
|
}}>
|
|
{lang.account.registration.termsOfUse}
|
|
</Text>
|
|
),
|
|
);
|
|
|
|
replacedText = reactStringReplace(
|
|
replacedText,
|
|
'${privacyPolicy}',
|
|
(match, i) => (
|
|
<Text
|
|
key={match + i}
|
|
color={textColor}
|
|
bold
|
|
onPress={() => {
|
|
showToast(toast, {
|
|
title: lang.account.registration.privacyPolicy,
|
|
variant: 'solid',
|
|
status: 'info',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {colorScheme: 'primary'},
|
|
});
|
|
}}>
|
|
{lang.account.registration.privacyPolicy}
|
|
</Text>
|
|
),
|
|
);
|
|
|
|
return (
|
|
<Text textAlign={'justify'} color={'white.900'}>
|
|
{replacedText}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function resendMail(email: EMail, toast: any): Promise<XToken> {
|
|
return new Promise<XToken>((resolve, reject) => {
|
|
makeRequest({
|
|
path: apiBackendRequest.REGISTER_RESEND_MAIL,
|
|
requestHeader: {
|
|
'X-Token':
|
|
store.getState().appVariables.preferences.RegisterProcess.XToken,
|
|
},
|
|
request: {
|
|
Email: email,
|
|
},
|
|
response: {
|
|
XToken: undefined,
|
|
},
|
|
})
|
|
.then(resp => {
|
|
let token =
|
|
store.getState().appVariables.preferences.RegisterProcess.XToken;
|
|
if (token === undefined) token = '';
|
|
|
|
if (resp.response.XToken !== undefined || token !== undefined) {
|
|
if (resp.response.XToken !== undefined) {
|
|
token = resp.response.XToken;
|
|
|
|
let regPro = {
|
|
...store.getState().appVariables.preferences.RegisterProcess,
|
|
};
|
|
regPro.XToken = token;
|
|
store.dispatch(appVarActions.setRegisterProcess(regPro));
|
|
|
|
saveVarChanges();
|
|
}
|
|
|
|
showToast(toast, {
|
|
title: store.getState().appVariables.lang.info,
|
|
variant: 'solid',
|
|
status: 'info',
|
|
description:
|
|
store.getState().appVariables.lang.account.registration.stepTwo
|
|
.resend[2],
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
|
|
resolve(token);
|
|
} else {
|
|
reject(500);
|
|
showToast(toast, {
|
|
title: store.getState().appVariables.lang.error,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: 'XToken is undefined',
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
}
|
|
})
|
|
.catch(resp => {
|
|
let text = 'Error ' + resp.status;
|
|
if (resp.status !== undefined) {
|
|
const _text =
|
|
store.getState().appVariables.lang.account.registration.stepTwo
|
|
.resendError[resp.status as number];
|
|
if (_text !== undefined) text = _text;
|
|
}
|
|
|
|
showToast(toast, {
|
|
title: store.getState().appVariables.lang.error,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: text,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
|
|
reject(resp.status);
|
|
});
|
|
});
|
|
}
|
|
|
|
function StepOne() {
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
const regPro = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.RegisterProcess,
|
|
);
|
|
|
|
const dispatch = useDispatch();
|
|
const toast = useToast();
|
|
|
|
const navigation = useNavigation<RootScreenNavigationProp>();
|
|
|
|
const initNoErrors = {
|
|
wrongFormat: false,
|
|
alreadyExists: false,
|
|
noEntered: false,
|
|
unknown: undefined,
|
|
};
|
|
|
|
const [errors, setErrors] = useState(initNoErrors);
|
|
|
|
const isError =
|
|
errors.wrongFormat ||
|
|
errors.alreadyExists ||
|
|
errors.noEntered ||
|
|
errors.unknown !== undefined;
|
|
|
|
const errorText = () => {
|
|
if (errors.wrongFormat) {
|
|
return lang.account.registration.stepOne.addressInvalid;
|
|
}
|
|
if (errors.alreadyExists) {
|
|
return lang.account.registration.stepOne.addressExists;
|
|
}
|
|
if (errors.noEntered) {
|
|
return lang.account.registration.stepOne.noMailEntered;
|
|
}
|
|
if (errors.unknown !== undefined) {
|
|
return errors.unknown;
|
|
}
|
|
};
|
|
|
|
const [isLoading, setLoading] = useState(false);
|
|
|
|
const [values, setValues] = useState({email: regPro.EMail});
|
|
|
|
useEffect(() => {
|
|
if (regPro.isRegistering === 'stepTwo') {
|
|
setLoading(true);
|
|
setErrors(initNoErrors);
|
|
|
|
setTimeout(nextStep, 500);
|
|
} else if (regPro.isRegistering === 'stepFinal') {
|
|
setLoading(true);
|
|
setErrors(initNoErrors);
|
|
|
|
setTimeout(nextStep, 500);
|
|
}
|
|
}, []);
|
|
|
|
const nextStep = () => {
|
|
setLoading(true);
|
|
setErrors(initNoErrors);
|
|
|
|
let rp = {...regPro};
|
|
|
|
if (rp.EMail !== values.email) {
|
|
rp.XToken = undefined;
|
|
}
|
|
|
|
makeRequest({
|
|
path: apiBackendRequest.REGISTER_STEP_1,
|
|
requestHeader: {},
|
|
request: {
|
|
Email: values.email,
|
|
},
|
|
response: {
|
|
XToken: undefined,
|
|
},
|
|
})
|
|
.then(resp => {
|
|
rp.isRegistering = 'stepTwo';
|
|
rp.EMail = values.email;
|
|
rp.XToken = resp.response.XToken;
|
|
|
|
dispatch(appVarActions.setRegisterProcess(rp));
|
|
saveVarChanges();
|
|
|
|
showToast(toast, {
|
|
title: lang.account.registration.stepOne.success,
|
|
variant: 'solid',
|
|
status: 'success',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {colorScheme: 'primary'},
|
|
});
|
|
|
|
navigation.navigate('Register', {screen: 'RegStepTwo'});
|
|
setLoading(false);
|
|
})
|
|
.catch(resp => {
|
|
if (resp.status === 401 || resp.status === 204) {
|
|
if (regPro.XToken !== undefined) {
|
|
if (resp.status === 401) {
|
|
rp.isRegistering = 'stepTwo';
|
|
rp.EMail = values.email;
|
|
|
|
dispatch(appVarActions.setRegisterProcess(rp));
|
|
saveVarChanges();
|
|
|
|
navigation.navigate('Register', {screen: 'RegStepTwo'});
|
|
} else if (resp.status === 204) {
|
|
rp.isRegistering = 'stepFinal';
|
|
rp.EMail = values.email;
|
|
|
|
dispatch(appVarActions.setRegisterProcess(rp));
|
|
saveVarChanges();
|
|
|
|
navigation.navigate('Register', {screen: 'RegStepFinal'});
|
|
}
|
|
|
|
setLoading(false);
|
|
} else {
|
|
resendMail(values.email, toast)
|
|
.then(() => {
|
|
navigation.navigate('Register', {screen: 'RegStepTwo'});
|
|
setLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setLoading(false);
|
|
});
|
|
}
|
|
return;
|
|
} else if (resp.status === 422) {
|
|
showToast(toast, {
|
|
title: lang.error,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: lang.account.registration.stepOne.addressExists,
|
|
isClosable: true,
|
|
rest: {colorScheme: 'primary'},
|
|
});
|
|
} else {
|
|
showToast(toast, {
|
|
title: lang.error,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: resp.status,
|
|
isClosable: true,
|
|
rest: {colorScheme: 'primary'},
|
|
});
|
|
}
|
|
setLoading(false);
|
|
});
|
|
};
|
|
|
|
const onButtonPress = () => {
|
|
if (values.email === '') {
|
|
let err = errors;
|
|
err.noEntered = true;
|
|
setErrors({...err});
|
|
} else if (validateEmail(values.email)) {
|
|
nextStep();
|
|
} else {
|
|
let err = errors;
|
|
err.wrongFormat = true;
|
|
setErrors({...err});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ScrollView keyboardShouldPersistTaps="handled">
|
|
<Box alignItems="center" mt={5}>
|
|
<FormControl
|
|
w="75%"
|
|
maxW="350px"
|
|
isRequired
|
|
isDisabled={isLoading}
|
|
isInvalid={isError}>
|
|
<FormControl.Label>
|
|
{lang.account.registration.stepOne.title}
|
|
</FormControl.Label>
|
|
<Input
|
|
autoFocus
|
|
autoComplete="email"
|
|
placeholder={lang.account.registration.stepOne.title}
|
|
value={values.email}
|
|
onSubmitEditing={onButtonPress}
|
|
autoCapitalize={'none'}
|
|
maxLength={emailOptions.maxLength}
|
|
keyboardType={'email-address'}
|
|
onChangeText={text => {
|
|
const mail = text.replaceAll(' ', '');
|
|
setValues({email: mail});
|
|
|
|
if (errors.noEntered && mail !== '') {
|
|
let err = errors;
|
|
err.noEntered = false;
|
|
setErrors({...err});
|
|
}
|
|
if (errors.wrongFormat && validateEmail(mail)) {
|
|
let err = errors;
|
|
err.wrongFormat = false;
|
|
setErrors({...err});
|
|
}
|
|
if (errors.alreadyExists) {
|
|
let err = errors;
|
|
err.alreadyExists = false;
|
|
setErrors({...err});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
|
{errorText()}
|
|
</FormControl.ErrorMessage>
|
|
</FormControl>
|
|
<Container marginY={5}>
|
|
<Agreement />
|
|
</Container>
|
|
<Button
|
|
w="75%"
|
|
maxW="350px"
|
|
colorScheme="primary"
|
|
rounded="xl"
|
|
_text={{fontSize: 'xl'}}
|
|
isLoading={isLoading}
|
|
onPress={onButtonPress}>
|
|
{lang.account.registration.stepOne.button}
|
|
</Button>
|
|
</Box>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function StepTwo() {
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
const regPro = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.RegisterProcess,
|
|
);
|
|
|
|
const dispatch = useDispatch();
|
|
|
|
const cellCount = 6;
|
|
const {colors} = useTheme();
|
|
|
|
const toast = useToast();
|
|
|
|
const navigation = useNavigation<RootScreenNavigationProp>();
|
|
|
|
const initNoErrors = {
|
|
noEntered: false,
|
|
};
|
|
|
|
const [errors, setErrors] = useState(initNoErrors);
|
|
|
|
const [isLoading, setLoading] = useState(false);
|
|
|
|
const [values, setValues] = useState({code: ''});
|
|
|
|
const headerText = () => {
|
|
return reactStringReplace(
|
|
lang.account.registration.stepTwo.title,
|
|
'${EMail}',
|
|
(match, i) => (
|
|
<Text key={match + i} color={'primary.400'}>
|
|
{regPro.EMail}
|
|
</Text>
|
|
),
|
|
);
|
|
};
|
|
|
|
const resendText = () => {
|
|
return reactStringReplace(
|
|
lang.account.registration.stepTwo.resend[0],
|
|
'${resend}',
|
|
(match, i) => (
|
|
<Text
|
|
key={match + i}
|
|
color={'primary.400'}
|
|
onPress={() => {
|
|
setLoading(true);
|
|
resendMail(
|
|
store.getState().appVariables.preferences.RegisterProcess.EMail,
|
|
toast,
|
|
)
|
|
.then(() => {
|
|
setLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setLoading(false);
|
|
});
|
|
}}>
|
|
{lang.account.registration.stepTwo.resend[1]}
|
|
</Text>
|
|
),
|
|
);
|
|
};
|
|
|
|
const validate = (text: string) => {
|
|
setLoading(true);
|
|
setTimeout(() => {
|
|
makeRequest({
|
|
path: apiBackendRequest.REGISTER_STEP_2,
|
|
|
|
requestHeader: {},
|
|
requestGET: {':verifyId': text, ':xToken': regPro.XToken || ''},
|
|
})
|
|
.then(resp => {
|
|
let rp = {...regPro};
|
|
rp.isRegistering = 'stepFinal';
|
|
|
|
dispatch(appVarActions.setRegisterProcess(rp));
|
|
saveVarChanges();
|
|
|
|
showToast(toast, {
|
|
title: lang.account.registration.stepTwo.success,
|
|
variant: 'solid',
|
|
status: 'success',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {colorScheme: 'primary'},
|
|
});
|
|
|
|
navigation.navigate('Register', {screen: 'RegStepFinal'});
|
|
setLoading(false);
|
|
})
|
|
.catch(resp => {
|
|
let text = 'Error ' + resp.status;
|
|
if (resp.status !== undefined) {
|
|
const _text =
|
|
lang.account.registration.stepTwo.verificationError[
|
|
resp.status as number
|
|
];
|
|
if (_text !== undefined) text = _text;
|
|
}
|
|
|
|
showToast(toast, {
|
|
title: lang.error,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: text,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
|
|
setLoading(false);
|
|
if (resp.status === 422) {
|
|
let rp = {...regPro};
|
|
rp.isRegistering = false;
|
|
|
|
dispatch(appVarActions.setRegisterProcess(rp));
|
|
saveVarChanges();
|
|
navigation.navigate('Register', {screen: 'RegStepOne'});
|
|
}
|
|
});
|
|
}, 500);
|
|
};
|
|
|
|
return (
|
|
<ScrollView keyboardShouldPersistTaps="handled">
|
|
<Box alignItems="center" mt={5}>
|
|
<Center w="75%" maxW="1000px">
|
|
<Text>{headerText()}</Text>
|
|
<Box>
|
|
<FormControl isDisabled={isLoading} isInvalid={errors.noEntered}>
|
|
<ConfirmationCodeField
|
|
cellCount={cellCount}
|
|
charType="number"
|
|
disabled={isLoading}
|
|
rest={{mt: 5}}
|
|
onChange={(text: string) => {
|
|
setValues({code: text});
|
|
setErrors(initNoErrors);
|
|
}}
|
|
onFinish={(text: string) => validate(text)}
|
|
/>
|
|
|
|
<FormControl.ErrorMessage
|
|
leftIcon={<WarningOutlineIcon size="xs" />}>
|
|
{lang.account.registration.stepTwo.noCodeEntered}
|
|
</FormControl.ErrorMessage>
|
|
</FormControl>
|
|
</Box>
|
|
</Center>
|
|
<Container marginY={5}>
|
|
<Agreement />
|
|
</Container>
|
|
<Button
|
|
w="75%"
|
|
maxW="350px"
|
|
colorScheme="primary"
|
|
rounded="xl"
|
|
_text={{fontSize: 'xl'}}
|
|
isLoading={isLoading}
|
|
onPress={() => {
|
|
if (values.code.length === cellCount) {
|
|
validate(values.code);
|
|
} else {
|
|
let err = {...errors};
|
|
err.noEntered = true;
|
|
setErrors(err);
|
|
}
|
|
}}>
|
|
{lang.account.registration.stepTwo.button}
|
|
</Button>
|
|
<Container marginY={5}>
|
|
<Text textAlign={'justify'} color={'white.900'}>
|
|
{resendText()}
|
|
</Text>
|
|
</Container>
|
|
</Box>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
function StepFinal() {
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
const regPro = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.RegisterProcess,
|
|
);
|
|
|
|
const dispatch = useDispatch();
|
|
|
|
const {colors} = useTheme();
|
|
|
|
const toast = useToast();
|
|
|
|
const navigation = useNavigation<RootScreenNavigationProp>();
|
|
|
|
const [isLoading, setLoading] = useState(false);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
interface inputElementType {
|
|
label: string;
|
|
input: string;
|
|
autoCapitalize: 'none' | 'words';
|
|
errorIndex: 'none' | string;
|
|
errorTextObject: any;
|
|
isPassword: boolean | 'passwordRepeat';
|
|
minLength: number;
|
|
maxLength: number;
|
|
onTextChange: any;
|
|
textChangeTimeout: number;
|
|
isAllowed: any;
|
|
autoComplete?: 'name' | 'username-new' | 'password-new';
|
|
}
|
|
|
|
const accountNameRef = useRef(setTimeout(() => {}));
|
|
const accountNameFetchRef = useRef(setTimeout(() => {}));
|
|
|
|
const accountName = {
|
|
label: lang.account.registration.stepFinal.accountName,
|
|
input: '',
|
|
autoComplete: 'username-new',
|
|
errorIndex: 'none',
|
|
errorTextObject: lang.account.registration.stepFinal.accountNameError,
|
|
minLength: accountNameOptions.minLength,
|
|
maxLength: accountNameOptions.maxLength,
|
|
isAllowed: accountNameOptions.isAllowed,
|
|
isPassword: false,
|
|
onTextChange: (e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
|
let text = e.nativeEvent.text;
|
|
let self = accountName;
|
|
|
|
clearTimeout(accountNameRef.current);
|
|
|
|
let obj = {...valuesAccountName};
|
|
accountNameRef.current = setTimeout(() => {
|
|
obj.input = text;
|
|
|
|
if (text.length < self.minLength) obj.errorIndex = 'tooShort';
|
|
else if (text.length > self.maxLength) obj.errorIndex = 'tooLong';
|
|
else if (self.isAllowed(text) === false) obj.errorIndex = 'invalid';
|
|
else obj.errorIndex = 'none';
|
|
|
|
setValuesAccountName(obj);
|
|
|
|
accountNameFetchRef.current = setTimeout(() => {
|
|
if (obj.errorIndex === 'none') {
|
|
makeRequest({
|
|
path: apiBackendRequest.REGISTER_STEP_FINAL_ACCOUNT_NAME_CHECK,
|
|
request: {AccountName: obj.input},
|
|
})
|
|
.then(resp => {})
|
|
.catch(resp => {
|
|
if (resp.status !== undefined) {
|
|
obj.errorIndex = resp.status;
|
|
setValuesAccountName({...obj});
|
|
}
|
|
});
|
|
}
|
|
}, 750);
|
|
}, 50);
|
|
|
|
clearTimeout(accountNameFetchRef.current);
|
|
},
|
|
} as inputElementType;
|
|
const [valuesAccountName, setValuesAccountName] = useState(accountName);
|
|
|
|
const userNameRef = useRef(setTimeout(() => {}));
|
|
|
|
const userName = {
|
|
label: lang.account.registration.stepFinal.userName,
|
|
input: '',
|
|
autoComplete: 'name',
|
|
errorIndex: 'none',
|
|
errorTextObject: lang.account.registration.stepFinal.userNameError,
|
|
minLength: userNameOptions.minLength,
|
|
maxLength: userNameOptions.maxLength,
|
|
isAllowed: userNameOptions.isAllowed,
|
|
isPassword: false,
|
|
onTextChange: (e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
|
let text = e.nativeEvent.text;
|
|
let self = userName;
|
|
|
|
clearTimeout(userNameRef.current);
|
|
|
|
userNameRef.current = setTimeout(() => {
|
|
let obj = {...valuesUserName};
|
|
obj.input = text;
|
|
|
|
if (text.length < self.minLength) obj.errorIndex = 'tooShort';
|
|
else if (text.length > self.maxLength) obj.errorIndex = 'tooLong';
|
|
else if (self.isAllowed(text) === false) obj.errorIndex = 'invalid';
|
|
else obj.errorIndex = 'none';
|
|
|
|
setValuesUserName(obj);
|
|
}, 50);
|
|
},
|
|
} as inputElementType;
|
|
const [valuesUserName, setValuesUserName] = useState(userName);
|
|
|
|
const passwordRef = useRef(setTimeout(() => {}));
|
|
|
|
const password = {
|
|
label: lang.account.registration.stepFinal.password,
|
|
input: '',
|
|
autoComplete: 'password-new',
|
|
errorIndex: 'none',
|
|
errorTextObject: lang.account.registration.stepFinal.passwordError,
|
|
minLength: passwordOptions.minLength,
|
|
maxLength: passwordOptions.maxLength,
|
|
isAllowed: passwordOptions.isAllowed,
|
|
isPassword: true,
|
|
onTextChange: (e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
|
let text = e.nativeEvent.text;
|
|
let self = password;
|
|
|
|
clearTimeout(passwordRef.current);
|
|
|
|
passwordRef.current = setTimeout(() => {
|
|
let obj = {...valuesPassword};
|
|
obj.input = text;
|
|
|
|
let PasswordQuality = PasswordQualityCalculator(text);
|
|
setPasswordQuality(PasswordQuality);
|
|
|
|
if (text.length < self.minLength) obj.errorIndex = 'tooShort';
|
|
else if (text.length > self.maxLength) obj.errorIndex = 'tooLong';
|
|
else if (self.isAllowed(text) === false) obj.errorIndex = 'invalid';
|
|
else if (passwordOptions.minBits >= PasswordQuality)
|
|
obj.errorIndex = 'weak';
|
|
else obj.errorIndex = 'none';
|
|
|
|
setValuesPassword(obj);
|
|
|
|
let objRe = {...valuesPasswordRe};
|
|
if (valuesPasswordRe.input !== '') {
|
|
if (text !== valuesPasswordRe.input) objRe.errorIndex = 'noMatch';
|
|
else objRe.errorIndex = 'none';
|
|
|
|
setValuesPasswordRe(objRe);
|
|
}
|
|
}, 50);
|
|
},
|
|
} as inputElementType;
|
|
const [valuesPassword, setValuesPassword] = useState(password);
|
|
const [passwordQuality, setPasswordQuality] = useState(0);
|
|
|
|
let passwordQualityIndex = 0;
|
|
if (passwordQuality >= 128) passwordQualityIndex = 4;
|
|
else if (passwordQuality >= 100) passwordQualityIndex = 3;
|
|
else if (passwordQuality >= 80) passwordQualityIndex = 2;
|
|
else if (passwordQuality >= 50) passwordQualityIndex = 1;
|
|
let passwordQualityPercent = (passwordQuality / 128.0) * 100;
|
|
passwordQualityPercent =
|
|
passwordQualityPercent >= 100.0 ? 100 : passwordQualityPercent;
|
|
|
|
const passwordQualityColor = 'hsl(' + passwordQualityPercent + ', 100%, 50%)';
|
|
|
|
const passwordQualityText =
|
|
lang.account.registration.stepFinal.passwordQuality.replace(
|
|
'${quality}',
|
|
lang.account.registration.stepFinal.passwordQualityList[
|
|
passwordQualityIndex
|
|
],
|
|
);
|
|
|
|
const passwordReRef = useRef(setTimeout(() => {}));
|
|
|
|
const passwordRe = {
|
|
label: lang.account.registration.stepFinal.passwordRepeat,
|
|
input: '',
|
|
autoComplete: 'password-new',
|
|
errorIndex: 'none',
|
|
errorTextObject: lang.account.registration.stepFinal.passwordError,
|
|
minLength: passwordOptions.minLength,
|
|
maxLength: passwordOptions.maxLength,
|
|
isAllowed: passwordOptions.isAllowed,
|
|
isPassword: 'passwordRepeat',
|
|
onTextChange: (e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
|
let text = e.nativeEvent.text;
|
|
let self = passwordRe;
|
|
|
|
clearTimeout(passwordReRef.current);
|
|
|
|
passwordReRef.current = setTimeout(() => {
|
|
let obj = {...valuesPasswordRe};
|
|
obj.input = text;
|
|
|
|
if (text !== valuesPassword.input) obj.errorIndex = 'noMatch';
|
|
else obj.errorIndex = 'none';
|
|
|
|
setValuesPasswordRe(obj);
|
|
}, 50);
|
|
},
|
|
} as inputElementType;
|
|
const [valuesPasswordRe, setValuesPasswordRe] = useState(passwordRe);
|
|
|
|
const inputElement = (
|
|
val: inputElementType,
|
|
set: React.Dispatch<React.SetStateAction<inputElementType>>,
|
|
valConst: inputElementType,
|
|
autofocus?: boolean,
|
|
) => {
|
|
const isPassword =
|
|
val.isPassword === true || val.isPassword === 'passwordRepeat';
|
|
|
|
return (
|
|
<FormControl
|
|
w="75%"
|
|
maxW={350}
|
|
isRequired
|
|
isDisabled={isLoading}
|
|
isInvalid={val.errorIndex !== 'none'}>
|
|
<FormControl.Label>{val.label}</FormControl.Label>
|
|
<Input
|
|
autoComplete={val.autoComplete}
|
|
autoFocus={autofocus}
|
|
type={isPassword && showPassword === false ? 'password' : 'text'}
|
|
placeholder={''}
|
|
keyboardType={'default'}
|
|
onChange={valConst.onTextChange}
|
|
InputRightElement={
|
|
isPassword ? (
|
|
<IconButton
|
|
mr="2"
|
|
focusable={false}
|
|
onPress={() => setShowPassword(!showPassword)}
|
|
icon={
|
|
<MaterialIcons
|
|
size={20}
|
|
color={colors.white[200]}
|
|
name={showPassword ? 'visibility' : 'visibility-off'}
|
|
/>
|
|
}
|
|
borderRadius="full"
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
|
{val.errorTextObject[val.errorIndex] !== undefined
|
|
? val.errorTextObject[val.errorIndex]
|
|
.replaceAll('${minLength}', val.minLength)
|
|
.replaceAll('${maxLength}', val.maxLength)
|
|
: val.errorTextObject[val.errorIndex] !== 'none'
|
|
? lang.error
|
|
: null}
|
|
</FormControl.ErrorMessage>
|
|
</FormControl>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<ScrollView keyboardShouldPersistTaps="handled">
|
|
<VStack space={3} alignItems="center" mt={5}>
|
|
<Container maxWidth={'100%'} alignItems={'center'}>
|
|
<Center>
|
|
<Text>{lang.account.registration.stepFinal.displayName}</Text>
|
|
<NameDisplay
|
|
UserName={
|
|
valuesUserName.input !== ''
|
|
? valuesUserName.input
|
|
: lang.account.registration.stepFinal.userName
|
|
}
|
|
AccountName={
|
|
valuesAccountName.input !== ''
|
|
? valuesAccountName.input
|
|
: lang.account.registration.stepFinal.accountName
|
|
}
|
|
/>
|
|
</Center>
|
|
</Container>
|
|
{inputElement(valuesUserName, setValuesUserName, userName)}
|
|
{inputElement(valuesAccountName, setValuesAccountName, accountName)}
|
|
{inputElement(valuesPassword, setValuesPassword, password)}
|
|
|
|
<Box w="75%" maxW={350}>
|
|
<Text mx={4} mb={1} fontSize={13} color="light.400">
|
|
{passwordQualityText}
|
|
</Text>
|
|
<Progress
|
|
value={passwordQualityPercent}
|
|
mx={4}
|
|
_filledTrack={{
|
|
bg: passwordQualityColor,
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{inputElement(valuesPasswordRe, setValuesPasswordRe, passwordRe)}
|
|
</VStack>
|
|
<Center>
|
|
<Container mt={5}>
|
|
<Agreement />
|
|
</Container>
|
|
<Button
|
|
marginY={5}
|
|
w="75%"
|
|
maxW="350px"
|
|
colorScheme="primary"
|
|
rounded="xl"
|
|
_text={{fontSize: 'xl'}}
|
|
isLoading={isLoading}
|
|
onPress={() => {
|
|
function showToastNow(text: string) {
|
|
showToast(toast, {
|
|
title: text,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
}
|
|
|
|
valuesUserName.onTextChange({
|
|
nativeEvent: {text: valuesUserName.input},
|
|
});
|
|
|
|
valuesAccountName.onTextChange({
|
|
nativeEvent: {text: valuesAccountName.input},
|
|
});
|
|
|
|
valuesPassword.onTextChange({
|
|
nativeEvent: {text: valuesPassword.input},
|
|
});
|
|
|
|
let obj = {...valuesPasswordRe};
|
|
if (obj.input !== valuesPassword.input) obj.errorIndex = 'noMatch';
|
|
else obj.errorIndex = 'none';
|
|
setValuesPasswordRe(obj);
|
|
|
|
setLoading(true);
|
|
|
|
setTimeout(() => {
|
|
if (
|
|
valuesAccountName.input === '' ||
|
|
valuesUserName.input === '' ||
|
|
valuesPassword.input === '' ||
|
|
valuesPasswordRe.input === '' ||
|
|
valuesUserName.errorIndex !== 'none' ||
|
|
valuesAccountName.errorIndex !== 'none' ||
|
|
valuesPassword.errorIndex !== 'none' ||
|
|
valuesPasswordRe.errorIndex !== 'none'
|
|
) {
|
|
/*showToastNow(
|
|
lang.account.registration.stepFinal.noAllFieldsEntered,
|
|
);*/
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
let base64PW = Buffer.from(valuesPassword.input).toString(
|
|
'base64',
|
|
);
|
|
|
|
let xToken =
|
|
store.getState().appVariables.preferences.RegisterProcess
|
|
.XToken;
|
|
|
|
if (xToken === undefined) xToken = '';
|
|
|
|
makeRequest({
|
|
path: apiBackendRequest.REGISTER_STEP_FINAL,
|
|
requestHeader: {
|
|
'X-Token': xToken,
|
|
},
|
|
request: {
|
|
AccountName: valuesAccountName.input,
|
|
Username: valuesUserName.input,
|
|
Password: base64PW,
|
|
},
|
|
response: {
|
|
XAuthorization: '',
|
|
UserId: '',
|
|
WebSocketSessionId: '',
|
|
},
|
|
})
|
|
.then(resp => {
|
|
if (
|
|
resp.response.XAuthorization !== '' &&
|
|
resp.response.UserId !== '' &&
|
|
resp.response.WebSocketSessionId !== ''
|
|
) {
|
|
let regPro = {
|
|
...store.getState().appVariables.preferences
|
|
.RegisterProcess,
|
|
};
|
|
regPro.isRegistering = false;
|
|
store.dispatch(appVarActions.setRegisterProcess(regPro));
|
|
|
|
MyUserManager.createNewMyUser(
|
|
resp.response.UserId,
|
|
valuesAccountName.input,
|
|
valuesUserName.input,
|
|
store.getState().appVariables.preferences.RegisterProcess
|
|
.EMail,
|
|
resp.response.XAuthorization,
|
|
resp.response.WebSocketSessionId,
|
|
);
|
|
navigation.popToTop();
|
|
navigation.goBack();
|
|
}
|
|
|
|
setLoading(false);
|
|
})
|
|
.catch(resp => {
|
|
let errorText =
|
|
lang.account.registration.stepFinal.registerError[
|
|
resp.status
|
|
] || 'Error ' + resp.status;
|
|
showToastNow(errorText);
|
|
setLoading(false);
|
|
});
|
|
}, 300);
|
|
}}>
|
|
{lang.account.registration.stepFinal.button}
|
|
</Button>
|
|
</Center>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function Login() {
|
|
const lang = useSelector((state: RootState) => state.appVariables.lang);
|
|
const regPro = useSelector(
|
|
(state: RootState) => state.appVariables.preferences.RegisterProcess,
|
|
);
|
|
|
|
const {colors} = useTheme();
|
|
const dispatch = useDispatch();
|
|
const toast = useToast();
|
|
|
|
const navigation = useNavigation<RootScreenNavigationProp>();
|
|
|
|
const initNoErrors = {
|
|
wrongFormat: false,
|
|
alreadyExists: false,
|
|
noEntered: false,
|
|
unknown: undefined,
|
|
};
|
|
|
|
const [errorsMail, setErrorsMail] = useState(initNoErrors);
|
|
const [errorsPassword, setErrorsPassword] = useState(initNoErrors);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
const isErrorMail =
|
|
errorsMail.wrongFormat || errorsMail.alreadyExists || errorsMail.noEntered;
|
|
|
|
const isErrorPassword = errorsPassword.noEntered;
|
|
|
|
const errorTextMail = () => {
|
|
if (errorsMail.wrongFormat) {
|
|
return lang.account.registration.stepOne.addressInvalid;
|
|
}
|
|
if (errorsMail.noEntered) {
|
|
return lang.account.registration.stepOne.noMailEntered;
|
|
}
|
|
if (errorsMail.unknown !== undefined) {
|
|
return errorsMail.unknown;
|
|
}
|
|
};
|
|
|
|
const errorTextPassword = () => {
|
|
if (errorsPassword.noEntered) {
|
|
return lang.account.registration.stepFinal.passwordError.required;
|
|
}
|
|
if (errorsPassword.unknown !== undefined) {
|
|
return errorsPassword.unknown;
|
|
}
|
|
};
|
|
|
|
const [isLoading, setLoading] = useState(false);
|
|
|
|
const [values, setValues] = useState({email: regPro.EMail, password: ''});
|
|
|
|
const onButtonPress = () => {
|
|
if (values.email === '') {
|
|
let err = {...errorsMail};
|
|
err.noEntered = true;
|
|
setErrorsMail({...err});
|
|
}
|
|
if (values.password === '') {
|
|
let err = {...errorsPassword};
|
|
err.noEntered = true;
|
|
setErrorsPassword({...err});
|
|
} else if (validateEmail(values.email)) {
|
|
setLoading(true);
|
|
|
|
if (values.password.length < passwordOptions.minLength) {
|
|
setTimeout(() => {
|
|
setLoading(false);
|
|
|
|
showToast(toast, {
|
|
title: lang.account.login.wrongEmPw,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
let base64PW = Buffer.from(values.password).toString('base64');
|
|
|
|
makeRequest({
|
|
path: apiBackendRequest.LOGIN,
|
|
requestHeader: {},
|
|
request: {
|
|
Email: values.email,
|
|
Password: base64PW,
|
|
},
|
|
response: {
|
|
XAuthorization: '',
|
|
UserId: '',
|
|
WebSocketSessionId: '',
|
|
},
|
|
})
|
|
.then(resp => {
|
|
setLoading(false);
|
|
|
|
let accName = 'ga';
|
|
let userName = 'ga';
|
|
MyUserManager.createNewMyUser(
|
|
resp.response.UserId,
|
|
accName,
|
|
userName,
|
|
values.email,
|
|
resp.response.XAuthorization,
|
|
resp.response.WebSocketSessionId,
|
|
)
|
|
.then(() => {
|
|
showToast(toast, {
|
|
title: lang.account.login.success,
|
|
variant: 'solid',
|
|
status: 'success',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
|
|
setLoading(false);
|
|
navigation.goBack();
|
|
})
|
|
.catch(() => {
|
|
setLoading(false);
|
|
showToast(toast, {
|
|
title: lang.account.login.failed,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
});
|
|
})
|
|
.catch(resp => {
|
|
setLoading(false);
|
|
|
|
if (resp.status === 422) {
|
|
showToast(toast, {
|
|
title: lang.account.login.wrongEmPw,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
} else {
|
|
showToast(toast, {
|
|
title: lang.error + ' ' + resp.status,
|
|
variant: 'solid',
|
|
status: 'error',
|
|
description: undefined,
|
|
isClosable: true,
|
|
rest: {},
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
let err = {...errorsMail};
|
|
err.wrongFormat = true;
|
|
setErrorsMail({...err});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ScrollView keyboardShouldPersistTaps="handled">
|
|
<Box alignItems="center" mt={5}>
|
|
<FormControl
|
|
w="75%"
|
|
maxW="350px"
|
|
isRequired
|
|
isDisabled={isLoading}
|
|
isInvalid={isErrorMail}>
|
|
<FormControl.Label>
|
|
{lang.account.registration.stepOne.title}
|
|
</FormControl.Label>
|
|
<Input
|
|
autoFocus
|
|
autoComplete="email"
|
|
placeholder={lang.account.registration.stepOne.title}
|
|
value={values.email}
|
|
onSubmitEditing={onButtonPress}
|
|
autoCapitalize={'none'}
|
|
maxLength={emailOptions.maxLength}
|
|
keyboardType={'email-address'}
|
|
onChangeText={text => {
|
|
const mail = text.replaceAll(' ', '');
|
|
let val = {...values};
|
|
val.email = mail;
|
|
setValues(val);
|
|
|
|
if (errorsMail.noEntered && mail !== '') {
|
|
let err = errorsMail;
|
|
err.noEntered = false;
|
|
setErrorsMail({...err});
|
|
}
|
|
if (errorsMail.wrongFormat && validateEmail(mail)) {
|
|
let err = errorsMail;
|
|
err.wrongFormat = false;
|
|
setErrorsMail({...err});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
|
{errorTextMail()}
|
|
</FormControl.ErrorMessage>
|
|
</FormControl>
|
|
<FormControl
|
|
marginBottom={5}
|
|
w="75%"
|
|
maxW="350px"
|
|
isRequired
|
|
isDisabled={isLoading}
|
|
isInvalid={isErrorPassword}>
|
|
<FormControl.Label>
|
|
{lang.account.registration.stepFinal.password}
|
|
</FormControl.Label>
|
|
<Input
|
|
autoFocus
|
|
autoComplete="password"
|
|
placeholder={lang.account.registration.stepFinal.password}
|
|
value={values.password}
|
|
onSubmitEditing={onButtonPress}
|
|
autoCapitalize={'none'}
|
|
maxLength={emailOptions.maxLength}
|
|
type={showPassword === false ? 'password' : 'text'}
|
|
keyboardType={
|
|
showPassword === true ? 'visible-password' : undefined
|
|
}
|
|
InputRightElement={
|
|
<IconButton
|
|
mr="2"
|
|
focusable={false}
|
|
onPress={() => setShowPassword(!showPassword)}
|
|
icon={
|
|
<MaterialIcons
|
|
size={20}
|
|
color={colors.white[200]}
|
|
name={showPassword ? 'visibility' : 'visibility-off'}
|
|
/>
|
|
}
|
|
borderRadius="full"
|
|
/>
|
|
}
|
|
onChangeText={password => {
|
|
let val = {...values};
|
|
val.password = password;
|
|
setValues(val);
|
|
|
|
if (errorsPassword.noEntered && password !== '') {
|
|
let err = errorsPassword;
|
|
err.noEntered = false;
|
|
setErrorsMail({...err});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
|
{errorTextPassword()}
|
|
</FormControl.ErrorMessage>
|
|
</FormControl>
|
|
<Button
|
|
w="75%"
|
|
maxW="350px"
|
|
colorScheme="primary"
|
|
rounded="xl"
|
|
_text={{fontSize: 'xl'}}
|
|
isLoading={isLoading}
|
|
onPress={onButtonPress}>
|
|
{lang.account.login.title}
|
|
</Button>
|
|
</Box>
|
|
</ScrollView>
|
|
);
|
|
}
|