Merge branch 'develop' into feat/user-profile

This commit is contained in:
SajadMRjl
2025-08-15 20:16:38 +03:30
committed by GitHub
76 changed files with 6399 additions and 327 deletions

View File

@@ -1,12 +1,15 @@
import { CssBaseline } from '@mui/material';
import './App.css';
import { LanguageManager } from './components/LanguageManager';
import { LanguageManager } from '@/components/LanguageManager';
import { RouterProvider } from 'react-router-dom';
import { router } from '@/routes';
function App() {
return (
<>
<CssBaseline />
<LanguageManager />
<RouterProvider router={router} />
</>
);
}

View File

@@ -1,6 +1,7 @@
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { countries } from '@/features/profile/data/countries';
// TODO: move countries outside of feature directory
import { countries } from '@/features/authentication/data/Countries';
interface CountryFlagProps {
code: string;

View File

@@ -0,0 +1,35 @@
import { Box, IconButton, Typography } from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { More } from 'iconsax-react';
import type { User } from './type';
interface HeaderProps {
user: User;
}
export const Header: React.FC<HeaderProps> = ({ user }) => {
return (
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: (t) => t.spacing(10.5),
}}
>
<Box>
<Typography variant="body1">
{user.firstName + ' ' + user.lastName}
</Typography>
<Typography variant="body2" color="textSecondary">
{user.phoneNumber}
</Typography>
</Box>
<IconButton>
<Icon Component={More} />
</IconButton>
</Box>
);
};

View File

@@ -0,0 +1,69 @@
import { SideNav } from '@rkheftan/harmony-ui';
import { buildNavItems } from './buildNavItems';
import { appRoutes } from '@/routes/config';
import { Outlet, useLocation } from 'react-router-dom';
import { Box, useMediaQuery, useTheme } from '@mui/material';
import { Header } from './Header';
import { useState } from 'react';
import { Toolbar } from './Toolbar';
import type { User } from './type';
export const Layout = () => {
const navItemConfigs = buildNavItems(appRoutes);
const location = useLocation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [sideNavOpen, setSideNavOpen] = useState(false);
const [user] = useState<User>({
firstName: 'محمد حسین',
lastName: 'برزه گر',
phoneNumber: '09123456789',
});
return (
<Box
sx={{
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Box
sx={{
// TODO: Check for better approach
width: isMobile ? '100%' : 1070,
height: isMobile ? '100vh' : '80%',
display: 'flex',
flexDirection: 'row-reverse',
borderRadius: isMobile ? 0 : 4,
backgroundColor: 'background.paper',
overflow: 'hidden',
}}
>
<Box sx={{ flex: 1 }}>
<Toolbar
isMobile={isMobile}
sideNavOpen={sideNavOpen}
setSideNavOpen={setSideNavOpen}
user={user}
/>
<Outlet />
</Box>
<SideNav
open={sideNavOpen}
onClose={() => setSideNavOpen(false)}
header={isMobile ? undefined : <Header user={user} />}
footer={isMobile ? <Header user={user} /> : undefined}
navConfig={navItemConfigs}
activePath={location.pathname + location.hash}
selectedVariant="textOnly"
positioning="absolute"
sideNavVariant={isMobile ? 'temporary' : 'full'}
top={8.125}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,75 @@
import {
Avatar,
Box,
IconButton,
Toolbar as MuiToolbar,
Typography,
} from '@mui/material';
import { Icon } from '@rkheftan/harmony-ui';
import { HambergerMenu, Menu } from 'iconsax-react';
import type { Dispatch, SetStateAction } from 'react';
import type { User } from './type';
interface ToolbarProps {
sideNavOpen: boolean;
setSideNavOpen: Dispatch<SetStateAction<boolean>>;
isMobile: boolean;
user: User;
}
export const Toolbar: React.FC<ToolbarProps> = ({
sideNavOpen,
setSideNavOpen,
isMobile,
user,
}) => {
return (
<MuiToolbar
sx={{
height: (t) => t.spacing(isMobile ? 8 : 10.5),
px: isMobile ? 3 : 2,
borderBottom: (t) => `1px solid ${t.palette.divider}`,
boxSizing: 'content-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box
sx={{ display: 'flex', height: '100%', alignItems: 'center', gap: 2 }}
>
{isMobile && (
<IconButton
sx={{
backgroundColor: sideNavOpen ? 'primary.light' : undefined,
color: sideNavOpen ? 'primary.main' : undefined,
'&:hover': {
backgroundColor: sideNavOpen ? 'primary.light' : undefined,
},
}}
onClick={() => setSideNavOpen(!sideNavOpen)}
>
<Icon Component={HambergerMenu} />
</IconButton>
)}
{/* <Logo /> */}
<Typography variant="h6">LOGO placeholder</Typography>
</Box>
<Box
sx={{ display: 'flex', height: '100%', alignItems: 'center', gap: 1 }}
>
{isMobile && (
<Avatar
sx={{ width: 32, height: 32, fontSize: '14px' }}
src={user.profileUrl}
>
{user.firstName.charAt(0) + ' ' + user.lastName.charAt(0)}
</Avatar>
)}
<IconButton>
<Icon Component={Menu} variant="Bold" />
</IconButton>
</Box>
</MuiToolbar>
);
};

View File

@@ -0,0 +1,34 @@
// src/components/SideNav.tsx (Conceptual Example)
import { useTranslation } from 'react-i18next';
import { type RouteConfig } from '@/routes/config';
import { Icon, type NavItemConfig } from '@rkheftan/harmony-ui';
import type { Icon as Iconsax } from 'iconsax-react';
const getIcon = (icon?: Iconsax) => (isSelected: boolean) =>
icon ? (
<Icon Component={icon} variant={isSelected ? 'Bold' : 'Broken'} />
) : undefined;
export function buildNavItems(routes: RouteConfig[]): NavItemConfig[] {
const { t } = useTranslation();
return routes.flatMap((route) => {
// Check if route itself does not have a navItem but its child has
if (!route.navConfig && route.children) {
return buildNavItems(route.children);
}
// Check if route.navConfig is defined before destructuring
if (!route.navConfig) {
return []; // Return an empty array to be flattened
}
const { title, icon } = route.navConfig;
return {
text: t(title),
getIcon: getIcon(icon),
path: route.path,
children: route.children ? buildNavItems(route.children) : undefined,
};
});
}

View File

@@ -0,0 +1,8 @@
// TODO: this type file is temporary and should replace it with the actual use type and value when api is ready
export interface User {
firstName: string;
lastName: string;
phoneNumber: string;
profileUrl?: string;
}

27
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Alert, Snackbar, type AlertColor } from '@mui/material';
import { type PropsWithChildren } from 'react';
export interface ToastProps extends PropsWithChildren {
color: AlertColor | undefined;
open: boolean;
onClose: () => void;
}
export const Toast = ({ color, open, onClose, children }: ToastProps) => {
return (
<Snackbar
sx={{ width: (t) => `calc(100% - ${t.spacing(6)})`, maxWidth: '396px' }}
open={open}
onClose={onClose}
>
<Alert
onClose={onClose}
severity={color}
variant="filled"
sx={{ width: '100%' }}
>
{children}
</Alert>
</Snackbar>
);
};

View File

@@ -0,0 +1,124 @@
import React, {
useRef,
useEffect,
type SetStateAction,
type Dispatch,
useState,
type KeyboardEvent,
} from 'react';
import { TextField, Stack } from '@mui/material';
interface DigitInputProps {
error: boolean;
success: boolean;
onChange: Dispatch<SetStateAction<string>>;
}
const DigitInput: React.FC<DigitInputProps> = ({
onChange,
error,
success,
}) => {
const [code, setCode] = useState<string[]>(['', '', '', '']);
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
useEffect(() => {
inputRefs.current[0]?.focus();
}, []);
const handleDigitInputValueChange = (value: string[]) => {
const formatted = value.filter((char) => char !== '').join('');
onChange(formatted);
};
const handleChange = (value: string, index: number) => {
if (!/^\d$/.test(value) && value !== '') return;
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
handleDigitInputValueChange(newCode);
if (value && index < 4 - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleBackspace = (
event: KeyboardEvent<HTMLDivElement>,
index: number,
) => {
event.preventDefault();
if (index >= 0) {
handleChange('', index);
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (event: React.ClipboardEvent) => {
event.preventDefault();
const pastedData = event.clipboardData.getData('text').replace(/\D/g, ''); // Remove non-digit characters
const newCode = [...code];
pastedData.split('').forEach((digit, i) => {
if (i < code.length) {
newCode[i] = digit;
}
});
setCode(newCode);
handleDigitInputValueChange(newCode);
// Focus the next empty input after the last pasted character
const lastIndex = Math.min(pastedData.length, code.length) - 1;
if (lastIndex >= 0 && inputRefs.current[lastIndex]) {
inputRefs.current[lastIndex]?.focus();
}
};
return (
<Stack
direction="row-reverse"
alignItems="center"
sx={{ gap: 2, width: '100%', my: 4 }}
justifyContent="center"
>
{code.map((digit, index) => (
<TextField
error={error}
color={success ? 'success' : 'primary'}
key={index}
inputRef={(el) => (inputRefs.current[index] = el)}
value={digit}
onChange={(e) => handleChange(e.target.value, index)}
onKeyDown={(e) => e.key === 'Backspace' && handleBackspace(e, index)}
onPaste={(e) => handlePaste(e)}
slotProps={{
htmlInput: {
maxLength: 1,
sx: {
height: '72px',
color: error
? 'error.main'
: success
? 'success.main'
: 'text.primary',
},
style: {
textAlign: 'center',
fontSize: '48px',
},
},
}}
variant="standard"
size="medium"
sx={{
width: '83px',
}}
/>
))}
</Stack>
);
};
export default DigitInput;

View File

@@ -0,0 +1,8 @@
import { Box, styled } from '@mui/material';
export const Container = styled(Box)(() => ({
width: '100%',
maxWidth: '100vw',
height: '100vh',
margin: '0 auto',
}));

View File

@@ -0,0 +1,21 @@
import { Box, styled, type BoxProps } from '@mui/material';
// Define the props our component will accept
interface FlexBoxProps extends BoxProps {
direction?: 'row' | 'column';
justify?: string;
align?: string;
}
export const FlexBox = styled(Box, {
// Do not forward these custom props to the DOM element
shouldForwardProp: (prop) =>
prop !== 'direction' && prop !== 'justify' && prop !== 'align',
})<FlexBoxProps>(
({ direction = 'row', justify = 'flex-start', align = 'stretch' }) => ({
display: 'flex',
flexDirection: direction,
justifyContent: justify,
alignItems: align,
}),
);

View File

@@ -0,0 +1,19 @@
import { Box, styled, type BoxProps } from '@mui/material';
interface StackProps extends BoxProps {
direction?: 'row' | 'column';
spacing?: number; // Spacing factor (multiplied by theme.spacing)
align?: string;
}
export const Stack = styled(Box, {
shouldForwardProp: (prop) =>
prop !== 'direction' && prop !== 'spacing' && prop !== 'align',
})<StackProps>(
({ theme, direction = 'column', spacing = 2, align = 'stretch' }) => ({
display: 'flex',
flexDirection: direction,
alignItems: align,
gap: theme.spacing(spacing),
}),
);

267
src/countries.ts Normal file
View File

@@ -0,0 +1,267 @@
import type { CountryCode } from '@/types/commonTypes';
export interface Country {
code: string;
label: string;
phone: CountryCode;
}
export const countries: readonly Country[] = [
{ code: 'AF', label: 'country.afghanistan', phone: '+93' },
{ code: 'AX', label: 'country.aland_islands', phone: '+358' },
{ code: 'AL', label: 'country.albania', phone: '+355' },
{ code: 'DZ', label: 'country.algeria', phone: '+213' },
{ code: 'AS', label: 'country.american_samoa', phone: '+1684' },
{ code: 'AD', label: 'country.andorra', phone: '+376' },
{ code: 'AO', label: 'country.angola', phone: '+244' },
{ code: 'AI', label: 'country.anguilla', phone: '+1264' },
{ code: 'AQ', label: 'country.antarctica', phone: '+672' },
{ code: 'AG', label: 'country.antigua_and_barbuda', phone: '+1268' },
{ code: 'AR', label: 'country.argentina', phone: '+54' },
{ code: 'AM', label: 'country.armenia', phone: '+374' },
{ code: 'AW', label: 'country.aruba', phone: '+297' },
{ code: 'AU', label: 'country.australia', phone: '+61' },
{ code: 'AT', label: 'country.austria', phone: '+43' },
{ code: 'AZ', label: 'country.azerbaijan', phone: '+994' },
{ code: 'BS', label: 'country.bahamas', phone: '+1242' },
{ code: 'BH', label: 'country.bahrain', phone: '+973' },
{ code: 'BD', label: 'country.bangladesh', phone: '+880' },
{ code: 'BB', label: 'country.barbados', phone: '+1246' },
{ code: 'BY', label: 'country.belarus', phone: '+375' },
{ code: 'BE', label: 'country.belgium', phone: '+32' },
{ code: 'BZ', label: 'country.belize', phone: '+501' },
{ code: 'BJ', label: 'country.benin', phone: '+229' },
{ code: 'BM', label: 'country.bermuda', phone: '+1441' },
{ code: 'BT', label: 'country.bhutan', phone: '+975' },
{ code: 'BO', label: 'country.bolivia', phone: '+591' },
{ code: 'BA', label: 'country.bosnia_and_herzegovina', phone: '+387' },
{ code: 'BW', label: 'country.botswana', phone: '+267' },
{ code: 'BR', label: 'country.brazil', phone: '+55' },
{
code: 'IO',
label: 'country.british_indian_ocean_territory',
phone: '+246',
},
{ code: 'VG', label: 'country.british_virgin_islands', phone: '+1284' },
{ code: 'BN', label: 'country.brunei', phone: '+673' },
{ code: 'BG', label: 'country.bulgaria', phone: '+359' },
{ code: 'BF', label: 'country.burkina_faso', phone: '+226' },
{ code: 'BI', label: 'country.burundi', phone: '+257' },
{ code: 'KH', label: 'country.cambodia', phone: '+855' },
{ code: 'CM', label: 'country.cameroon', phone: '+237' },
{ code: 'CA', label: 'country.canada', phone: '+1' },
{ code: 'CV', label: 'country.cape_verde', phone: '+238' },
{ code: 'KY', label: 'country.cayman_islands', phone: '+1345' },
{ code: 'CF', label: 'country.central_african_republic', phone: '+236' },
{ code: 'TD', label: 'country.chad', phone: '+235' },
{ code: 'CL', label: 'country.chile', phone: '+56' },
{ code: 'CN', label: 'country.china', phone: '+86' },
{ code: 'CX', label: 'country.christmas_island', phone: '+61' },
{ code: 'CC', label: 'country.cocos_keeling_islands', phone: '+61' },
{ code: 'CO', label: 'country.colombia', phone: '+57' },
{ code: 'KM', label: 'country.comoros', phone: '+269' },
{ code: 'CG', label: 'country.congo_brazzaville', phone: '+242' },
{ code: 'CD', label: 'country.congo_kinshasa', phone: '+243' },
{ code: 'CK', label: 'country.cook_islands', phone: '+682' },
{ code: 'CR', label: 'country.costa_rica', phone: '+506' },
{ code: 'CI', label: 'country.cote_divoire', phone: '+225' },
{ code: 'HR', label: 'country.croatia', phone: '+385' },
{ code: 'CU', label: 'country.cuba', phone: '+53' },
{ code: 'CW', label: 'country.curacao', phone: '+599' },
{ code: 'CY', label: 'country.cyprus', phone: '+357' },
{ code: 'CZ', label: 'country.czech_republic', phone: '+420' },
{ code: 'DK', label: 'country.denmark', phone: '+45' },
{ code: 'DJ', label: 'country.djibouti', phone: '+253' },
{ code: 'DM', label: 'country.dominica', phone: '+1767' },
{ code: 'DO', label: 'country.dominican_republic', phone: '+1' },
{ code: 'EC', label: 'country.ecuador', phone: '+593' },
{ code: 'EG', label: 'country.egypt', phone: '+20' },
{ code: 'SV', label: 'country.el_salvador', phone: '+503' },
{ code: 'GQ', label: 'country.equatorial_guinea', phone: '+240' },
{ code: 'ER', label: 'country.eritrea', phone: '+291' },
{ code: 'EE', label: 'country.estonia', phone: '+372' },
{ code: 'SZ', label: 'country.eswatini', phone: '+268' },
{ code: 'ET', label: 'country.ethiopia', phone: '+251' },
{ code: 'FK', label: 'country.falkland_islands', phone: '+500' },
{ code: 'FO', label: 'country.faroe_islands', phone: '+298' },
{ code: 'FJ', label: 'country.fiji', phone: '+679' },
{ code: 'FI', label: 'country.finland', phone: '+358' },
{ code: 'FR', label: 'country.france', phone: '+33' },
{ code: 'GF', label: 'country.french_guiana', phone: '+594' },
{ code: 'PF', label: 'country.french_polynesia', phone: '+689' },
{ code: 'GA', label: 'country.gabon', phone: '+241' },
{ code: 'GM', label: 'country.gambia', phone: '+220' },
{ code: 'GE', label: 'country.georgia', phone: '+995' },
{ code: 'DE', label: 'country.germany', phone: '+49' },
{ code: 'GH', label: 'country.ghana', phone: '+233' },
{ code: 'GI', label: 'country.gibraltar', phone: '+350' },
{ code: 'GR', label: 'country.greece', phone: '+30' },
{ code: 'GL', label: 'country.greenland', phone: '+299' },
{ code: 'GD', label: 'country.grenada', phone: '+1473' },
{ code: 'GP', label: 'country.guadeloupe', phone: '+590' },
{ code: 'GU', label: 'country.guam', phone: '+1671' },
{ code: 'GT', label: 'country.guatemala', phone: '+502' },
{ code: 'GG', label: 'country.guernsey', phone: '+44' },
{ code: 'GN', label: 'country.guinea', phone: '+224' },
{ code: 'GW', label: 'country.guinea_bissau', phone: '+245' },
{ code: 'GY', label: 'country.guyana', phone: '+592' },
{ code: 'HT', label: 'country.haiti', phone: '+509' },
{ code: 'HN', label: 'country.honduras', phone: '+504' },
{ code: 'HK', label: 'country.hong_kong', phone: '+852' },
{ code: 'HU', label: 'country.hungary', phone: '+36' },
{ code: 'IS', label: 'country.iceland', phone: '+354' },
{ code: 'IN', label: 'country.india', phone: '+91' },
{ code: 'ID', label: 'country.indonesia', phone: '+62' },
{ code: 'IR', label: 'country.iran', phone: '+98' },
{ code: 'IQ', label: 'country.iraq', phone: '+964' },
{ code: 'IE', label: 'country.ireland', phone: '+353' },
{ code: 'IM', label: 'country.isle_of_man', phone: '+44' },
{ code: 'IL', label: 'country.israel', phone: '+972' },
{ code: 'IT', label: 'country.italy', phone: '+39' },
{ code: 'JM', label: 'country.jamaica', phone: '+1876' },
{ code: 'JP', label: 'country.japan', phone: '+81' },
{ code: 'JE', label: 'country.jersey', phone: '+44' },
{ code: 'JO', label: 'country.jordan', phone: '+962' },
{ code: 'KZ', label: 'country.kazakhstan', phone: '+7' },
{ code: 'KE', label: 'country.kenya', phone: '+254' },
{ code: 'KI', label: 'country.kiribati', phone: '+686' },
{ code: 'XK', label: 'country.kosovo', phone: '+383' },
{ code: 'KW', label: 'country.kuwait', phone: '+965' },
{ code: 'KG', label: 'country.kyrgyzstan', phone: '+996' },
{ code: 'LA', label: 'country.laos', phone: '+856' },
{ code: 'LV', label: 'country.latvia', phone: '+371' },
{ code: 'LB', label: 'country.lebanon', phone: '+961' },
{ code: 'LS', label: 'country.lesotho', phone: '+266' },
{ code: 'LR', label: 'country.liberia', phone: '+231' },
{ code: 'LY', label: 'country.libya', phone: '+218' },
{ code: 'LI', label: 'country.liechtenstein', phone: '+423' },
{ code: 'LT', label: 'country.lithuania', phone: '+370' },
{ code: 'LU', label: 'country.luxembourg', phone: '+352' },
{ code: 'MO', label: 'country.macau', phone: '+853' },
{ code: 'MG', label: 'country.madagascar', phone: '+261' },
{ code: 'MW', label: 'country.malawi', phone: '+265' },
{ code: 'MY', label: 'country.malaysia', phone: '+60' },
{ code: 'MV', label: 'country.maldives', phone: '+960' },
{ code: 'ML', label: 'country.mali', phone: '+223' },
{ code: 'MT', label: 'country.malta', phone: '+356' },
{ code: 'MH', label: 'country.marshall_islands', phone: '+692' },
{ code: 'MQ', label: 'country.martinique', phone: '+596' },
{ code: 'MR', label: 'country.mauritania', phone: '+222' },
{ code: 'MU', label: 'country.mauritius', phone: '+230' },
{ code: 'YT', label: 'country.mayotte', phone: '+262' },
{ code: 'MX', label: 'country.mexico', phone: '+52' },
{ code: 'FM', label: 'country.micronesia', phone: '+691' },
{ code: 'MD', label: 'country.moldova', phone: '+373' },
{ code: 'MC', label: 'country.monaco', phone: '+377' },
{ code: 'MN', label: 'country.mongolia', phone: '+976' },
{ code: 'ME', label: 'country.montenegro', phone: '+382' },
{ code: 'MS', label: 'country.montserrat', phone: '+1664' },
{ code: 'MA', label: 'country.morocco', phone: '+212' },
{ code: 'MZ', label: 'country.mozambique', phone: '+258' },
{ code: 'MM', label: 'country.myanmar', phone: '+95' },
{ code: 'NA', label: 'country.namibia', phone: '+264' },
{ code: 'NR', label: 'country.nauru', phone: '+674' },
{ code: 'NP', label: 'country.nepal', phone: '+977' },
{ code: 'NL', label: 'country.netherlands', phone: '+31' },
{ code: 'NC', label: 'country.new_caledonia', phone: '+687' },
{ code: 'NZ', label: 'country.new_zealand', phone: '+64' },
{ code: 'NI', label: 'country.nicaragua', phone: '+505' },
{ code: 'NE', label: 'country.niger', phone: '+227' },
{ code: 'NG', label: 'country.nigeria', phone: '+234' },
{ code: 'NU', label: 'country.niue', phone: '+683' },
{ code: 'NF', label: 'country.norfolk_island', phone: '+672' },
{ code: 'KP', label: 'country.north_korea', phone: '+850' },
{ code: 'MK', label: 'country.north_macedonia', phone: '+389' },
{ code: 'MP', label: 'country.northern_mariana_islands', phone: '+1670' },
{ code: 'NO', label: 'country.norway', phone: '+47' },
{ code: 'OM', label: 'country.oman', phone: '+968' },
{ code: 'PK', label: 'country.pakistan', phone: '+92' },
{ code: 'PW', label: 'country.palau', phone: '+680' },
{ code: 'PS', label: 'country.palestine', phone: '+970' },
{ code: 'PA', label: 'country.panama', phone: '+507' },
{ code: 'PG', label: 'country.papua_new_guinea', phone: '+675' },
{ code: 'PY', label: 'country.paraguay', phone: '+595' },
{ code: 'PE', label: 'country.peru', phone: '+51' },
{ code: 'PH', label: 'country.philippines', phone: '+63' },
{ code: 'PN', label: 'country.pitcairn_islands', phone: '+64' },
{ code: 'PL', label: 'country.poland', phone: '+48' },
{ code: 'PT', label: 'country.portugal', phone: '+351' },
{ code: 'PR', label: 'country.puerto_rico', phone: '+1' },
{ code: 'QA', label: 'country.qatar', phone: '+974' },
{ code: 'RE', label: 'country.reunion', phone: '+262' },
{ code: 'RO', label: 'country.romania', phone: '+40' },
{ code: 'RU', label: 'country.russia', phone: '+7' },
{ code: 'RW', label: 'country.rwanda', phone: '+250' },
{ code: 'BL', label: 'country.saint_barthelemy', phone: '+590' },
{ code: 'SH', label: 'country.saint_helena', phone: '+290' },
{ code: 'KN', label: 'country.saint_kitts_and_nevis', phone: '+1869' },
{ code: 'LC', label: 'country.saint_lucia', phone: '+1758' },
{ code: 'MF', label: 'country.saint_martin', phone: '+590' },
{ code: 'PM', label: 'country.saint_pierre_and_miquelon', phone: '+508' },
{
code: 'VC',
label: 'country.saint_vincent_and_the_grenadines',
phone: '+1784',
},
{ code: 'WS', label: 'country.samoa', phone: '+685' },
{ code: 'SM', label: 'country.san_marino', phone: '+378' },
{ code: 'ST', label: 'country.sao_tome_and_principe', phone: '+239' },
{ code: 'SA', label: 'country.saudi_arabia', phone: '+966' },
{ code: 'SN', label: 'country.senegal', phone: '+221' },
{ code: 'RS', label: 'country.serbia', phone: '+381' },
{ code: 'SC', label: 'country.seychelles', phone: '+248' },
{ code: 'SL', label: 'country.sierra_leone', phone: '+232' },
{ code: 'SG', label: 'country.singapore', phone: '+65' },
{ code: 'SX', label: 'country.sint_maarten', phone: '+1721' },
{ code: 'SK', label: 'country.slovakia', phone: '+421' },
{ code: 'SI', label: 'country.slovenia', phone: '+386' },
{ code: 'SB', label: 'country.solomon_islands', phone: '+677' },
{ code: 'SO', label: 'country.somalia', phone: '+252' },
{ code: 'ZA', label: 'country.south_africa', phone: '+27' },
{
code: 'GS',
label: 'country.south_georgia_and_south_sandwich_islands',
phone: '+500',
},
{ code: 'KR', label: 'country.south_korea', phone: '+82' },
{ code: 'SS', label: 'country.south_sudan', phone: '+211' },
{ code: 'ES', label: 'country.spain', phone: '+34' },
{ code: 'LK', label: 'country.sri_lanka', phone: '+94' },
{ code: 'SD', label: 'country.sudan', phone: '+249' },
{ code: 'SR', label: 'country.suriname', phone: '+597' },
{ code: 'SJ', label: 'country.svalbard_and_jan_mayen', phone: '+47' },
{ code: 'SE', label: 'country.sweden', phone: '+46' },
{ code: 'CH', label: 'country.switzerland', phone: '+41' },
{ code: 'SY', label: 'country.syria', phone: '+963' },
{ code: 'TW', label: 'country.taiwan', phone: '+886' },
{ code: 'TJ', label: 'country.tajikistan', phone: '+992' },
{ code: 'TZ', label: 'country.tanzania', phone: '+255' },
{ code: 'TH', label: 'country.thailand', phone: '+66' },
{ code: 'TL', label: 'country.timor_leste', phone: '+670' },
{ code: 'TG', label: 'country.togo', phone: '+228' },
{ code: 'TK', label: 'country.tokelau', phone: '+690' },
{ code: 'TO', label: 'country.tonga', phone: '+676' },
{ code: 'TT', label: 'country.trinidad_and_tobago', phone: '+1868' },
{ code: 'TN', label: 'country.tunisia', phone: '+216' },
{ code: 'TR', label: 'country.turkey', phone: '+90' },
{ code: 'TM', label: 'country.turkmenistan', phone: '+993' },
{ code: 'TC', label: 'country.turks_and_caicos_islands', phone: '+1649' },
{ code: 'TV', label: 'country.tuvalu', phone: '+688' },
{ code: 'VI', label: 'country.us_virgin_islands', phone: '+1340' },
{ code: 'UG', label: 'country.uganda', phone: '+256' },
{ code: 'UA', label: 'country.ukraine', phone: '+380' },
{ code: 'AE', label: 'country.united_arab_emirates', phone: '+971' },
{ code: 'GB', label: 'country.united_kingdom', phone: '+44' },
{ code: 'US', label: 'country.united_states', phone: '+1' },
{ code: 'UY', label: 'country.uruguay', phone: '+598' },
{ code: 'UZ', label: 'country.uzbekistan', phone: '+998' },
{ code: 'VU', label: 'country.vanuatu', phone: '+678' },
{ code: 'VA', label: 'country.vatican_city', phone: '+39' },
{ code: 'VE', label: 'country.venezuela', phone: '+58' },
{ code: 'VN', label: 'country.vietnam', phone: '+84' },
{ code: 'WF', label: 'country.wallis_and_futuna', phone: '+681' },
{ code: 'EH', label: 'country.western_sahara', phone: '+212' },
{ code: 'YE', label: 'country.yemen', phone: '+967' },
{ code: 'ZM', label: 'country.zambia', phone: '+260' },
{ code: 'ZW', label: 'country.zimbabwe', phone: '+263' },
];

View File

@@ -0,0 +1,32 @@
import {
type SendEmailOtpPayload,
type ConfirmEmailOtpPayload,
type CompleteUserInfoPayload,
type CompleteUserInfoResponse,
type GenericApiResponse,
} from '../types/completionFormApiTypes';
import apiClient from '@/lib/apiClient';
export const sendEmailOtpApi = async (
payload: SendEmailOtpPayload,
): Promise<GenericApiResponse & { codeSentAnyway?: boolean }> => {
const { data } = await apiClient.post('/User/SendEmailOtp', payload);
return data;
};
export const confirmEmailOtpApi = async (
payload: ConfirmEmailOtpPayload,
): Promise<GenericApiResponse> => {
const { data } = await apiClient.post('/User/ConfirmEmailOtp', payload);
return data;
};
export const completeUserInformationApi = async (
payload: CompleteUserInfoPayload,
): Promise<CompleteUserInfoResponse> => {
const { data } = await apiClient.post(
'/User/CompleteUserInformation',
payload,
);
return data;
};

View File

@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import {
DatePicker,
PickersDay,
type PickersDayProps,
} from '@mui/x-date-pickers';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { AdapterDateFnsJalali } from '@mui/x-date-pickers/AdapterDateFnsJalali';
import { enUS } from 'date-fns/locale';
import { faIR as faIRJalali } from 'date-fns-jalali/locale';
import { getDay } from 'date-fns-jalali';
import { format as formatJalali } from 'date-fns-jalali';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { toLocaleDigits } from '@/utils/persianDigit';
import { type DateOfBirthProps } from '../types/settingForm';
export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
const { t, i18n } = useTranslation('completionForm');
const isFarsi = i18n.language === 'fa' || i18n.language === 'fa-IR';
const { Adapter, locale, formatString, dayOfWeekFormatter } = useMemo(() => {
if (isFarsi) {
const persianDays = ['ی', 'د', 'س', 'چ', 'پ', 'ج', 'ش'];
return {
Adapter: AdapterDateFnsJalali,
locale: faIRJalali,
formatString: 'yyyy/MM/dd',
dayOfWeekFormatter: (date: Date) => persianDays[getDay(date)],
};
}
return {
Adapter: AdapterDateFns,
locale: enUS,
formatString: 'MM/dd/yyyy',
dayOfWeekFormatter: undefined,
};
}, [isFarsi]);
const CustomDay = (props: PickersDayProps) => {
const dayNumber = isFarsi
? formatJalali(props.day, 'dd')
: format(props.day, 'dd');
return (
<PickersDay {...props}>
{toLocaleDigits(dayNumber, i18n.language)}
</PickersDay>
);
};
return (
<LocalizationProvider dateAdapter={Adapter} adapterLocale={locale}>
<DatePicker
label={toLocaleDigits(t('completion.dateOfBirth'), i18n.language)}
value={value}
onChange={onChange}
format={formatString}
dayOfWeekFormatter={dayOfWeekFormatter}
slots={{ day: CustomDay }}
slotProps={{
textField: {
fullWidth: true,
sx: {
flex: '1 1 260px',
},
},
}}
/>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,184 @@
import React from 'react';
import {
TextField,
Box,
Button,
Switch,
FormGroup,
Typography,
InputAdornment,
IconButton,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { TickCircle, Edit } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type EmailSectionProps } from '../types/settingForm';
export function EmailSection({
showEmail,
setShowEmail,
email,
setEmail,
correctEmail,
codeSent,
verificationCode,
setVerificationCode,
buttonState,
getButtonLabel,
handleSendCode,
handleVerifyCode,
emailVerified,
loading,
handleEditEmail,
}: EmailSectionProps) {
const { t } = useTranslation('completionForm');
const onSendCodeClick = () => {
if (!correctEmail) return;
handleSendCode();
};
const handleToggleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowEmail(e.target.checked);
};
return (
<>
<FormGroup>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: { xs: 2, sm: 0 },
}}
>
<Switch checked={showEmail} onChange={handleToggleEmail} />
<Typography
sx={{ color: showEmail ? 'primary.main' : 'text.primary' }}
>
{t('completion.determineEmail')}
</Typography>
</Box>
</FormGroup>
{showEmail && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 0 },
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.email')}
variant="outlined"
fullWidth
value={email}
onChange={(e) => setEmail(e.target.value)}
error={email.length > 0 && !correctEmail}
sx={{ flex: '1 1 260px' }}
slotProps={{
input: {
startAdornment:
!loading && emailVerified ? (
<InputAdornment position="end">
<Icon
Component={TickCircle}
size="medium"
variant="Bold"
color="success.main"
/>
</InputAdornment>
) : null,
endAdornment:
buttonState === 'counting' ? (
<InputAdornment position="start">
<IconButton onClick={handleEditEmail}>
<Icon
Component={Edit}
color="primary.main"
size="medium"
/>
</IconButton>
</InputAdornment>
) : null,
sx: {
paddingLeft: buttonState === 'counting' ? 0 : undefined,
},
},
}}
/>
{!loading && !emailVerified && (
<Button
type="button"
variant="text"
onClick={onSendCodeClick}
sx={{
minWidth: '140px',
width: { xs: '100%', sm: '156px' },
alignSelf: 'center',
textTransform: 'none',
}}
>
{getButtonLabel()}
</Button>
)}
</Box>
{email && (
<Typography
sx={{ color: correctEmail ? 'success.main' : 'error.main' }}
variant="caption"
>
{correctEmail ? '' : t('completion.emailCorrectForm')}
</Typography>
)}
{!emailVerified && codeSent && correctEmail && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.verificationCode')}
variant="outlined"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
sx={{ flex: '1 1 260px' }}
disabled={loading}
/>
<Button
variant="contained"
onClick={handleVerifyCode}
disabled={loading}
sx={{
width: { xs: '100%', sm: '156px' },
border: 0.5,
textTransform: 'none',
}}
>
{loading ? (
<CircularProgress size={20} />
) : (
t('completion.checkCodeButton')
)}
</Button>
</Box>
)}
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import {
TextField,
Box,
IconButton,
Switch,
FormGroup,
Typography,
InputAdornment,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react';
import { PasswordValidationItem } from './PasswordValidation';
import { Icon } from '@rkheftan/harmony-ui';
import { type PasswordSectionProps } from '../types/settingForm';
export function PasswordSection({
showPasswordSection,
setShowPasswordSection,
password,
setPassword,
confirmPassword,
setConfirmPassword,
matchPassword,
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
showValidations,
}: PasswordSectionProps) {
const { t } = useTranslation('completionForm');
const [showPasswordText, setShowPasswordText] = useState(false);
const [showPasswordRepetitionText, setShowPasswordRepetitionText] =
useState(false);
const handleTogglePasswordSection = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setShowPasswordSection(e.target.checked);
};
const handleTogglePasswordEye = () => setShowPasswordText((prev) => !prev);
const handleTogglePasswordRepetitionEye = () =>
setShowPasswordRepetitionText((prev) => !prev);
return (
<>
<FormGroup>
<Box
sx={{
display: 'flex',
gap: 0.5,
px: { xs: 2, sm: -4 },
alignItems: 'center',
}}
>
<Switch
checked={showPasswordSection}
onChange={handleTogglePasswordSection}
/>
<Typography
sx={{
color: showPasswordSection ? 'primary.main' : 'text.primary',
}}
>
{t('completion.determinePassword')}
</Typography>
</Box>
</FormGroup>
{showPasswordSection && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 0 },
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
variant="outlined"
type={showPasswordText ? 'text' : 'password'}
sx={{
flex: '1 1 260px',
'& .MuiInputBase-input': {
pr: 8, // Increased padding to accommodate both icons
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: '64px', // Adjusted to fit both icons
}}
>
<IconButton
onClick={handleTogglePasswordEye}
sx={{ p: 0.5 }}
>
{showPasswordText ? (
<Icon
Component={Eye}
color="primary.main"
size="medium"
/>
) : (
<Icon
Component={EyeSlash}
size="medium"
color="primary.main"
/>
)}
</IconButton>
{validPassword && (
<Icon
Component={TickCircle}
size="medium"
color="success.main"
variant="Bold"
/>
)}
</Box>
</InputAdornment>
),
}}
/>
<TextField
label={t('completion.passwordRepetition')}
variant="outlined"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={confirmPassword.length > 0 && !matchPassword}
helperText={
confirmPassword.length > 0 && !matchPassword
? t('completion.notCompatibility')
: ' '
}
type={showPasswordRepetitionText ? 'text' : 'password'}
sx={{
flex: '1 1 260px',
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minWidth: '64px',
}}
>
<IconButton
onClick={handleTogglePasswordRepetitionEye}
sx={{ p: 0.5 }}
>
{showPasswordRepetitionText ? (
<Icon
Component={Eye}
color="primary.main"
size="medium"
/>
) : (
<Icon
Component={EyeSlash}
size="medium"
color="primary.main"
/>
)}
</IconButton>
{confirmPassword.length > 0 && (
<Icon
Component={matchPassword ? TickCircle : CloseCircle}
size="medium"
color={matchPassword ? 'success.main' : 'error.main'}
variant="Bold"
/>
)}
</Box>
</InputAdornment>
),
}}
/>
</Box>
{password && showValidations && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Box
sx={{
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
}}
>
<PasswordValidationItem
isValid={hasNumber}
label={t('completion.hasNumber')}
/>
<PasswordValidationItem
isValid={hasMinLength}
label={t('completion.hasMinLength')}
/>
</Box>
<Box
sx={{
flex: '1 1 260px',
display: 'flex',
flexDirection: 'column',
}}
>
<PasswordValidationItem
isValid={hasUpperAndLower}
label={t('completion.hasUpperAndLower')}
/>
<PasswordValidationItem
isValid={hasSpecialChar}
label={t('completion.hasSpecialChar')}
/>
</Box>
</Box>
)}
</Box>
)}
</>
);
}

View File

@@ -0,0 +1,39 @@
import { Box, Typography } from '@mui/material';
import { TickCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
import { type ValidationItemProps } from '../types/settingForm';
export function PasswordValidationItem({
isValid,
label,
}: ValidationItemProps) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 0.5,
flexWrap: 'wrap',
}}
>
<Icon
Component={TickCircle}
size="small"
color={isValid ? 'success.main' : 'primary.main'}
variant={isValid ? 'Bold' : 'Outline'}
/>
<Typography
variant="body2"
color="text.primary"
sx={{
wordBreak: 'break-word',
flex: 1,
}}
>
{label}
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,168 @@
import {
TextField,
FormControl,
InputLabel,
MenuItem,
Select,
Box,
Autocomplete,
type SelectChangeEvent,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Woman, Man } from 'iconsax-react';
import DateOfBirth from './DateOfBirth';
import { countries } from '../data/Countries';
import { CountryFlag } from '@/components/CountryFlag';
import { Icon } from '@rkheftan/harmony-ui';
import { type PersonalInfoFieldsProps } from '../types/settingForm';
import { Gender } from '../types/settingForm';
export function PersonalInfoFields({
firstName,
setFirstName,
lastName,
setLastName,
nationalId,
setNationalId,
birthDate,
setBirthDate,
sex,
setSex,
country,
setCountry,
}: PersonalInfoFieldsProps) {
const { t } = useTranslation('completionForm');
const countryOptions = countries.map((c) => ({
code: c.code,
label: t(c.label, { ns: 'countries' }),
}));
const currentCountry = countryOptions.find((c) => c.code === country) || null;
const handleChangeSex = (e: SelectChangeEvent<Gender>) => {
setSex(e.target.value as Gender);
};
const genderOptions = [
{
value: Gender.Female,
icon: Woman,
label: t('completion.woman'),
color: '#F50057',
},
{
value: Gender.Male,
icon: Man,
label: t('completion.man'),
color: '#0091EA',
},
];
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%',
px: { xs: 2, sm: 4, md: 1 },
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<TextField
label={t('completion.name')}
placeholder={t('completion.name')}
variant="outlined"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
sx={{ flex: '1 1 260px' }}
/>
<TextField
label={t('completion.familyName')}
placeholder={t('completion.familyName')}
variant="outlined"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
sx={{ flex: '1 1 260px' }}
/>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<FormControl sx={{ flex: '1 1 260px' }}>
<InputLabel>{t('completion.gender')}</InputLabel>
<Select
value={sex}
label={t('completion.gender')}
onChange={handleChangeSex}
>
{genderOptions.map((g) => (
<MenuItem key={g.value} value={g.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon Component={g.icon} size="small" color={g.color} />
{g.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label={t('completion.optionalNationalCode')}
placeholder={t('completion.optionalNationalCode')}
value={nationalId}
onChange={(e) => setNationalId(e.target.value)}
variant="outlined"
sx={{ flex: '1 1 260px' }}
/>
</Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Autocomplete
sx={{ flex: '1 1 260px' }}
options={countryOptions}
getOptionLabel={(option) => option.label}
value={currentCountry}
onChange={(_, newValue) => setCountry(newValue?.code || '')}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.code}>
<CountryFlag code={option.code} />
{option.label}
</Box>
)}
renderInput={(params) => (
<TextField {...params} label={t('completion.country')} />
)}
clearOnEscape
/>
<Box sx={{ flex: '1 1 260px' }}>
<DateOfBirth value={birthDate} onChange={setBirthDate} />
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import {
Box,
Button,
Typography,
Link,
Dialog,
DialogTitle,
DialogContent,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { type SubmitProps } from '../types/settingForm';
export function SubmitSection({ onSubmit, loading, error }: SubmitProps) {
const { t, i18n } = useTranslation('completionForm');
const [openDialog, setOpenDialog] = useState(false);
const handleOpenDialog = (e: React.MouseEvent) => {
e.preventDefault();
setOpenDialog(true);
};
const agreementText = t('completion.agreement');
return (
<>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
px: { xs: 2, sm: 0 },
mb: 2,
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Typography
variant="body2"
sx={{
flex: '1 1 260px',
maxWidth: 'calc(100% - 263px)',
minWidth: '260px',
}}
color="text.primary"
>
{t('completion.agreementPart1')}
<Link href="#" onClick={handleOpenDialog}>
{t('completion.agreementLinkText')}
</Link>
{t('completion.agreementPart2')}
</Typography>
<Button
variant="contained"
onClick={onSubmit}
disabled={loading}
sx={{
width: { xs: '100%', sm: '247px' },
alignSelf: { xs: 'stretch', sm: 'center' },
textTransform: 'none',
}}
>
{loading
? t('completion.submitting')
: t('completion.registerButton')}
</Button>
{error && <Typography color="error">{error}</Typography>}
</Box>
<Dialog
open={openDialog}
onClose={() => setOpenDialog(false)}
fullWidth
maxWidth="md"
dir={i18n.language.startsWith('fa') ? 'rtl' : 'ltr'}
>
<DialogTitle>
{t('completion.rules') || t('completion.rules')}
</DialogTitle>
<DialogContent
dividers
sx={{ whiteSpace: 'pre-line', fontSize: '14px' }}
>
{agreementText}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,329 @@
import { useEffect, useState } from 'react';
import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import Logo from '@/components/Logo';
import { PersonalInfoFields } from './PersonalInfoFields';
import { PasswordSection } from './PasswordSection';
import { EmailSection } from './EmailSection';
import { SubmitSection } from './SubmitSection';
import { useToast } from '@rkheftan/harmony-ui';
import { regex } from '../../../utils/regex';
import { toLocaleDigits } from '../../../utils/persianDigit';
import i18n from '@/config/i18n';
import { Gender } from '../types/settingForm';
import { useApi } from '@/hooks/useApi';
import {
sendEmailOtpApi,
confirmEmailOtpApi,
completeUserInformationApi,
} from '../api/userCompletion';
import {
type SendEmailOtpPayload,
type ConfirmEmailOtpPayload,
type CompleteUserInfoPayload,
} from '../types/completionFormApiTypes';
import { type ApiResponse } from '@/types/apiResponse';
export function UserCompletionForm() {
const { t } = useTranslation('completionForm');
const showToast = useToast();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [nationalId, setNationalId] = useState('');
const [birthDate, setBirthDate] = useState<Date | null>(null);
const [sex, setSex] = useState<Gender>(Gender.Female);
const [country, setCountry] = useState('');
const [showPasswordSection, setShowPasswordSection] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showEmail, setShowEmail] = useState(false);
const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [codeSent, setCodeSent] = useState(false);
const [buttonState, setButtonState] = useState<'default' | 'counting'>(
'default',
);
const [countdown, setCountdown] = useState(0);
const [emailVerified, setEmailVerified] = useState(false);
const [showPasswordValidations, setShowPasswordValidations] = useState(false);
const {
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
correctEmail,
} = regex(password, email);
const matchPassword = password === confirmPassword;
const { execute: sendCode, data: sendCodeData } = useApi(
async (payload: SendEmailOtpPayload) => {
const result = await sendEmailOtpApi(payload);
const conformingResult: ApiResponse = {
...result,
errorCode: result.errorCode || 0,
validations: [],
};
return { data: conformingResult };
},
);
const {
execute: verifyCode,
loading: isVerifyingCode,
data: verifyCodeData,
} = useApi(async (payload: ConfirmEmailOtpPayload) => {
const result = await confirmEmailOtpApi(payload);
const conformingResult: ApiResponse = {
...result,
errorCode: result.errorCode || 0,
validations: [],
};
return { data: conformingResult };
});
const {
execute: submitForm,
loading: isSubmitting,
error: submitError,
data: submitData,
} = useApi(async (payload: CompleteUserInfoPayload) => {
const result = await completeUserInformationApi(payload);
const conformingResult: ApiResponse = {
...result,
errorCode: result.errorCode || 0,
validations: [],
};
return { data: conformingResult };
});
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
useEffect(() => {
if (sendCodeData) {
if (sendCodeData.success) {
showToast({
message: sendCodeData.message || t('completion.successfullCodeSent'),
severity: 'success',
});
setCodeSent(true);
setButtonState('counting');
setCountdown(120);
} else {
showToast({
message: sendCodeData.message || t('completion.problem'),
severity: 'error',
});
}
}
}, [sendCodeData, showToast, t]);
useEffect(() => {
if (verifyCodeData) {
if (verifyCodeData.success) {
setEmailVerified(true);
showToast({
message: verifyCodeData.message || t('completion.codeVerified'),
severity: 'success',
});
} else {
showToast({
message: verifyCodeData.message || t('completion.invalidCode'),
severity: 'error',
});
setEmailVerified(false);
}
}
}, [verifyCodeData, showToast, t]);
useEffect(() => {
if (submitData) {
showToast({
message:
submitData.message ||
t(
submitData.success
? 'completion.submitSuccess'
: 'completion.submitError',
),
severity: submitData.success ? 'success' : 'error',
});
} else if (submitError) {
showToast({
message: getErrorMessage(submitError) || t('completion.problem'),
severity: 'error',
});
}
}, [submitData, submitError, showToast, t]);
useEffect(() => {
setShowPasswordValidations(password ? !validPassword : false);
}, [password, validPassword]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (buttonState === 'counting' && countdown > 0) {
timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
setButtonState('default');
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => clearInterval(timer);
}, [buttonState, countdown]);
const handleSendCode = () => {
sendCode({ email });
};
const handleVerifyCode = () => {
if (!verificationCode.trim()) {
showToast({
message: 'Please enter the verification code',
severity: 'warning',
});
return;
}
verifyCode({ email, otpCode: verificationCode });
};
const handleSubmit = () => {
submitForm({
userId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
firstName,
lastName,
gender: sex,
nationalId,
savePassword: showPasswordSection,
password: showPasswordSection ? password : undefined,
saveEmail: showEmail,
email: showEmail ? email : undefined,
birthDate,
country,
});
};
const getButtonLabel = () => {
if (buttonState === 'counting') {
const m = String(Math.floor(countdown / 60)).padStart(2, '0');
const s = String(countdown % 60).padStart(2, '0');
return toLocaleDigits(`${m}:${s}`, i18n.language);
}
return t('completion.vericationCodeButton');
};
const handleEditEmail = () => {
setButtonState('default');
setCodeSent(false);
setEmailVerified(false);
setVerificationCode('');
};
return (
<Box
sx={{
backgroundColor: 'background.default',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
p: { xs: 1, sm: 2, md: 3 },
}}
>
<Box sx={{ mb: 2 }}>
<Logo />
</Box>
<Box
sx={{
width: '100%',
maxWidth: 730,
backgroundColor: 'background.paper',
border: '1px solid #ccc',
display: 'flex',
flexDirection: 'column',
gap: 2,
px: { xs: 2, sm: 4, md: 6 },
py: { xs: 3, sm: 4 },
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="h5" color="text.primary">
{t('completion.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('completion.description')}
</Typography>
</Box>
<PersonalInfoFields
firstName={firstName}
setFirstName={setFirstName}
lastName={lastName}
setLastName={setLastName}
nationalId={nationalId}
setNationalId={setNationalId}
birthDate={birthDate}
setBirthDate={setBirthDate}
sex={sex}
setSex={setSex}
country={country}
setCountry={setCountry}
/>
<PasswordSection
showPasswordSection={showPasswordSection}
setShowPasswordSection={setShowPasswordSection}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
matchPassword={matchPassword}
hasNumber={hasNumber}
hasMinLength={hasMinLength}
hasUpperAndLower={hasUpperAndLower}
hasSpecialChar={hasSpecialChar}
validPassword={validPassword}
showValidations={showPasswordValidations}
/>
<EmailSection
showEmail={showEmail}
setShowEmail={setShowEmail}
email={email}
setEmail={setEmail}
correctEmail={correctEmail}
codeSent={codeSent}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
buttonState={buttonState}
getButtonLabel={getButtonLabel}
handleSendCode={handleSendCode}
handleVerifyCode={handleVerifyCode}
emailVerified={emailVerified}
loading={isVerifyingCode}
handleEditEmail={handleEditEmail}
/>
<SubmitSection
onSubmit={handleSubmit}
loading={isSubmitting}
error={getErrorMessage(submitError)}
success={!!submitData?.success}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,271 @@
export interface Country {
code: string;
label: string;
phone: string;
}
export interface Country {
code: string;
label: string;
phone: string;
}
export const countries: readonly Country[] = [
{ code: 'AF', label: 'country.afghanistan', phone: '+93' },
{ code: 'AX', label: 'country.aland_islands', phone: '+358' },
{ code: 'AL', label: 'country.albania', phone: '+355' },
{ code: 'DZ', label: 'country.algeria', phone: '+213' },
{ code: 'AS', label: 'country.american_samoa', phone: '+1684' },
{ code: 'AD', label: 'country.andorra', phone: '+376' },
{ code: 'AO', label: 'country.angola', phone: '+244' },
{ code: 'AI', label: 'country.anguilla', phone: '+1264' },
{ code: 'AQ', label: 'country.antarctica', phone: '+672' },
{ code: 'AG', label: 'country.antigua_and_barbuda', phone: '+1268' },
{ code: 'AR', label: 'country.argentina', phone: '+54' },
{ code: 'AM', label: 'country.armenia', phone: '+374' },
{ code: 'AW', label: 'country.aruba', phone: '+297' },
{ code: 'AU', label: 'country.australia', phone: '+61' },
{ code: 'AT', label: 'country.austria', phone: '+43' },
{ code: 'AZ', label: 'country.azerbaijan', phone: '+994' },
{ code: 'BS', label: 'country.bahamas', phone: '+1242' },
{ code: 'BH', label: 'country.bahrain', phone: '+973' },
{ code: 'BD', label: 'country.bangladesh', phone: '+880' },
{ code: 'BB', label: 'country.barbados', phone: '+1246' },
{ code: 'BY', label: 'country.belarus', phone: '+375' },
{ code: 'BE', label: 'country.belgium', phone: '+32' },
{ code: 'BZ', label: 'country.belize', phone: '+501' },
{ code: 'BJ', label: 'country.benin', phone: '+229' },
{ code: 'BM', label: 'country.bermuda', phone: '+1441' },
{ code: 'BT', label: 'country.bhutan', phone: '+975' },
{ code: 'BO', label: 'country.bolivia', phone: '+591' },
{ code: 'BA', label: 'country.bosnia_and_herzegovina', phone: '+387' },
{ code: 'BW', label: 'country.botswana', phone: '+267' },
{ code: 'BR', label: 'country.brazil', phone: '+55' },
{
code: 'IO',
label: 'country.british_indian_ocean_territory',
phone: '+246',
},
{ code: 'VG', label: 'country.british_virgin_islands', phone: '+1284' },
{ code: 'BN', label: 'country.brunei', phone: '+673' },
{ code: 'BG', label: 'country.bulgaria', phone: '+359' },
{ code: 'BF', label: 'country.burkina_faso', phone: '+226' },
{ code: 'BI', label: 'country.burundi', phone: '+257' },
{ code: 'KH', label: 'country.cambodia', phone: '+855' },
{ code: 'CM', label: 'country.cameroon', phone: '+237' },
{ code: 'CA', label: 'country.canada', phone: '+1' },
{ code: 'CV', label: 'country.cape_verde', phone: '+238' },
{ code: 'KY', label: 'country.cayman_islands', phone: '+1345' },
{ code: 'CF', label: 'country.central_african_republic', phone: '+236' },
{ code: 'TD', label: 'country.chad', phone: '+235' },
{ code: 'CL', label: 'country.chile', phone: '+56' },
{ code: 'CN', label: 'country.china', phone: '+86' },
{ code: 'CX', label: 'country.christmas_island', phone: '+61' },
{ code: 'CC', label: 'country.cocos_keeling_islands', phone: '+61' },
{ code: 'CO', label: 'country.colombia', phone: '+57' },
{ code: 'KM', label: 'country.comoros', phone: '+269' },
{ code: 'CG', label: 'country.congo_brazzaville', phone: '+242' },
{ code: 'CD', label: 'country.congo_kinshasa', phone: '+243' },
{ code: 'CK', label: 'country.cook_islands', phone: '+682' },
{ code: 'CR', label: 'country.costa_rica', phone: '+506' },
{ code: 'CI', label: 'country.cote_divoire', phone: '+225' },
{ code: 'HR', label: 'country.croatia', phone: '+385' },
{ code: 'CU', label: 'country.cuba', phone: '+53' },
{ code: 'CW', label: 'country.curacao', phone: '+599' },
{ code: 'CY', label: 'country.cyprus', phone: '+357' },
{ code: 'CZ', label: 'country.czech_republic', phone: '+420' },
{ code: 'DK', label: 'country.denmark', phone: '+45' },
{ code: 'DJ', label: 'country.djibouti', phone: '+253' },
{ code: 'DM', label: 'country.dominica', phone: '+1767' },
{ code: 'DO', label: 'country.dominican_republic', phone: '+1' },
{ code: 'EC', label: 'country.ecuador', phone: '+593' },
{ code: 'EG', label: 'country.egypt', phone: '+20' },
{ code: 'SV', label: 'country.el_salvador', phone: '+503' },
{ code: 'GQ', label: 'country.equatorial_guinea', phone: '+240' },
{ code: 'ER', label: 'country.eritrea', phone: '+291' },
{ code: 'EE', label: 'country.estonia', phone: '+372' },
{ code: 'SZ', label: 'country.eswatini', phone: '+268' },
{ code: 'ET', label: 'country.ethiopia', phone: '+251' },
{ code: 'FK', label: 'country.falkland_islands', phone: '+500' },
{ code: 'FO', label: 'country.faroe_islands', phone: '+298' },
{ code: 'FJ', label: 'country.fiji', phone: '+679' },
{ code: 'FI', label: 'country.finland', phone: '+358' },
{ code: 'FR', label: 'country.france', phone: '+33' },
{ code: 'GF', label: 'country.french_guiana', phone: '+594' },
{ code: 'PF', label: 'country.french_polynesia', phone: '+689' },
{ code: 'GA', label: 'country.gabon', phone: '+241' },
{ code: 'GM', label: 'country.gambia', phone: '+220' },
{ code: 'GE', label: 'country.georgia', phone: '+995' },
{ code: 'DE', label: 'country.germany', phone: '+49' },
{ code: 'GH', label: 'country.ghana', phone: '+233' },
{ code: 'GI', label: 'country.gibraltar', phone: '+350' },
{ code: 'GR', label: 'country.greece', phone: '+30' },
{ code: 'GL', label: 'country.greenland', phone: '+299' },
{ code: 'GD', label: 'country.grenada', phone: '+1473' },
{ code: 'GP', label: 'country.guadeloupe', phone: '+590' },
{ code: 'GU', label: 'country.guam', phone: '+1671' },
{ code: 'GT', label: 'country.guatemala', phone: '+502' },
{ code: 'GG', label: 'country.guernsey', phone: '+44' },
{ code: 'GN', label: 'country.guinea', phone: '+224' },
{ code: 'GW', label: 'country.guinea_bissau', phone: '+245' },
{ code: 'GY', label: 'country.guyana', phone: '+592' },
{ code: 'HT', label: 'country.haiti', phone: '+509' },
{ code: 'HN', label: 'country.honduras', phone: '+504' },
{ code: 'HK', label: 'country.hong_kong', phone: '+852' },
{ code: 'HU', label: 'country.hungary', phone: '+36' },
{ code: 'IS', label: 'country.iceland', phone: '+354' },
{ code: 'IN', label: 'country.india', phone: '+91' },
{ code: 'ID', label: 'country.indonesia', phone: '+62' },
{ code: 'IR', label: 'country.iran', phone: '+98' },
{ code: 'IQ', label: 'country.iraq', phone: '+964' },
{ code: 'IE', label: 'country.ireland', phone: '+353' },
{ code: 'IM', label: 'country.isle_of_man', phone: '+44' },
{ code: 'IL', label: 'country.israel', phone: '+972' },
{ code: 'IT', label: 'country.italy', phone: '+39' },
{ code: 'JM', label: 'country.jamaica', phone: '+1876' },
{ code: 'JP', label: 'country.japan', phone: '+81' },
{ code: 'JE', label: 'country.jersey', phone: '+44' },
{ code: 'JO', label: 'country.jordan', phone: '+962' },
{ code: 'KZ', label: 'country.kazakhstan', phone: '+7' },
{ code: 'KE', label: 'country.kenya', phone: '+254' },
{ code: 'KI', label: 'country.kiribati', phone: '+686' },
{ code: 'XK', label: 'country.kosovo', phone: '+383' },
{ code: 'KW', label: 'country.kuwait', phone: '+965' },
{ code: 'KG', label: 'country.kyrgyzstan', phone: '+996' },
{ code: 'LA', label: 'country.laos', phone: '+856' },
{ code: 'LV', label: 'country.latvia', phone: '+371' },
{ code: 'LB', label: 'country.lebanon', phone: '+961' },
{ code: 'LS', label: 'country.lesotho', phone: '+266' },
{ code: 'LR', label: 'country.liberia', phone: '+231' },
{ code: 'LY', label: 'country.libya', phone: '+218' },
{ code: 'LI', label: 'country.liechtenstein', phone: '+423' },
{ code: 'LT', label: 'country.lithuania', phone: '+370' },
{ code: 'LU', label: 'country.luxembourg', phone: '+352' },
{ code: 'MO', label: 'country.macau', phone: '+853' },
{ code: 'MG', label: 'country.madagascar', phone: '+261' },
{ code: 'MW', label: 'country.malawi', phone: '+265' },
{ code: 'MY', label: 'country.malaysia', phone: '+60' },
{ code: 'MV', label: 'country.maldives', phone: '+960' },
{ code: 'ML', label: 'country.mali', phone: '+223' },
{ code: 'MT', label: 'country.malta', phone: '+356' },
{ code: 'MH', label: 'country.marshall_islands', phone: '+692' },
{ code: 'MQ', label: 'country.martinique', phone: '+596' },
{ code: 'MR', label: 'country.mauritania', phone: '+222' },
{ code: 'MU', label: 'country.mauritius', phone: '+230' },
{ code: 'YT', label: 'country.mayotte', phone: '+262' },
{ code: 'MX', label: 'country.mexico', phone: '+52' },
{ code: 'FM', label: 'country.micronesia', phone: '+691' },
{ code: 'MD', label: 'country.moldova', phone: '+373' },
{ code: 'MC', label: 'country.monaco', phone: '+377' },
{ code: 'MN', label: 'country.mongolia', phone: '+976' },
{ code: 'ME', label: 'country.montenegro', phone: '+382' },
{ code: 'MS', label: 'country.montserrat', phone: '+1664' },
{ code: 'MA', label: 'country.morocco', phone: '+212' },
{ code: 'MZ', label: 'country.mozambique', phone: '+258' },
{ code: 'MM', label: 'country.myanmar', phone: '+95' },
{ code: 'NA', label: 'country.namibia', phone: '+264' },
{ code: 'NR', label: 'country.nauru', phone: '+674' },
{ code: 'NP', label: 'country.nepal', phone: '+977' },
{ code: 'NL', label: 'country.netherlands', phone: '+31' },
{ code: 'NC', label: 'country.new_caledonia', phone: '+687' },
{ code: 'NZ', label: 'country.new_zealand', phone: '+64' },
{ code: 'NI', label: 'country.nicaragua', phone: '+505' },
{ code: 'NE', label: 'country.niger', phone: '+227' },
{ code: 'NG', label: 'country.nigeria', phone: '+234' },
{ code: 'NU', label: 'country.niue', phone: '+683' },
{ code: 'NF', label: 'country.norfolk_island', phone: '+672' },
{ code: 'KP', label: 'country.north_korea', phone: '+850' },
{ code: 'MK', label: 'country.north_macedonia', phone: '+389' },
{ code: 'MP', label: 'country.northern_mariana_islands', phone: '+1670' },
{ code: 'NO', label: 'country.norway', phone: '+47' },
{ code: 'OM', label: 'country.oman', phone: '+968' },
{ code: 'PK', label: 'country.pakistan', phone: '+92' },
{ code: 'PW', label: 'country.palau', phone: '+680' },
{ code: 'PS', label: 'country.palestine', phone: '+970' },
{ code: 'PA', label: 'country.panama', phone: '+507' },
{ code: 'PG', label: 'country.papua_new_guinea', phone: '+675' },
{ code: 'PY', label: 'country.paraguay', phone: '+595' },
{ code: 'PE', label: 'country.peru', phone: '+51' },
{ code: 'PH', label: 'country.philippines', phone: '+63' },
{ code: 'PN', label: 'country.pitcairn_islands', phone: '+64' },
{ code: 'PL', label: 'country.poland', phone: '+48' },
{ code: 'PT', label: 'country.portugal', phone: '+351' },
{ code: 'PR', label: 'country.puerto_rico', phone: '+1' },
{ code: 'QA', label: 'country.qatar', phone: '+974' },
{ code: 'RE', label: 'country.reunion', phone: '+262' },
{ code: 'RO', label: 'country.romania', phone: '+40' },
{ code: 'RU', label: 'country.russia', phone: '+7' },
{ code: 'RW', label: 'country.rwanda', phone: '+250' },
{ code: 'BL', label: 'country.saint_barthelemy', phone: '+590' },
{ code: 'SH', label: 'country.saint_helena', phone: '+290' },
{ code: 'KN', label: 'country.saint_kitts_and_nevis', phone: '+1869' },
{ code: 'LC', label: 'country.saint_lucia', phone: '+1758' },
{ code: 'MF', label: 'country.saint_martin', phone: '+590' },
{ code: 'PM', label: 'country.saint_pierre_and_miquelon', phone: '+508' },
{
code: 'VC',
label: 'country.saint_vincent_and_the_grenadines',
phone: '+1784',
},
{ code: 'WS', label: 'country.samoa', phone: '+685' },
{ code: 'SM', label: 'country.san_marino', phone: '+378' },
{ code: 'ST', label: 'country.sao_tome_and_principe', phone: '+239' },
{ code: 'SA', label: 'country.saudi_arabia', phone: '+966' },
{ code: 'SN', label: 'country.senegal', phone: '+221' },
{ code: 'RS', label: 'country.serbia', phone: '+381' },
{ code: 'SC', label: 'country.seychelles', phone: '+248' },
{ code: 'SL', label: 'country.sierra_leone', phone: '+232' },
{ code: 'SG', label: 'country.singapore', phone: '+65' },
{ code: 'SX', label: 'country.sint_maarten', phone: '+1721' },
{ code: 'SK', label: 'country.slovakia', phone: '+421' },
{ code: 'SI', label: 'country.slovenia', phone: '+386' },
{ code: 'SB', label: 'country.solomon_islands', phone: '+677' },
{ code: 'SO', label: 'country.somalia', phone: '+252' },
{ code: 'ZA', label: 'country.south_africa', phone: '+27' },
{
code: 'GS',
label: 'country.south_georgia_and_south_sandwich_islands',
phone: '+500',
},
{ code: 'KR', label: 'country.south_korea', phone: '+82' },
{ code: 'SS', label: 'country.south_sudan', phone: '+211' },
{ code: 'ES', label: 'country.spain', phone: '+34' },
{ code: 'LK', label: 'country.sri_lanka', phone: '+94' },
{ code: 'SD', label: 'country.sudan', phone: '+249' },
{ code: 'SR', label: 'country.suriname', phone: '+597' },
{ code: 'SJ', label: 'country.svalbard_and_jan_mayen', phone: '+47' },
{ code: 'SE', label: 'country.sweden', phone: '+46' },
{ code: 'CH', label: 'country.switzerland', phone: '+41' },
{ code: 'SY', label: 'country.syria', phone: '+963' },
{ code: 'TW', label: 'country.taiwan', phone: '+886' },
{ code: 'TJ', label: 'country.tajikistan', phone: '+992' },
{ code: 'TZ', label: 'country.tanzania', phone: '+255' },
{ code: 'TH', label: 'country.thailand', phone: '+66' },
{ code: 'TL', label: 'country.timor_leste', phone: '+670' },
{ code: 'TG', label: 'country.togo', phone: '+228' },
{ code: 'TK', label: 'country.tokelau', phone: '+690' },
{ code: 'TO', label: 'country.tonga', phone: '+676' },
{ code: 'TT', label: 'country.trinidad_and_tobago', phone: '+1868' },
{ code: 'TN', label: 'country.tunisia', phone: '+216' },
{ code: 'TR', label: 'country.turkey', phone: '+90' },
{ code: 'TM', label: 'country.turkmenistan', phone: '+993' },
{ code: 'TC', label: 'country.turks_and_caicos_islands', phone: '+1649' },
{ code: 'TV', label: 'country.tuvalu', phone: '+688' },
{ code: 'VI', label: 'country.us_virgin_islands', phone: '+1340' },
{ code: 'UG', label: 'country.uganda', phone: '+256' },
{ code: 'UA', label: 'country.ukraine', phone: '+380' },
{ code: 'AE', label: 'country.united_arab_emirates', phone: '+971' },
{ code: 'GB', label: 'country.united_kingdom', phone: '+44' },
{ code: 'US', label: 'country.united_states', phone: '+1' },
{ code: 'UY', label: 'country.uruguay', phone: '+598' },
{ code: 'UZ', label: 'country.uzbekistan', phone: '+998' },
{ code: 'VU', label: 'country.vanuatu', phone: '+678' },
{ code: 'VA', label: 'country.vatican_city', phone: '+39' },
{ code: 'VE', label: 'country.venezuela', phone: '+58' },
{ code: 'VN', label: 'country.vietnam', phone: '+84' },
{ code: 'WF', label: 'country.wallis_and_futuna', phone: '+681' },
{ code: 'EH', label: 'country.western_sahara', phone: '+212' },
{ code: 'YE', label: 'country.yemen', phone: '+967' },
{ code: 'ZM', label: 'country.zambia', phone: '+260' },
{ code: 'ZW', label: 'country.zimbabwe', phone: '+263' },
];

View File

@@ -0,0 +1,32 @@
export interface GenericApiResponse {
success: boolean;
message: string;
errorCode?: number;
}
export interface SendEmailOtpPayload {
email: string;
}
export interface ConfirmEmailOtpPayload {
email: string;
otpCode: string;
}
export interface CompleteUserInfoPayload {
userId: string;
firstName: string;
lastName: string;
gender: 0 | 1 | 2;
nationalId: string;
birthDate: Date | null;
country: string;
savePassword?: boolean;
password?: string;
saveEmail?: boolean;
email?: string;
}
export interface CompleteUserInfoResponse extends GenericApiResponse {
validations: { property: string; message: string }[] | null;
}

View File

@@ -0,0 +1,73 @@
import { type Dispatch, type SetStateAction } from 'react';
export enum Gender {
None = 0,
Female = 1,
Male = 2,
}
export interface DateOfBirthProps {
value: Date | null;
onChange: (date: Date | null) => void;
}
export interface EmailSectionProps {
showEmail: boolean;
setShowEmail: (checked: boolean) => void;
email: string;
setEmail: (email: string) => void;
correctEmail: boolean;
codeSent: boolean;
verificationCode: string;
setVerificationCode: (code: string) => void;
buttonState: 'default' | 'counting' | 'sent';
getButtonLabel: () => string;
handleSendCode: () => void;
handleVerifyCode: () => void;
emailVerified: boolean;
loading: boolean;
handleEditEmail: () => void;
}
export interface PasswordSectionProps {
showPasswordSection: boolean;
setShowPasswordSection: (checked: boolean) => void;
password: string;
setPassword: (password: string) => void;
confirmPassword: string;
setConfirmPassword: (confirmPassword: string) => void;
matchPassword: boolean;
hasNumber: boolean;
hasMinLength: boolean;
hasUpperAndLower: boolean;
hasSpecialChar: boolean;
validPassword: boolean;
showValidations: boolean;
}
export interface ValidationItemProps {
isValid: boolean;
label: string;
}
export interface PersonalInfoFieldsProps {
firstName: string;
setFirstName: (v: string) => void;
lastName: string;
setLastName: (v: string) => void;
sex: Gender;
setSex: Dispatch<SetStateAction<Gender>>;
country: string;
setCountry: (country: string) => void;
nationalId: string;
setNationalId: (v: string) => void;
birthDate: Date | null;
setBirthDate: (d: Date | null) => void;
}
export interface SubmitProps {
onSubmit: () => void;
loading: boolean;
error: string | null;
success: boolean;
}

View File

@@ -0,0 +1,104 @@
import type { ApiResponse } from '@/types/apiResponse';
import type { FetchPromise } from '@/types/fetchPromise';
import type {
CompleteUserInformationRequest,
ConfirmEmailOtpRequest,
ConfirmForgetPassCodeRequest,
ConfirmOtpResponse,
ConfirmSmsOtpRequest,
GetUserStatusByPhoneNumberOrEmailRequest,
GetUserStatusByPhoneNumberOrEmailResponse,
LoginOrSignUpWithGoogleRequest,
LoginOrSignUpWithGoogleResponse,
LoginRequest,
LoginResponse,
PasswordLoginRequest,
ResetPasswordRequest,
ResetPasswordResponse,
SendEmailOtpRequest,
SendForgetPassCodeRequest,
SendSmsOtpRequest,
} from '../types/userTypes';
const API_URL = 'https://accounts.business-harmony.com/api';
export const fetchRequest = <T = ApiResponse>(
url: string,
body: Object | null,
): FetchPromise<T> => {
return fetch(`${API_URL}/${url}`, {
body: JSON.stringify(body),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
};
// GetUserStatusByPhoneNumberOrEmail
export const getUserStatusByPhoneNumberOrEmail = async (
body: GetUserStatusByPhoneNumberOrEmailRequest,
) => {
return fetchRequest<GetUserStatusByPhoneNumberOrEmailResponse>(
'User/GetUserStatusByPhoneNumberOrEmail',
body,
);
};
export const loginOrSignUpWithOtp = async (body: LoginRequest) => {
return fetchRequest<LoginResponse>('User/LoginOrSignUpWithOtp', body);
};
export const loginWithPassword = async (body: PasswordLoginRequest) => {
return fetchRequest<LoginResponse>('User/LoginWithPassword', body);
};
export const sendSmsOtp = async (body: SendSmsOtpRequest) => {
return fetchRequest<ApiResponse>('User/SendSmsOtp', body);
};
export const sendEmailOtp = async (body: SendEmailOtpRequest) => {
return fetchRequest<ApiResponse>('User/SendEmailOtp', body);
};
export const confirmSmsOtp = async (body: ConfirmSmsOtpRequest) => {
return fetchRequest<ConfirmOtpResponse>('User/ConfirmSmsOtp', body);
};
export const confirmEmailOtp = async (body: ConfirmEmailOtpRequest) => {
return fetchRequest<ConfirmOtpResponse>('User/ConfirmEmailOtp', body);
};
export const resetPassword = async (body: ResetPasswordRequest) => {
return fetchRequest<ResetPasswordResponse>('User/ResetPassword', body);
};
export const sendForgetPassCode = async (body: SendForgetPassCodeRequest) => {
return fetchRequest<ApiResponse>('User/SendForgetPassCode', body);
};
export const confirmForgetPassCode = async (
body: ConfirmForgetPassCodeRequest,
) => {
return fetchRequest<ConfirmOtpResponse>('User/ConfirmForgetPassCode', body);
};
export const loginOrSignUpWithGoogle = async (
body: LoginOrSignUpWithGoogleRequest,
) => {
return fetchRequest<LoginOrSignUpWithGoogleResponse>(
'User/LoginOrSignUpWithGoogle',
body,
);
};
export const completeUserInformation = async (
body: CompleteUserInformationRequest,
) => {
return fetchRequest<ApiResponse>('User/CompleteUserInformation', body);
};
export const logOut = async () => {
return fetchRequest<ApiResponse>('User/LogOut', {});
};

View File

@@ -0,0 +1,23 @@
import { Paper } from '@mui/material';
import { type PropsWithChildren } from 'react';
// Beacuse in the otp verify there is a element outside of the authentication card
export const AuthenticationCard = ({ children }: PropsWithChildren) => {
return (
<Paper
elevation={0}
sx={{
borderRadius: 4,
p: {
xs: 4,
md: 6,
},
marginInline: 2,
width: (t) => `calc(100% - ${t.spacing(2)})`,
maxWidth: '552px',
}}
>
{children}
</Paper>
);
};

View File

@@ -0,0 +1,139 @@
import { useState, type JSX } from 'react';
import { LoginRegisterForm } from './LoginRegiserForm';
import type { AuthMode, AuthStep, AuthType } from '../../types/authTypes';
import { OtpVerifyForm } from './OtpVerifyForm';
import { isNumeric } from '@/utils/regexes/isNumeric';
import { CompleteSignUp } from './CompleteSignUp';
import { EnterPasswordForm } from './EnterPasswordForm';
import { UserStatus } from '../../types/userTypes';
import type { CountryCode, GUID } from '@/types/commonTypes';
import { VerifyPhoneNumber } from './VerifyPhoneNumber';
import { useNavigate, useSearchParams } from 'react-router-dom';
export const AuthenticationSteps = (): JSX.Element => {
const navigate = useNavigate();
const DEFAULT_RETURN_URL = '/profile';
const [searchParams] = useSearchParams();
const authReturnUrl: string =
searchParams.get('returnUrl') ?? DEFAULT_RETURN_URL;
const [authMode, setAuthMode] = useState<AuthMode>('register');
const [authType, setAuthType] = useState<AuthType>('phone');
const [currentStep, setCurrentStep] = useState<AuthStep>('emailOrPhone');
const [loginRegisterValue, setLoginRegisterValue] = useState<string>('');
const [countryCode, setCountryCode] = useState<CountryCode>('+98');
const [addPhoneCountryCode, setAddPhoneCountryCode] =
useState<CountryCode>('+98');
const [addedPhoneNumberValue, setAddedPhoneNumberValue] =
useState<string>('');
const handleLoginRegister = (value: string, userStatus: UserStatus) => {
setAuthType(isNumeric(value) ? 'phone' : 'email');
switch (userStatus) {
case UserStatus.NotRegistered:
setAuthMode('register');
setCurrentStep('verify');
break;
case UserStatus.RegisteredWithoutPassword:
setAuthMode('login');
setCurrentStep('verify');
break;
case UserStatus.RegisteredWithPassword:
setAuthMode('login');
setCurrentStep('enterPassword');
break;
}
};
const handleUserLoggedIn = (userId: GUID) => {
localStorage.setItem('userID', userId);
redirectToReturnUrl();
};
const handleConfrimPhoneNumber = (userId: GUID) => {
localStorage.setItem('userID', userId);
setCurrentStep('addPhoneNumber');
};
const handlePhoneNumberVerified = () => {
redirectToReturnUrl();
};
const redirectToReturnUrl = () => {
if (authReturnUrl === DEFAULT_RETURN_URL) {
navigate(DEFAULT_RETURN_URL);
} else {
location.href = authReturnUrl;
}
};
return (
<>
{currentStep === 'emailOrPhone' && (
<LoginRegisterForm
authReturnUrl={authReturnUrl}
onGoogleAuthenticated={handleUserLoggedIn}
countryCode={countryCode}
setCountryCode={setCountryCode}
loginRegisterValue={loginRegisterValue}
setLoginRegisterValue={setLoginRegisterValue}
authType={authType}
setAuthType={setAuthType}
onLoginRegisterSubmit={handleLoginRegister}
/>
)}
{currentStep === 'verify' && (
<OtpVerifyForm
onVerifyPhoneNumber={handleConfrimPhoneNumber}
authReturnUrl={authReturnUrl}
countryCode={countryCode}
onOTPVerified={handleUserLoggedIn}
onEditValue={() => setCurrentStep('emailOrPhone')}
authMode={authMode}
authType={authType}
value={loginRegisterValue}
/>
)}
{currentStep === 'enterPassword' && (
<EnterPasswordForm
authReturnUrl={authReturnUrl}
loginRegisterValue={loginRegisterValue}
countryCode={countryCode}
authType={authType}
onLoggedIn={handleUserLoggedIn}
onEditValue={() => setCurrentStep('emailOrPhone')}
onLoginWithOTP={() => setCurrentStep('verify')}
emailOrPhone={loginRegisterValue}
/>
)}
{currentStep === 'addPhoneNumber' && (
<CompleteSignUp
countryCode={addPhoneCountryCode}
setCountryCode={setAddPhoneCountryCode}
value={addedPhoneNumberValue}
setValue={setAddedPhoneNumberValue}
email={loginRegisterValue}
onCompleteSignUp={() => setCurrentStep('addedPhoneNumberVerify')}
/>
)}
{currentStep === 'addedPhoneNumberVerify' && (
<VerifyPhoneNumber
countryCode={countryCode}
onEditValue={() => setCurrentStep('addPhoneNumber')}
value={addedPhoneNumberValue}
onPhoneNumberVerified={handlePhoneNumberVerified}
/>
)}
</>
);
};

View File

@@ -0,0 +1,118 @@
import { Button, TextField, Typography } from '@mui/material';
import parsePhoneNumberFromString from 'libphonenumber-js';
import { useRef, useState, type Dispatch } from 'react';
import { useTranslation } from 'react-i18next';
import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../CountryCodeSelector';
import { sendSmsOtp } from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
export interface CompleteSignUpProps {
email: string;
value: string;
setValue: Dispatch<string>;
countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>;
onCompleteSignUp: (countryCode: string, value: string) => void;
}
export const CompleteSignUp = ({
email,
value,
setValue,
countryCode,
setCountryCode,
onCompleteSignUp,
}: CompleteSignUpProps) => {
const { t } = useTranslation('authentication');
const [error, setError] = useState<string>();
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [touched, setTouched] = useState<boolean>(false);
const inputError: boolean = touched && !!error;
const [sendOtpLoading, setSendOtpLoading] = useState<boolean>(false);
const isPhoneValid = (code: string, phone: string) => {
const phoneNumber = parsePhoneNumberFromString(code + phone);
return phoneNumber && phoneNumber.isValid();
};
const handleBlur = () => {
setTouched(true);
handleValueError();
};
const handleValueError = () => {
if (!value) {
setError(t('loginForm.thisFieldIsRequired'));
}
if (!isPhoneValid(countryCode, value)) {
setError(t('loginForm.phoneNumberIsInvalid'));
} else {
setError(undefined);
}
};
const handleCompleteSignUp = async () => {
handleValueError();
if (!value || !isPhoneValid(countryCode, value)) {
inputRef.current?.focus();
} else {
setSendOtpLoading(true);
await sendSmsOtp({ phoneNumber: countryCode + value });
onCompleteSignUp(countryCode, value);
setSendOtpLoading(false);
}
};
return (
<AuthenticationCard>
<Typography variant="h5" sx={{ mb: 0.5 }}>
{t('completeSignUp.completeSignUp')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
{t(
'completeSignUp.emailHasBeenSuccessfullyVerifiedPleaseEnterYourContactNumberToContinue',
{ email },
)}
</Typography>
<TextField
ref={textFieldRef}
inputRef={inputRef}
label={t('completeSignUp.phoneNumber')}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
autoFocus
slotProps={{
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
input: {
endAdornment: (
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
show={true}
menuAnchor={textFieldRef.current}
onCloseFocusRef={inputRef}
/>
),
},
}}
sx={{ my: 4 }}
/>
<Button loading={sendOtpLoading} onClick={handleCompleteSignUp}>
{t('verify.confirmAndContinue')}
</Button>
</AuthenticationCard>
);
};

View File

@@ -0,0 +1,190 @@
import { useRef, useState } from 'react';
import { AuthenticationCard } from '../AuthenticationCard';
import { ArrowLeft, Edit2, Eye, EyeSlash } from 'iconsax-react';
import {
Box,
Button,
IconButton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Toast } from '@/components/Toast';
import { Link } from 'react-router-dom';
import type { AuthType } from '../../types/authTypes';
import type { CountryCode, GUID } from '@/types/commonTypes';
import {
loginWithPassword,
sendEmailOtp,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { PasswordLoginRequest } from '../../types/userTypes';
export interface EnterPasswordFormProps {
onEditValue: () => void;
onLoginWithOTP: () => void;
onLoggedIn: (userId: GUID) => void;
emailOrPhone: string;
authType: AuthType;
loginRegisterValue: string;
countryCode: CountryCode;
authReturnUrl: string;
}
export const EnterPasswordForm = ({
onEditValue,
onLoginWithOTP,
onLoggedIn,
emailOrPhone,
authType,
loginRegisterValue,
countryCode,
authReturnUrl,
}: EnterPasswordFormProps) => {
const { t } = useTranslation('authentication');
const [passValue, setPassValue] = useState<string>('');
const [inputTouched, setInputTouched] = useState<boolean>(false);
const [showPassword, setShowPassword] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const [loginLoading, setLoginLoading] = useState<boolean>(false);
const [isLoginStatusSuccess, setIsLoginStatusSuccess] = useState<boolean>();
const [loginAlertOpen, setLoginAlertOpen] = useState<boolean>(false);
const [loginFailedMessage, setLoginFailedMessage] = useState<string>('');
const [sendOtpLoading, setSendOtpLoading] = useState<boolean>(false);
const handleBlur = () => {
setInputTouched(true);
};
const handleSubmit = async () => {
if (!passValue) {
inputRef.current?.focus();
} else {
setLoginLoading(true);
const apiRequest: PasswordLoginRequest = {
phoneNumber:
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
email: authType === 'email' ? loginRegisterValue : undefined,
password: passValue,
returnUrl: authReturnUrl,
};
const result = await loginWithPassword(apiRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setIsLoginStatusSuccess(true);
onLoggedIn(jsonRes.userId);
} else {
setIsLoginStatusSuccess(false);
setLoginFailedMessage(jsonRes.message);
}
setLoginAlertOpen(true);
setLoginLoading(false);
}
};
const handleLoginWithOtp = async () => {
setSendOtpLoading(true);
if (authType === 'phone') {
await sendSmsOtp({ phoneNumber: countryCode + loginRegisterValue });
} else {
await sendEmailOtp({ email: loginRegisterValue });
}
setSendOtpLoading(false);
onLoginWithOTP();
};
return (
<AuthenticationCard>
<Toast
open={loginAlertOpen}
onClose={() => setLoginAlertOpen(false)}
color={!isLoginStatusSuccess ? 'error' : 'success'}
>
{!isLoginStatusSuccess
? loginFailedMessage
: t('verify.youHaveSuccessfullyLoggedIn')}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">
{t('enterPassword.loginWithPassword')}
</Typography>
<Button
variant="outlined"
size="large"
sx={{ width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditValue}
>
{emailOrPhone}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
{t('enterPassword.enterThePasswordYouSetForYourAccount')}
</Typography>
<TextField
label={t('enterPassword.loginPassword')}
type={showPassword ? 'text' : 'password'}
value={passValue}
inputRef={inputRef}
onChange={(e) => setPassValue(e.target.value)}
onBlur={handleBlur}
error={!passValue && inputTouched}
helperText={
!passValue && inputTouched ? t('loginForm.thisFieldIsRequired') : ''
}
autoFocus
slotProps={{
htmlInput: { sx: { lineHeight: 1.5 } },
input: {
endAdornment: (
<IconButton
color="primary"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <Eye /> : <EyeSlash />}
</IconButton>
),
},
}}
sx={{ my: 4 }}
/>
<Button
onClick={handleLoginWithOtp}
sx={{ width: 'auto', mb: 2 }}
variant="text"
loading={sendOtpLoading}
endIcon={<ArrowLeft />}
>
{t('enterPassword.loginWithOneTimeCode')}
</Button>
<Stack spacing={1}>
<Button loading={loginLoading} onClick={handleSubmit}>
{t('verify.confirmAndLogin')}
</Button>
<Link to="/forget-password">
<Button variant="text">{t('enterPassword.iForgotMyPassword')}</Button>
</Link>
</Stack>
</AuthenticationCard>
);
};

View File

@@ -0,0 +1,81 @@
import { Button } from '@mui/material';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { GoogleCodeClientResponse } from '../../types/userTypes';
import { loginOrSignUpWithGoogle } from '../../api/authorizationAPI';
import type { GUID } from '@/types/commonTypes';
import { Google } from 'iconsax-react';
export interface GoogleAuthenticationProps {
disabled: boolean;
authReturnUrl: string;
onGoogleAuthenticated: (userId: GUID) => void;
}
export const GoogleAuthentication = ({
disabled,
authReturnUrl,
onGoogleAuthenticated,
}: GoogleAuthenticationProps) => {
const { t } = useTranslation('authentication');
const [loginWithGoogleLoading, setLoginWithGoogleLoading] =
useState<boolean>(false);
const clientRef = useRef<any>(null);
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.async = true;
script.defer = true;
document.body.appendChild(script);
script.onload = () => {
clientRef.current = google.accounts.oauth2.initCodeClient({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
scope: 'openid email profile',
ux_mode: 'popup',
response_type: 'id_token',
callback: async (resp: GoogleCodeClientResponse) => {
setLoginWithGoogleLoading(true);
const result = await loginOrSignUpWithGoogle({
idToken: resp.id_token,
returnUrl: authReturnUrl,
});
const jsonRes = await result.json();
if (jsonRes.success) {
onGoogleAuthenticated(jsonRes.userId);
} else {
// Todo: Add useToast to handle error
}
setLoginWithGoogleLoading(false);
},
});
};
return () => {
document.body.removeChild(script);
};
}, []);
const handleGoogleLogin = () => {
if (clientRef.current) {
clientRef.current.requestCode();
}
};
return (
<Button
onClick={handleGoogleLogin}
disabled={disabled}
loading={loginWithGoogleLoading}
variant="outlined"
startIcon={<Google variant="Bold" />}
>
{t('loginForm.loginWithGoogle')}
</Button>
);
};

View File

@@ -0,0 +1,170 @@
import { Button, Stack, TextField, Typography } from '@mui/material';
import { useRef, useState, type Dispatch } from 'react';
import { useTranslation } from 'react-i18next';
import { isNumeric } from '@/utils/regexes/isNumeric';
import type { AuthType } from '../../types/authTypes';
import { isEmail } from '@/utils/regexes/isEmail';
import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../CountryCodeSelector';
import type { UserStatus } from '../../types/userTypes';
import { getUserStatusByPhoneNumberOrEmail } from '../../api/authorizationAPI';
import { Toast } from '@/components/Toast';
import type { CountryCode, GUID } from '@/types/commonTypes';
import { GoogleAuthentication } from './GoogleAuthentication';
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
export interface LoginRegisterFormProps {
loginRegisterValue: string;
setLoginRegisterValue: Dispatch<string>;
countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>;
authType: AuthType;
setAuthType: Dispatch<AuthType>;
onLoginRegisterSubmit: (value: string, userStatus: UserStatus) => void;
authReturnUrl: string;
onGoogleAuthenticated: (userId: GUID) => void;
}
export function LoginRegisterForm({
loginRegisterValue,
setLoginRegisterValue,
countryCode,
setCountryCode,
authType,
setAuthType,
onLoginRegisterSubmit,
authReturnUrl,
onGoogleAuthenticated,
}: LoginRegisterFormProps) {
const [checkStatusLoading, setCheckStatusLoading] = useState<boolean>(false);
const { t } = useTranslation('authentication');
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>();
const inputError: boolean = touched && !!error;
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setLoginRegisterValue(newValue);
// If the new value contains only digits (or is empty), it's a phone number
if (isNumeric(newValue)) {
setAuthType('phone');
} else {
setAuthType('email');
}
};
const handleBlur = () => {
setTouched(true);
validateInput(loginRegisterValue, authType);
};
const validateInput = (
value: string,
authType: AuthType,
setErrors: boolean = true,
): boolean => {
if (!value) {
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
return false;
}
if (authType === 'email' && !isEmail(value)) {
if (setErrors) setError(t('loginForm.emailIsInvalid'));
return false;
}
if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
return false;
}
if (setErrors) setError(undefined);
return true;
};
const handleSubmit = async () => {
if (validateInput(loginRegisterValue, authType, false)) {
setCheckStatusLoading(true);
const result = await getUserStatusByPhoneNumberOrEmail({
phoneNumber:
authType === 'phone' ? countryCode + loginRegisterValue : undefined,
email: authType === 'email' ? loginRegisterValue : undefined,
});
const jsonResult = await result.json();
if (jsonResult.success) {
onLoginRegisterSubmit(loginRegisterValue, jsonResult.userStatus);
} else {
setErrorMessage(jsonResult.message);
}
setCheckStatusLoading(false);
} else {
inputRef.current?.focus();
validateInput(loginRegisterValue, authType);
}
};
const showAdornment = authType === 'phone' && loginRegisterValue.length > 0;
return (
<AuthenticationCard>
<Toast
color="error"
onClose={() => setErrorMessage(undefined)}
open={!!errorMessage}
>
{errorMessage}
</Toast>
<Stack spacing={1}>
<Typography variant="h5">{t('loginForm.title')}</Typography>
<Typography variant="body2" color="text.secondary">
{t('loginForm.description')}
</Typography>
</Stack>
<TextField
ref={textFieldRef}
inputRef={inputRef}
label={t('loginForm.emailOrPhoneLabel')}
value={loginRegisterValue}
onChange={handleInputChange}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
autoFocus
slotProps={{
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
input: {
endAdornment: (
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
show={showAdornment}
menuAnchor={textFieldRef.current}
onCloseFocusRef={inputRef}
/>
),
},
}}
sx={{ my: 4 }}
/>
<Stack spacing={2}>
<Button loading={checkStatusLoading} onClick={handleSubmit}>
{t('loginForm.submitButton')}
</Button>
<GoogleAuthentication
authReturnUrl={authReturnUrl}
onGoogleAuthenticated={onGoogleAuthenticated}
disabled={checkStatusLoading}
/>
</Stack>
</AuthenticationCard>
);
}

View File

@@ -0,0 +1,225 @@
import { useTranslation } from 'react-i18next';
import { Box, Button, Stack, Typography } from '@mui/material';
import { Edit2 } from 'iconsax-react';
import DigitInput from '@/components/components/DigitsInput';
import type { AuthMode, AuthType } from '../../types/authTypes';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { LoginRequest } from '../../types/userTypes';
import {
loginOrSignUpWithOtp,
sendEmailOtp,
sendSmsOtp,
} from '../../api/authorizationAPI';
import type { CountryCode, GUID } from '@/types/commonTypes';
interface OtpVerifyFormProps {
value: string;
countryCode: CountryCode;
authType: AuthType;
authMode: AuthMode;
onEditValue: () => void;
onOTPVerified: (userId: GUID) => void;
onVerifyPhoneNumber: (userId: GUID) => void;
authReturnUrl: string;
}
export function OtpVerifyForm({
value,
countryCode,
authType,
authMode,
onEditValue,
onOTPVerified,
onVerifyPhoneNumber,
authReturnUrl,
}: OtpVerifyFormProps) {
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>();
const [errorMessage, setErrorMessage] = useState<string>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertOpen, setVerifyAlertOpen] = useState<boolean>(false);
const { t } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120);
const [canResend, setCanResend] = useState(false);
const [resendLoading, setResendLoading] = useState<boolean>(false);
useEffect(() => {
let interval: NodeJS.Timeout;
if (resendTimer > 0) {
interval = setInterval(() => {
setResendTimer((prev) => prev - 1);
}, 1000);
} else {
setCanResend(true);
}
return () => clearInterval(interval);
}, [resendTimer]);
const handleResendOTPCode = async () => {
setResendLoading(true);
if (authType === 'phone') {
await sendSmsOtp({ phoneNumber: countryCode + value });
} else {
await sendEmailOtp({ email: value });
}
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
};
const formatTime = (seconds: number) => {
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
};
const handleVerifyOTP = () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
handleLoginOrSignUp();
}
};
const handleLoginOrSignUp = async () => {
setOtpDigitInvalid(false);
setVerifyStatusLoading(true);
const loginRequest: LoginRequest = {
otpCode: otpCode,
phoneNumber: authType === 'phone' ? countryCode + value : undefined,
email: authType === 'email' ? value : undefined,
returnUrl: authReturnUrl,
};
const result = await loginOrSignUpWithOtp(loginRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setVerifyStatus('success');
if (jsonRes.registeredWithOutPhoneNumber) {
onVerifyPhoneNumber(jsonRes.userId);
} else {
onOTPVerified(jsonRes.userId);
}
} else {
setVerifyStatus('failed');
setErrorMessage(jsonRes.message);
}
setVerifyAlertOpen(true);
setVerifyStatusLoading(false);
};
const otpMessage = (): string => {
if (authType === 'phone' && authMode === 'login') {
return t(
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
);
} else if (authType === 'phone' && authMode === 'register') {
return t(
'verify.thereIsNoAccountWithThisNumberA4DigitVerificationCodeHasBeenSentToThisNumberToCreateANewAccount',
);
} else if (authType === 'email' && authMode === 'login') {
return t(
'verify.a4digitVerificationCodeHasBeenSentToYourEmailAddressPleaseEnterIt',
);
} else if (authType === 'email' && authMode === 'register') {
return t(
'verify.thereIsNoAccountWithThisEmailAddressA4DigitVerificationCodeHasBeenSentToThisEmailAddressToCreateANewAccount',
);
}
return '';
};
const toastMessage =
verifyStatus === 'failed'
? (errorMessage ?? t('verify.theVerificationCodeIsIncorrect'))
: verifyStatus === 'success' && authMode === 'register'
? t('verify.youHaveSuccessfullySignedIn')
: verifyStatus === 'success' && authMode === 'login'
? t('verify.youHaveSuccessfullyLoggedIn')
: '';
return (
<Stack alignItems="center">
<AuthenticationCard>
<Toast
open={verifyAlertOpen}
onClose={() => setVerifyAlertOpen(false)}
color={verifyStatus === 'failed' ? 'error' : 'success'}
>
{toastMessage}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">{t('verify.verify')}</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditValue}
>
{authType === 'phone' ? countryCode + value : value}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{otpMessage()}
</Typography>
<DigitInput
error={otpDigitInvalid || verifyStatus === 'failed'}
success={verifyStatus === 'success'}
onChange={(value) => setOtpCode(value)}
/>
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
{authMode === 'register'
? t('verify.confirmAndContinue')
: t('verify.confirmAndLogin')}
</Button>
</AuthenticationCard>
<Stack
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
mt: 1.5,
}}
>
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
<Button
variant="text"
loading={resendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,174 @@
import { useTranslation } from 'react-i18next';
import { Box, Button, Stack, Typography } from '@mui/material';
import { Edit2 } from 'iconsax-react';
import DigitInput from '@/components/components/DigitsInput';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { ConfirmSmsOtpRequest } from '../../types/userTypes';
import { confirmSmsOtp, sendSmsOtp } from '../../api/authorizationAPI';
import type { CountryCode } from '@/types/commonTypes';
interface VerifyPhoneNumberProps {
value: string;
countryCode: CountryCode;
onEditValue: () => void;
onPhoneNumberVerified: () => void;
}
export function VerifyPhoneNumber({
value,
countryCode,
onEditValue,
onPhoneNumberVerified,
}: VerifyPhoneNumberProps) {
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'success' | 'failed'>();
const [errorMessage, setErrorMessage] = useState<string>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertOpen, setVerifyAlertOpen] = useState<boolean>(false);
const { t } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120);
const [canResend, setCanResend] = useState(false);
const [resendLoading, setResendLoading] = useState<boolean>(false);
useEffect(() => {
let interval: NodeJS.Timeout;
if (resendTimer > 0) {
interval = setInterval(() => {
setResendTimer((prev) => prev - 1);
}, 1000);
} else {
setCanResend(true);
}
return () => clearInterval(interval);
}, [resendTimer]);
const handleResendOTPCode = async () => {
setResendLoading(true);
await sendSmsOtp({ phoneNumber: countryCode + value });
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
};
const formatTime = (seconds: number) => {
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
};
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
setOtpDigitInvalid(false);
setVerifyStatusLoading(true);
const confirmSmsOtpRequest: ConfirmSmsOtpRequest = {
otpCode: otpCode,
phoneNumber: countryCode + value,
};
const result = await confirmSmsOtp(confirmSmsOtpRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setVerifyStatus('success');
onPhoneNumberVerified();
} else {
setVerifyStatus('failed');
setErrorMessage(jsonRes.message);
}
setVerifyAlertOpen(true);
setVerifyStatusLoading(false);
}
};
const verifyAlertMessage = (): string => {
if (verifyStatus === 'failed') {
return errorMessage ?? t('verify.theVerificationCodeIsIncorrect');
} else {
return t('verify.youHaveSuccessfullyLoggedIn');
}
};
return (
<Stack alignItems="center">
<AuthenticationCard>
<Toast
open={verifyAlertOpen}
onClose={() => setVerifyAlertOpen(false)}
color={verifyStatus === 'failed' ? 'error' : 'success'}
>
{verifyAlertMessage()}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">{t('verify.verify')}</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditValue}
>
{countryCode + value}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{t(
'verify.a4DigitVerificationCodeHasBeenSentToYourBobileNumberPleaseEnterIt',
)}
</Typography>
<DigitInput
error={otpDigitInvalid || verifyStatus === 'failed'}
success={verifyStatus === 'success'}
onChange={(value) => setOtpCode(value)}
/>
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
{t('verify.confirmAndLogin')}
</Button>
</AuthenticationCard>
<Stack
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
mt: 1.5,
}}
>
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
<Button
variant="text"
loading={resendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,254 @@
import {
Box,
InputAdornment,
ListItem,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
TextField,
Typography,
} from '@mui/material';
import { useMemo, useRef, useState, type RefObject } from 'react';
import { ArrowDown2 } from 'iconsax-react';
import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next';
import { countries, type Country } from '../../../countries';
import type { CountryCode } from '@/types/commonTypes';
interface CountryCodeSelectorProps {
show: boolean;
value: CountryCode;
onChange: (newValue: CountryCode) => void;
menuAnchor: HTMLElement | null;
onCloseFocusRef: RefObject<HTMLInputElement | null>;
}
/**
* An animated country code adornment that fades and slides into view.
* Its visibility is controlled by the `show` prop.
*/
export function CountryCodeSelector({
show,
value,
onChange,
menuAnchor,
onCloseFocusRef,
}: CountryCodeSelectorProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const open = Boolean(anchorEl);
const searchInputRef = useRef<HTMLInputElement>(null);
const menuWidth = menuAnchor ? menuAnchor.clientWidth : 'auto';
const { t, i18n } = useTranslation();
const selectedCountry =
countries.find((c) => c.phone === value) || countries[0];
const handleClick = () => {
setAnchorEl(menuAnchor);
};
const handleClose = () => {
setTimeout(() => {
setAnchorEl(null);
}, 0);
setTimeout(() => {
onCloseFocusRef.current?.focus();
}, 100);
setSearchTerm(''); // Reset search on close
};
const handleSelect = (country: Country) => {
onChange(country.phone);
handleClose();
};
const handleMenuEntered = () => {
// Focus the input field after the menu has finished opening
searchInputRef.current?.focus();
};
const filteredCountries = useMemo(
() =>
countries.filter(
(country) =>
t(country.label).toLowerCase().includes(searchTerm.toLowerCase()) ||
country.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
country.phone.includes(searchTerm),
),
[searchTerm],
);
return (
<InputAdornment
position={i18n.dir() === 'rtl' ? 'start' : 'end'}
sx={{
mx: 0,
}}
>
<Box
onClick={handleClick}
sx={{
// Animate width and opacity based on the 'show' prop
width: show ? 'auto' : 0,
opacity: show ? 1 : 0,
transition: (theme) =>
theme.transitions.create(['width', 'opacity'], {
duration: theme.transitions.duration.standard,
}),
// Prevent content from wrapping or spilling out during animation
overflow: 'hidden',
whiteSpace: 'nowrap',
// layout styles
height: '100%',
display: 'flex',
alignItems: 'center',
gap: 0.25,
pl: show ? 0.25 : 0,
'&:hover': {
cursor: 'pointer',
},
}}
>
{/* This inner Box prevents the content from being squeezed during the transition */}
<ArrowDown2 size="24" variant="Bold" />
<Typography
variant="body1"
color="text.primary"
component="div"
sx={{ direction: 'rtl' }} // TODO: need to fixed for both en and fa
>
{value}
</Typography>
<ReactCountryFlag
countryCode={selectedCountry.code}
svg
style={{
height: '1.5rem',
width: '1.5rem',
// TODO: Check alignment for better styling definition
marginTop: '-2px',
marginRight: '4px',
}}
/>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
slotProps={{
list: {
sx: {
// Set the width to match the anchor element's width
width: menuWidth,
height: '18.75rem',
},
},
transition: {
onEntered: handleMenuEntered,
},
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
p: 1,
backgroundColor: 'background.paper',
}}
>
<TextField
inputRef={searchInputRef}
size="small"
fullWidth
label={t('labels.search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</Box>
{/* Can improve preformance with using virtual scrolling */}
<Box sx={{ width: '100%', maxHeight: '14.75rem', overflow: 'auto' }}>
{filteredCountries.length === 0 ? (
<MenuItem disabled>
<ListItem>
<ListItemText>{t('messages.noResualtFound')}</ListItemText>
</ListItem>
</MenuItem>
) : (
filteredCountries.map((country) => (
<MenuItem
key={country.code}
selected={country.phone === value}
onClick={() => handleSelect(country)}
>
<ListItemIcon>
<ReactCountryFlag
countryCode={country.code}
svg
style={{
height: '1.125rem',
width: '1.125rem',
}}
/>
</ListItemIcon>
<ListItemText primary={t(country.label)} />
<Typography color="text.secondary">
{country.phone}
</Typography>
</MenuItem>
))
)}
</Box>
{/* virtual scrolling */}
{/* <Virtuoso
style={{ height: '14.75rem' }} // Adjust height to account for the search bar
data={filteredCountries}
components={{
EmptyPlaceholder: () => (
<MenuItem disabled>
<ListItemText>{t('messages.noResultFound')}</ListItemText>
</MenuItem>
),
}}
initialTopMostItemIndex={countries.indexOf(selectedCountry)}
itemContent={(_, country) => (
<MenuItem
key={country.code}
selected={country.phone === value}
onClick={() => handleSelect(country)}
>
<ListItemIcon>
<ReactCountryFlag
countryCode={country.code}
svg
style={{ fontSize: '1.25em', lineHeight: '1.25em' }}
/>
</ListItemIcon>
<ListItemText primary={country.label} />
<Typography variant="body2" color="text.secondary">
{country.phone}
</Typography>
</MenuItem>
)}
/> */}
</Menu>
</Box>
</InputAdornment>
);
}

View File

@@ -0,0 +1,254 @@
import { useRef, useState } from 'react';
import { AuthenticationCard } from '../AuthenticationCard';
import { Edit2, Eye, EyeSlash, TickCircle } from 'iconsax-react';
import {
Box,
Button,
IconButton,
Stack,
TextField,
Typography,
useTheme,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Toast } from '@/components/Toast';
import { containsNumber } from '@/utils/regexes/containsNumber';
import { containsSymbol } from '@/utils/regexes/containsSymbol';
import { least8Chars } from '@/utils/regexes/least8Chars';
import { hasUpperAndLowerLetter } from '@/utils/regexes/hasUpperAndLowerLetter';
import type { ResetPasswordRequest } from '../../types/userTypes';
import type { AuthType } from '../../types/authTypes';
import type { CountryCode } from '@/types/commonTypes';
import { resetPassword } from '../../api/authorizationAPI';
export interface ChangePasswordProps {
onEditInfo: () => void;
onPasswordChanged: () => void;
forgettedPasswordInfo: string;
infoType: AuthType;
countryCode: CountryCode;
}
export const ChangePassword = ({
onEditInfo,
onPasswordChanged,
forgettedPasswordInfo,
infoType,
countryCode,
}: ChangePasswordProps) => {
const theme = useTheme();
const { t } = useTranslation('authentication');
const [passValue, setPassValue] = useState<string>('');
const [confirmPassValue, setConfirmPassValue] = useState<string>('');
const [inputTouched, setInputTouched] = useState<boolean>(false);
const [confirmInputTouched, setConfirmInputTouched] =
useState<boolean>(false);
const [showPassword, setShowPassword] = useState<boolean>(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const confirmInputRef = useRef<HTMLInputElement>(null);
const [changePasswordLoading, setChangePasswordLoading] =
useState<boolean>(false);
const [changePasswordStatus, setChangePasswordStatus] = useState<
'success' | 'failed'
>();
const [changePassAlertOpen, setChangePassAlertOpen] =
useState<boolean>(false);
const [changePassFailedMessage, setChangePassFailedMessage] =
useState<string>('');
const passwordValidationRules = [
{ title: t('forgetPassword.includingANumber'), validator: containsNumber },
{ title: t('forgetPassword.atLeast8Characters'), validator: least8Chars },
{
title: t('forgetPassword.containsAnUppercaseAndLowercaseLetter'),
validator: hasUpperAndLowerLetter,
},
{ title: t('forgetPassword.ContainsASymbol'), validator: containsSymbol },
];
const handleBlur = () => {
setInputTouched(true);
};
const handleConfirmPassBlur = () => {
setConfirmInputTouched(true);
};
const handleSubmit = async () => {
if (!passValue || !isValidPassword(passValue)) {
setInputTouched(true);
inputRef.current?.focus();
} else if (passValue !== confirmPassValue) {
setConfirmInputTouched(true);
confirmInputRef.current?.focus();
} else {
setChangePasswordLoading(true);
const apiRequest: ResetPasswordRequest = {
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
phoneNumber:
infoType === 'phone'
? countryCode + forgettedPasswordInfo
: undefined,
newPassword: passValue,
confirmNewPassword: confirmPassValue,
};
const result = await resetPassword(apiRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setChangePasswordStatus('success');
onPasswordChanged();
} else {
setChangePasswordStatus('failed');
setChangePassFailedMessage(jsonRes.message);
}
setChangePassAlertOpen(true);
setChangePasswordLoading(false);
}
};
const isValidPassword = (value: string) => {
return (
containsNumber(value) &&
containsSymbol(value) &&
least8Chars(value) &&
hasUpperAndLowerLetter(value)
);
};
return (
<AuthenticationCard>
<Toast
open={changePassAlertOpen}
onClose={() => setChangePassAlertOpen(false)}
color={changePasswordStatus === 'failed' ? 'error' : 'success'}
>
{changePasswordStatus === 'failed'
? changePassFailedMessage
: t('forgetPassword.passwordChangedSuccessfully')}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">
{t('forgetPassword.changePassword')}
</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditInfo}
>
{forgettedPasswordInfo}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, mb: 2 }}>
{t('forgetPassword.createANewPassword')}
</Typography>
<TextField
label={t('forgetPassword.newPassword')}
type={showPassword ? 'text' : 'password'}
value={passValue}
inputRef={inputRef}
onChange={(e) => setPassValue(e.target.value)}
onBlur={handleBlur}
error={inputTouched && !isValidPassword(passValue)}
autoFocus
slotProps={{
htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } },
input: {
startAdornment: confirmPassValue &&
isValidPassword(passValue) &&
passValue === confirmPassValue && (
<TickCircle variant="Bold" color={theme.palette.success.main} />
),
endAdornment: passValue ? (
<IconButton
color="primary"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <Eye /> : <EyeSlash />}
</IconButton>
) : (
''
),
},
}}
sx={{ mt: 4 }}
/>
{!isValidPassword(passValue) && (
<Stack spacing={1} sx={{ mt: 2 }}>
{passwordValidationRules.map((rule) => (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TickCircle
variant={rule.validator(passValue) ? 'Bold' : 'Linear'}
color={
rule.validator(passValue)
? theme.palette.success.main
: theme.palette.primary.light
}
/>
<Typography variant="body2">{rule.title}</Typography>
</Stack>
))}
</Stack>
)}
<TextField
label={t('forgetPassword.confirmPassword')}
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassValue}
inputRef={confirmInputRef}
onChange={(e) => setConfirmPassValue(e.target.value)}
onBlur={handleConfirmPassBlur}
error={confirmInputTouched && confirmPassValue !== passValue}
slotProps={{
htmlInput: { sx: { lineHeight: 1.5, paddingInlineStart: 1 } },
input: {
startAdornment: confirmPassValue &&
isValidPassword(passValue) &&
passValue === confirmPassValue && (
<TickCircle variant="Bold" color={theme.palette.success.main} />
),
endAdornment: confirmPassValue ? (
<IconButton
color="primary"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <Eye /> : <EyeSlash />}
</IconButton>
) : (
''
),
},
}}
sx={{ my: 4 }}
/>
<Stack spacing={1}>
<Button loading={changePasswordLoading} onClick={handleSubmit}>
{t('forgetPassword.confirm')}
</Button>
</Stack>
</AuthenticationCard>
);
};

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { AuthType } from '../../types/authTypes';
import { ForgettedPasswordInfo } from './ForgettedPasswordInfo';
import { ForgetPasswordOtp } from './ForgetPasswordOtp';
import { ChangePassword } from './ChangePassword';
import type { CountryCode } from '@/types/commonTypes';
export const ForgetPasswordContainer = () => {
const [forgetPassCurrentStep, setForgetPassCurrentStep] = useState<
'enterInfo' | 'verifyOtp' | 'setPassword'
>('enterInfo');
const [forgettedPasswordInfo, setForgettedPasswordInfo] =
useState<string>('');
const [infoCountryCode, setInfoCountryCode] = useState<CountryCode>('+98');
const [infoType, setInfoType] = useState<AuthType>('email');
const handleVerifyOtp = () => {
setForgetPassCurrentStep('verifyOtp');
};
const handleEditInfo = () => {
setForgetPassCurrentStep('enterInfo');
};
const handleOtpVerified = () => {
setForgetPassCurrentStep('setPassword');
};
const handlePasswordChanged = () => {
console.log('changingPasswordTo');
};
return (
<>
{forgetPassCurrentStep === 'enterInfo' && (
<ForgettedPasswordInfo
infoType={infoType}
setInfoType={setInfoType}
forgettedPasswordInfo={forgettedPasswordInfo}
setForgettedPasswordInfo={setForgettedPasswordInfo}
onVerifyOtp={handleVerifyOtp}
countryCode={infoCountryCode}
setCountryCode={setInfoCountryCode}
/>
)}
{forgetPassCurrentStep === 'verifyOtp' && (
<ForgetPasswordOtp
countryCode={infoCountryCode}
infoType={infoType}
onEditInfo={handleEditInfo}
forgettedPasswordInfo={forgettedPasswordInfo}
onOTPVerified={handleOtpVerified}
/>
)}
{forgetPassCurrentStep === 'setPassword' && (
<ChangePassword
onEditInfo={handleEditInfo}
forgettedPasswordInfo={forgettedPasswordInfo}
onPasswordChanged={handlePasswordChanged}
infoType={infoType}
countryCode={infoCountryCode}
/>
)}
</>
);
};

View File

@@ -0,0 +1,185 @@
import { useTranslation } from 'react-i18next';
import { Box, Button, Stack, Typography } from '@mui/material';
import { Edit2 } from 'iconsax-react';
import DigitInput from '@/components/components/DigitsInput';
import type { AuthType } from '../../types/authTypes';
import { useEffect, useState } from 'react';
import { Toast } from '@/components/Toast';
import { AuthenticationCard } from '../AuthenticationCard';
import type { ConfirmForgetPassCodeRequest } from '../../types/userTypes';
import type { CountryCode } from '@/types/commonTypes';
import { confirmForgetPassCode } from '../../api/authorizationAPI';
interface ForgetPasswordOtpProps {
forgettedPasswordInfo: string;
infoType: AuthType;
countryCode: CountryCode;
onEditInfo: () => void;
onOTPVerified: (otpCode: string) => void;
}
export function ForgetPasswordOtp({
forgettedPasswordInfo,
infoType,
countryCode,
onEditInfo,
onOTPVerified,
}: ForgetPasswordOtpProps) {
const [otpCode, setOtpCode] = useState<string>('');
const [otpDigitInvalid, setOtpDigitInvalid] = useState<boolean>(false);
const [verifyStatus, setVerifyStatus] = useState<'failed' | 'success'>();
const [verifyStatusLoading, setVerifyStatusLoading] =
useState<boolean>(false);
const [verifyAlertMessage, setVerifyAlertMessage] = useState<string>();
const { t } = useTranslation('authentication');
const [resendTimer, setResendTimer] = useState<number>(120);
const [canResend, setCanResend] = useState(false);
const [resendLoading, setResendLoading] = useState<boolean>(false);
useEffect(() => {
let interval: NodeJS.Timeout;
if (resendTimer > 0) {
interval = setInterval(() => {
setResendTimer((prev) => prev - 1);
}, 1000);
} else {
setCanResend(true);
}
return () => clearInterval(interval);
}, [resendTimer]);
const handleResendOTPCode = () => {
setResendLoading(true);
// TODO: Call API here instead of settimeout
setTimeout(() => {
console.log('resended');
setResendTimer(120);
setCanResend(false);
setResendLoading(false);
}, 1000);
};
const formatTime = (seconds: number) => {
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
};
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
setOtpDigitInvalid(true);
} else {
setOtpDigitInvalid(false);
setVerifyStatusLoading(true);
// Change setTimeout to api call
const apiRequest: ConfirmForgetPassCodeRequest = {
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
phoneNumber:
infoType === 'phone'
? countryCode + forgettedPasswordInfo
: undefined,
code: otpCode,
};
const result = await confirmForgetPassCode(apiRequest);
const jsonRes = await result.json();
if (jsonRes.success) {
setVerifyStatus('success');
onOTPVerified(otpCode);
} else {
setVerifyStatus('failed');
setVerifyAlertMessage(jsonRes.message);
}
setVerifyStatusLoading(false);
}
};
return (
<Stack alignItems="center">
<AuthenticationCard>
<Toast
open={!!verifyAlertMessage}
onClose={() => setVerifyAlertMessage(undefined)}
color={'error'}
>
{verifyAlertMessage}
</Toast>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
mb: 0.5,
}}
>
<Typography variant="h5">
{t('forgetPassword.forgetPassword')}
</Typography>
<Button
variant="outlined"
size="large"
sx={{ textTransform: 'lowercase', width: 'auto' }}
endIcon={<Edit2 />}
onClick={onEditInfo}
>
{infoType === 'phone'
? countryCode + forgettedPasswordInfo
: forgettedPasswordInfo}
</Button>
</Box>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{infoType === 'email'
? t(
'forgetPassword.anEmailContainingARecoveryCodeHasBeenSentToThisEmailAddress',
)
: t(
'forgetPassword.anCodeContainingARecoveryCodeHasBeenSentToThisPhoneNumber',
)}
</Typography>
<DigitInput
error={otpDigitInvalid || verifyStatus === 'failed'}
success={verifyStatus === 'success'}
onChange={(value) => setOtpCode(value)}
/>
<Button onClick={handleVerifyOTP} loading={verifyStatusLoading}>
{t('forgetPassword.confirm')}
</Button>
</AuthenticationCard>
<Stack
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
mt: 1.5,
}}
>
<Typography variant="body1">{t('verify.resendCodeIn')}</Typography>
<Button
variant="text"
loading={resendLoading}
sx={{ width: 'auto' }}
onClick={canResend ? handleResendOTPCode : undefined}
>
{canResend && t('verify.resendCode')}
{!canResend && `${formatTime(resendTimer)} ${t('verify.moreMinute')}`}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { Button, Stack, TextField, Typography } from '@mui/material';
import { useRef, useState, type Dispatch } from 'react';
import { useTranslation } from 'react-i18next';
import { isNumeric } from '@/utils/regexes/isNumeric';
import type { AuthType } from '../../types/authTypes';
import { isEmail } from '@/utils/regexes/isEmail';
import { AuthenticationCard } from '../AuthenticationCard';
import { CountryCodeSelector } from '../CountryCodeSelector';
import type { CountryCode } from '@/types/commonTypes';
import { sendForgetPassCode } from '../../api/authorizationAPI';
import type { SendForgetPassCodeRequest } from '../../types/userTypes';
import { Toast } from '@/components/Toast';
import { isPhoneNumber } from '@/utils/regexes/isValidPhoneNumber';
export interface ForgettedPasswordInfoProps {
forgettedPasswordInfo: string;
setForgettedPasswordInfo: Dispatch<string>;
infoType: AuthType;
setInfoType: Dispatch<AuthType>;
onVerifyOtp: (value: string) => void;
countryCode: CountryCode;
setCountryCode: Dispatch<CountryCode>;
}
export function ForgettedPasswordInfo({
forgettedPasswordInfo,
setForgettedPasswordInfo,
infoType,
setInfoType,
onVerifyOtp,
countryCode,
setCountryCode,
}: ForgettedPasswordInfoProps) {
const { t } = useTranslation('authentication');
const textFieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string>();
const [touched, setTouched] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>();
const [sendCodeLoading, setSendCodeLoading] = useState<boolean>(false);
const inputError: boolean = touched && !!error;
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setForgettedPasswordInfo(newValue);
// If the new value contains only digits (or is empty), it's a phone number
if (isNumeric(newValue)) {
setInfoType('phone');
} else {
setInfoType('email');
}
};
const handleBlur = () => {
setTouched(true);
validateInput(forgettedPasswordInfo, infoType);
};
const validateInput = (
value: string,
authType: AuthType,
setErrors: boolean = true,
) => {
if (!value) {
if (setErrors) setError(t('loginForm.thisFieldIsRequired'));
return false;
} else if (authType === 'email' && !isEmail(value)) {
if (setErrors) setError(t('loginForm.emailIsInvalid'));
return false;
} else if (authType === 'phone' && !isPhoneNumber(countryCode, value)) {
if (setErrors) setError(t('loginForm.phoneNumberIsInvalid'));
return false;
} else {
if (setErrors) setError(undefined);
return true;
}
};
const handleSubmit = async () => {
if (validateInput(forgettedPasswordInfo, infoType, false)) {
setSendCodeLoading(true);
const sendCodeRequest: SendForgetPassCodeRequest = {
email: infoType === 'email' ? forgettedPasswordInfo : undefined,
phoneNumber:
infoType === 'phone'
? countryCode + forgettedPasswordInfo
: undefined,
};
const result = await sendForgetPassCode(sendCodeRequest);
const jsonRes = await result.json();
if (!jsonRes.success) {
setErrorMessage(jsonRes.message);
}
setSendCodeLoading(false);
onVerifyOtp(forgettedPasswordInfo);
} else {
inputRef.current?.focus();
validateInput(forgettedPasswordInfo, infoType);
}
};
const showAdornment =
infoType === 'phone' && forgettedPasswordInfo.length > 0;
return (
<AuthenticationCard>
<Toast
color="error"
onClose={() => setErrorMessage(undefined)}
open={!!errorMessage}
>
{errorMessage}
</Toast>
<Stack spacing={1}>
<Typography variant="h5">
{t('forgetPassword.forgetPassword')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t(
'forgetPassword.pleaseEnterYourMobileNumberEmailToRecoverYourPassword',
)}
</Typography>
</Stack>
<TextField
ref={textFieldRef}
inputRef={inputRef}
label={t('loginForm.emailOrPhoneLabel')}
value={forgettedPasswordInfo}
onChange={handleInputChange}
onBlur={handleBlur}
error={inputError}
helperText={inputError ? error : ''}
autoFocus
slotProps={{
htmlInput: { dir: 'auto', sx: { lineHeight: 1.5, paddingX: 0 } },
input: {
endAdornment: (
<CountryCodeSelector
value={countryCode}
onChange={setCountryCode}
show={showAdornment}
menuAnchor={textFieldRef.current}
onCloseFocusRef={inputRef}
/>
),
},
}}
sx={{ my: 4, mb: 8 }}
/>
<Stack spacing={2}>
<Button loading={sendCodeLoading} onClick={handleSubmit}>
{t('forgetPassword.confirm')}
</Button>
</Stack>
</AuthenticationCard>
);
}

View File

View File

@@ -0,0 +1,20 @@
import { FlexBox } from '@/components/components/common/FlexBox';
import Logo from '@/components/Logo';
import { AuthenticationSteps } from '../components/AuthenticationSteps/AuthenticationSteps';
export function AuthenticationPage() {
return (
<FlexBox
direction="column"
align="center"
justify="center"
sx={{
minHeight: '100vh',
gap: 3,
}}
>
<Logo />
<AuthenticationSteps />
</FlexBox>
);
}

View File

@@ -0,0 +1,20 @@
import { FlexBox } from '@/components/components/common/FlexBox';
import Logo from '@/components/Logo';
import { ForgetPasswordContainer } from '../components/ForgetPassword/ForgetPasswordContainer';
export function ForgetPasswordPage() {
return (
<FlexBox
direction="column"
align="center"
justify="center"
sx={{
minHeight: '100vh',
gap: 3,
}}
>
<Logo />
<ForgetPasswordContainer />
</FlexBox>
);
}

View File

@@ -0,0 +1,10 @@
export type AuthType = 'email' | 'phone';
export type AuthMode = 'register' | 'login';
export type AuthStep =
| 'emailOrPhone'
| 'verify'
| 'enterPassword'
| 'addPhoneNumber'
| 'addedPhoneNumberVerify';

View File

@@ -0,0 +1,138 @@
// GetUserStatusByPhoneNumberOrEmail
import type { ApiResponse } from '@/types/apiResponse';
import type { GUID } from '@/types/commonTypes';
export interface GetUserStatusByPhoneNumberOrEmailRequest {
phoneNumber?: string;
email?: string;
}
export interface GetUserStatusByPhoneNumberOrEmailResponse extends ApiResponse {
userStatus: UserStatus;
}
export enum UserStatus {
None = 0,
RegisteredWithPassword = 1,
RegisteredWithoutPassword = 2,
NotRegistered = 3,
}
// LoginOrSignUpWithOtp
export interface LoginRequest {
otpCode: string;
phoneNumber?: string;
email?: string;
returnUrl: string;
}
export interface PasswordLoginRequest {
phoneNumber?: string;
email?: string;
password: string;
returnUrl: string;
}
export interface LoginResponse extends ApiResponse {
returnUrl: string;
userId: GUID;
registeredWithOutPhoneNumber: boolean;
completedUserInformation: boolean;
}
// SendSmsOtp
export interface SendSmsOtpRequest {
phoneNumber: string;
}
// SendEmailOtp
export interface SendEmailOtpRequest {
email: string;
}
// ConfirmOtp
export interface ConfirmEmailOtpRequest {
email: string;
otpCode: string;
}
export interface ConfirmSmsOtpRequest {
phoneNumber: string;
otpCode: string;
}
export interface ConfirmOtpResponse extends ApiResponse {
confirm: boolean;
}
// ResetPassword
export interface ResetPasswordRequest {
email?: string;
phoneNumber?: string;
newPassword: string;
confirmNewPassword: string;
}
export interface ResetPasswordResponse extends ApiResponse {
passwordChanged: boolean;
}
// SendForgetPassCode
export interface SendForgetPassCodeRequest {
email?: string;
phoneNumber?: string;
}
// ConfirmForgetPassCode
export interface ConfirmForgetPassCodeRequest {
email?: string;
phoneNumber?: string;
code: string;
}
// LoginOrSignUpWithGoogle
export interface GoogleCodeClientResponse {
id_token: string;
}
export interface LoginOrSignUpWithGoogleRequest {
idToken: string;
returnUrl: string;
}
export interface LoginOrSignUpWithGoogleResponse extends ApiResponse {
userId: GUID;
registeredWithOutPhoneNumber: boolean;
completedUserInformation: boolean;
returnUrl: string;
}
// CompleteUserInformation
export interface CompleteUserInformationRequest {
firstName?: string;
lastName?: string;
gender?: Gender;
nationalCode?: string;
savePassword?: boolean;
password?: string;
saveEmail?: boolean;
email?: string;
birthDate?: string;
countryCode?: string;
userId?: GUID;
}
export enum Gender {
Male = 1,
Female = 2,
}

12
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> {}
}
interface Window {
google: typeof google;
}
const google: any;
}

View File

@@ -1,24 +1,46 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { type ApiResponse } from '@/types/apiResponse';
type ApiFunction<T, P> = (params?: P) => Promise<{ data: T }>;
// Define options for the hook
interface UseApiOptions {
// If true, the API call will be executed immediately on mount
immediate?: boolean;
}
export function useManualApi<T, P>(apiFunction: ApiFunction<T, P>) {
export function useApi<T extends ApiResponse, P extends any[]>(
apiFunction: (...args: P) => Promise<{ data: T }>,
options: UseApiOptions = {},
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);
const execute = useCallback(
async (...args: P) => {
setLoading(true);
setError(null);
const execute = async (params?: P) => {
setLoading(true);
setError(null);
try {
const response = await apiFunction(params);
setData(response.data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
try {
const response = await apiFunction(...args);
setData(response.data);
} catch (err) {
// TODO: can handle some common errors here, 400 and 500 errors
setError(err);
} finally {
setLoading(false);
}
},
[apiFunction],
);
// If the 'immediate' option is true, execute the function on mount
useEffect(() => {
if (options.immediate) {
// We pass undefined as params for the initial call.
execute(...(undefined as unknown as P));
}
};
}, [execute, options.immediate]);
return { data, loading, error, execute };
}

View File

@@ -12,8 +12,8 @@ const apiClient = axios.create({
// Set default headers
headers: {
// Accept: 'application/json',
Authorization: 'Bearer ' + getToken(),
'Content-Type': 'application/json',
Accept: 'application/json',
},
});

View File

@@ -3,6 +3,7 @@ import { I18nextProvider } from 'react-i18next';
import i18n from '@/config/i18n';
import { CustomThemeProvider } from './CustomThemeProvider';
import { RtlProvider } from './RtlProvider';
import { ToastProvider } from '@rkheftan/harmony-ui';
export const AppProviders: React.FC<{ children: React.ReactNode }> = ({
children,
@@ -10,7 +11,9 @@ export const AppProviders: React.FC<{ children: React.ReactNode }> = ({
return (
<I18nextProvider i18n={i18n}>
<RtlProvider>
<CustomThemeProvider>{children}</CustomThemeProvider>
<CustomThemeProvider>
<ToastProvider>{children}</ToastProvider>
</CustomThemeProvider>
</RtlProvider>
</I18nextProvider>
);

View File

@@ -13,6 +13,18 @@ export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({
const direction = i18n.dir(i18n.language);
return createTheme({
components: {
MuiSelect: {
styleOverrides: {
root: {
textAlign: 'left',
},
select: {
textAlign: 'left',
},
},
},
},
direction: direction,
colorSchemes: {
light: {
@@ -25,6 +37,21 @@ export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({
cssVariables: {
colorSchemeSelector: 'class',
},
components: {
MuiTextField: {
defaultProps: {
variant: 'outlined',
fullWidth: true,
},
},
MuiButton: {
defaultProps: {
size: 'large',
fullWidth: true,
variant: 'contained',
},
},
},
spacing: 8,
typography: typography,
components: {
@@ -40,7 +67,7 @@ export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({
}, [i18n]);
return (
<ThemeProvider theme={theme} defaultMode="system">
<ThemeProvider theme={theme} defaultMode="light">
{children}
</ThemeProvider>
);

View File

@@ -1,25 +1,23 @@
import React, { useState, useEffect } from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import rtlPlugin from 'stylis-plugin-rtl';
import { prefixer } from 'stylis';
// This provider configures Emotion's cache to support RTL.
export const RtlProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { i18n } = useTranslation();
const [cache, setCache] = useState(createCache({ key: 'css' }));
useEffect(() => {
const newDir = i18n.dir(i18n.language);
const newCache = createCache({
key: 'css',
stylisPlugins: newDir === 'rtl' ? [rtlPlugin] : [],
const cacheRtl = useMemo(() => {
const isRtl = i18n.dir(i18n.language) === 'rtl';
return createCache({
key: isRtl ? 'muirtl' : 'muiltr',
stylisPlugins: isRtl ? [prefixer, rtlPlugin] : [],
});
setCache(newCache);
}, [i18n, i18n.language]);
}, [i18n]);
return <CacheProvider value={cache}>{children}</CacheProvider>;
return <CacheProvider value={cacheRtl}>{children}</CacheProvider>;
};

119
src/routes/config.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { Layout } from '@/components/Layout/Layout';
import {
Calendar,
Devices,
LocationTick,
Mobile,
PasswordCheck,
Personalcard,
ProfileCircle,
Setting,
Shield,
Sms,
type Icon,
} from 'iconsax-react';
import { type ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
export interface RouteConfig {
path: string;
element?: ReactNode;
navConfig?: {
title: string; // Translation key
icon?: Icon;
};
children?: RouteConfig[];
}
// can lazy load component if needed (ex. lazy(() => import('@/features/home/routes/HomePage'));)
export const appRoutes: RouteConfig[] = [
{
path: '/',
element: <Navigate to="/setting/profile" replace />,
},
{
path: '/setting',
element: <Layout />,
children: [
// TODO: add route component to each route
{
path: '/setting/profile',
navConfig: {
// Profile component
title: 'side.account',
icon: ProfileCircle,
},
children: [
{
path: '/setting/profile#info',
navConfig: {
title: 'side.personalInfo',
icon: Personalcard,
},
},
{
path: '/setting/profile#contact-info',
navConfig: {
title: 'side.contactInfo',
icon: Mobile,
},
},
{
path: '/setting/profile#email',
navConfig: {
title: 'side.email',
icon: Sms,
},
},
],
},
{
path: '/setting/security',
// security component
navConfig: {
title: 'side.security',
icon: Shield,
},
children: [
{
path: '/setting/security#password',
navConfig: {
title: 'side.password',
icon: PasswordCheck,
},
},
{
path: '/setting/security#confirmed-ips',
navConfig: {
title: 'side.confirmedIps',
icon: LocationTick,
},
},
{
path: '/setting/security#recent-sessions',
navConfig: {
title: 'side.recentSessions',
icon: Devices,
},
},
],
},
{
path: '/setting/active-sessions',
// active session component
navConfig: {
title: 'side.activeSessions',
icon: Calendar,
},
},
{
path: '/setting/preferences',
// setting component
navConfig: {
title: 'side.setting',
icon: Setting,
},
},
],
},
];

34
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { Suspense, type ReactNode } from 'react';
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { appRoutes, type RouteConfig } from './config';
/**
* A recursive function to map our custom route config to the format
* that react-router-dom expects, applying layouts and guards.
*/
function mapRoutes(routes: RouteConfig[]): RouteObject[] {
return routes.map((route) => {
// Start with the base element, wrapped in Suspense for lazy loading
let element: ReactNode = (
<Suspense fallback={<div>Loading...</div>}>{route.element}</Suspense>
);
// Conditionally wrap the element in the specified layout
// if (route.layout) {
// element = <route.layout>{element}</route.layout>;
// }
// Conditionally wrap the element in the authentication guard
// if (route.authRequired) {
// element = <ProtectedRoute>{element}</ProtectedRoute>;
// }
return {
path: route.path,
element: element,
...(route.children && { children: mapRoutes(route.children) }),
};
});
}
export const router = createBrowserRouter(mapRoutes(appRoutes));

View File

@@ -4,7 +4,7 @@ import type { Palette } from './color.type';
export const PALETTE: Palette = {
primary: {
light: {
main: blue[500],
main: blue[400],
dark: blue[700],
light: blue[100],
contrastText: '#FFFFFF',
@@ -49,9 +49,9 @@ export const PALETTE: Palette = {
},
error: {
light: {
main: '#E53935',
dark: '#C62828',
light: '#EF5350',
main: '#d32f2f',
dark: '#c62828',
light: '#ef5350',
contrastText: '#FFFFFF',
},
dark: {
@@ -91,9 +91,9 @@ export const PALETTE: Palette = {
},
success: {
light: {
main: '#43A047',
dark: '#1B5E20',
light: '#81C784',
main: '#2e7d32',
dark: '#1b5e20',
light: '#4caf50',
contrastText: '#FFFFFF',
},
// TODO

3
src/types/commonTypes.ts Normal file
View File

@@ -0,0 +1,3 @@
export type GUID = `${string}-${string}-${string}-${string}-${string}`;
export type CountryCode = `+${number}`;

View File

@@ -0,0 +1,5 @@
export type FetchPromise<T = ''> = Promise<FetchResponse<T>>;
export interface FetchResponse<T> extends Response {
json(): Promise<T>;
}

View File

@@ -3,6 +3,7 @@ export function regex(password: string) {
const hasMinLength = password.length >= 8;
const hasUpperAndLower = /[A-Z]/.test(password) && /[a-z]/.test(password);
const hasSpecialChar = /[!@#$%^&*]/.test(password);
return {
hasNumber,

View File

@@ -0,0 +1 @@
export const containsNumber = (value: string) => /\d/.test(value);

View File

@@ -0,0 +1 @@
export const containsSymbol = (value: string) => /[!@#$%&*\^]/.test(value);

View File

@@ -0,0 +1,5 @@
export const hasUpperAndLowerLetter = (value: string) => {
const hasUpper = /[A-Z]/.test(value);
const hasLower = /[a-z]/.test(value);
return hasUpper && hasLower;
};

View File

@@ -0,0 +1,2 @@
export const isEmail = (value: string) =>
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value);

View File

@@ -0,0 +1 @@
export const isNumeric = (value: string) => /^\d*$/.test(value);

View File

@@ -0,0 +1,7 @@
import parsePhoneNumberFromString from 'libphonenumber-js';
export const isPhoneNumber = (code: string, phone: string) => {
const phoneNumber = parsePhoneNumberFromString(code + phone);
return phoneNumber && phoneNumber.isValid();
};

View File

@@ -0,0 +1 @@
export const least8Chars = (value: string) => value.length >= 8;