chore: fix styles and change the apis

This commit is contained in:
Koosha Lahouti
2025-08-15 17:49:53 +03:30
parent f82fed54d5
commit fa2d2a3c73
15 changed files with 1218 additions and 1190 deletions

1772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,12 @@
import {
type SendEmailOtpPayload,
type TokenApiResponse,
type ConfirmEmailOtpPayload,
type CompleteUserInfoPayload,
type CompleteUserInfoResponse,
type GenericApiResponse,
} from './types';
import axios from 'axios';
} from '../types/completionFormApiTypes';
import apiClient from '@/lib/apiClient';
const AUTH_API_URL = 'https://accounts.business-harmony.com';
export const getTokenApi = async (): Promise<TokenApiResponse> => {
const body = new URLSearchParams();
body.set('grant_type', 'password');
body.set('username', 'zareian.1381@gmail.com');
body.set('password', '123@Qweasd');
body.set('client_id', 'harmony_identity');
body.set('scope', 'openid harmony_identity profile offline_access');
const { data } = await axios.post<TokenApiResponse>(
`${AUTH_API_URL}/connect/token`,
body.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
return data;
};
export const sendEmailOtpApi = async (
payload: SendEmailOtpPayload,
): Promise<GenericApiResponse & { codeSentAnyway?: boolean }> => {

View File

@@ -14,11 +14,7 @@ import { format as formatJalali } from 'date-fns-jalali';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { toLocaleDigits } from '@/utils/persianDigit';
interface DateOfBirthProps {
value: Date | null;
onChange: (date: Date | null) => void;
}
import { type DateOfBirthProps } from '../types/settingForm';
export default function DateOfBirth({ value, onChange }: DateOfBirthProps) {
const { t, i18n } = useTranslation('completionForm');

View File

@@ -13,24 +13,7 @@ import {
import { useTranslation } from 'react-i18next';
import { TickCircle, Edit } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
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;
}
import { type EmailSectionProps } from '../types/settingForm';
export function EmailSection({
showEmail,

View File

@@ -12,22 +12,7 @@ import { useTranslation } from 'react-i18next';
import { TickCircle, Eye, EyeSlash, CloseCircle } from 'iconsax-react';
import { PasswordValidationItem } from './PasswordValidation';
import { Icon } from '@rkheftan/harmony-ui';
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;
}
import { type PasswordSectionProps } from '../types/settingForm';
export function PasswordSection({
showPasswordSection,

View File

@@ -1,11 +1,7 @@
import { Box, Typography } from '@mui/material';
import { TickCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
interface ValidationItemProps {
isValid: boolean;
label: string;
}
import { type ValidationItemProps } from '../types/settingForm';
export function PasswordValidationItem({
isValid,

View File

@@ -10,27 +10,12 @@ import {
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { Woman, Man } from 'iconsax-react';
import { type Dispatch, type SetStateAction } from 'react';
import DateOfBirth from './DateOfBirth';
import { countries } from '../data/Countries';
import { CountryFlag } from '@/components/CountryFlag';
import { Icon } from '@rkheftan/harmony-ui';
import { Gender } from './types';
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;
}
import { type PersonalInfoFieldsProps } from '../types/settingForm';
import { Gender } from '../types/settingForm';
export function PersonalInfoFields({
firstName,

View File

@@ -9,13 +9,9 @@ import {
DialogContent,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
interface Props {
onSubmit: () => void;
loading: boolean;
error: string | null;
success: boolean;
}
export function SubmitSection({ onSubmit, loading, error, success }: Props) {
import { type SubmitProps } from '../types/settingForm';
export function SubmitSection({ onSubmit, loading, error }: SubmitProps) {
const { t, i18n } = useTranslation('completionForm');
const [openDialog, setOpenDialog] = useState(false);

View File

@@ -1,50 +1,53 @@
import { useEffect, useState } from 'react';
import { Box, Typography, Button } from '@mui/material';
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 apiClient from '@/lib/apiClient';
import { useToast } from '@rkheftan/harmony-ui';
import { AxiosError } from 'axios';
import { regex } from '../../../utils/regex';
import { toLocaleDigits } from '../../../utils/persianDigit';
import axios, { isAxiosError } from 'axios';
import i18n from '@/config/i18n';
import { Gender } from './types'; // ✅ Added
interface TokenApiResponse {
access_token: string;
}
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);
// ✅ Corrected section: use Gender enum
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 [codeSent, setCodeSent] = useState(false);
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 [isVerifyingCode, setIsVerifyingCode] = useState(false);
const [showPasswordValidations, setShowPasswordValidations] = useState(false);
const {
hasNumber,
@@ -55,25 +58,113 @@ export function UserCompletionForm() {
correctEmail,
} = regex(password, email);
const matchPassword = password === confirmPassword;
const [showPasswordValidations, setShowPasswordValidations] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
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 showToast = useToast();
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 (password) {
if (!validPassword) {
setShowPasswordValidations(true);
if (sendCodeData) {
if (sendCodeData.success) {
showToast({
message: sendCodeData.message || t('completion.successfullCodeSent'),
severity: 'success',
});
setCodeSent(true);
setButtonState('counting');
setCountdown(120);
} else {
const timer = setTimeout(() => setShowPasswordValidations(false), 1000);
return () => clearTimeout(timer);
showToast({
message: sendCodeData.message || t('completion.problem'),
severity: 'error',
});
}
} else {
setShowPasswordValidations(false);
}
}, [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(() => {
@@ -93,6 +184,37 @@ export function UserCompletionForm() {
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');
@@ -102,142 +224,6 @@ export function UserCompletionForm() {
return t('completion.vericationCodeButton');
};
const [tokenError, setTokenError] = useState<string | null>(null);
const apiUrl = 'https://accounts.business-harmony.com';
const tokenEndpoint = `${apiUrl}/connect/token`;
const getToken = async () => {
setTokenError(null);
try {
const body = new URLSearchParams();
body.set('grant_type', 'password');
body.set('username', 'zareian.1381@gmail.com');
body.set('password', '123@Qweasd');
body.set('client_id', 'harmony_identity');
body.set('scope', 'openid harmony_identity profile offline_access');
const response = await axios.post<TokenApiResponse>(
tokenEndpoint,
body.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (response.data?.access_token) {
localStorage.setItem('authToken', response.data.access_token);
} else {
throw new Error('No access token in response');
}
} catch (error: unknown) {
let message = 'Failed to get token';
if (isAxiosError(error) && error.response) {
message = `Request failed with status ${error.response.status}`;
} else if (error instanceof Error) {
message = error.message;
}
setTokenError(message);
}
};
const storedToken = localStorage.getItem('authToken');
const handleSendCode = async () => {
setError(null);
setLoading(true);
setSuccess(false);
try {
const response = await apiClient.post(
'/User/SendEmailOtp',
{ email },
{
headers: {
Authorization: `Bearer ${storedToken}`,
},
},
);
if (response.data?.success) {
showToast({
message: response.data.message || t('completion.successfullCodeSent'),
severity: 'success',
});
setCodeSent(true);
setButtonState('counting');
setCountdown(120);
} else if (response.data?.codeSentAnyway) {
showToast({
message: t('completion.codeSentBut') + (response.data.message || ''),
severity: 'warning',
});
setCodeSent(true);
setButtonState('counting');
setCountdown(120);
} else {
showToast({
message: response.data.message || t('completion.problem'),
severity: 'error',
});
}
} catch (error: unknown) {
const err = error as AxiosError<{ message?: string }>;
showToast({
message: err.response?.data?.message || t('completion.problem'),
severity: 'error',
});
} finally {
setLoading(false);
}
};
const handleVerifyCode = async () => {
if (!verificationCode.trim()) {
setError('Please enter the verification code');
return;
}
setIsVerifyingCode(true);
setError(null);
try {
const res = await apiClient.post(
'/User/ConfirmEmailOtp',
{
email,
otpCode: verificationCode,
},
{
headers: {
Authorization: `Bearer ${storedToken}`,
},
},
);
if (res.data?.success) {
setEmailVerified(true);
setSuccess(true);
showToast({
message: res.data.message || t('completion.codeVerified'),
severity: 'success',
});
} else {
showToast({
message: res.data?.message || t('completion.invalidCode'),
severity: 'error',
});
setEmailVerified(false);
}
} catch (error: unknown) {
const err = error as AxiosError<{ message?: string }>;
showToast({
message: err.response?.data?.message || '',
severity: 'error',
});
setEmailVerified(false);
} finally {
setIsVerifyingCode(false);
}
};
const handleEditEmail = () => {
setButtonState('default');
setCodeSent(false);
@@ -245,60 +231,6 @@ export function UserCompletionForm() {
setVerificationCode('');
};
const handleSubmit = async () => {
setLoading(true);
setError(null);
setSuccess(false);
try {
const { data } = await apiClient.post<{
success: boolean;
errorCode: number;
message: string;
validations: { property: string; message: string }[];
}>(
'/User/CompleteUserInformation',
{
userId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
firstName,
lastName,
gender: sex === Gender.Female ? 2 : 1, // ✅ Corrected
nationalId,
savePassword: showPasswordSection,
password: showPasswordSection ? password : undefined,
saveEmail: showEmail,
email: showEmail ? email : undefined,
birthDate,
country,
},
{
headers: {
Authorization: `Bearer ${storedToken}`,
},
},
);
if (data.success) {
showToast({
message: data.message || t('completion.submitSuccess'),
severity: 'success',
});
} else {
showToast({
message: data.message || t('completion.submitError'),
severity: 'error',
});
}
} catch (error: unknown) {
const err = error as AxiosError<{ message?: string }>;
showToast({
message:
err.response?.data?.message || err.message || t('completion.problem'),
severity: 'error',
});
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
@@ -345,8 +277,8 @@ export function UserCompletionForm() {
setNationalId={setNationalId}
birthDate={birthDate}
setBirthDate={setBirthDate}
sex={sex} // ✅ Corrected
setSex={setSex} // ✅ Corrected
sex={sex}
setSex={setSex}
country={country}
setCountry={setCountry}
/>
@@ -384,21 +316,13 @@ export function UserCompletionForm() {
loading={isVerifyingCode}
handleEditEmail={handleEditEmail}
/>
<SubmitSection
onSubmit={handleSubmit}
loading={loading}
error={error}
success={success}
loading={isSubmitting}
error={getErrorMessage(submitError)}
success={!!submitData?.success}
/>
<Button
variant="contained"
color="secondary"
onClick={getToken}
size="large"
sx={{ textTransform: 'none' }}
>
Get Token
</Button>
</Box>
</Box>
);

View File

@@ -1,5 +0,0 @@
export enum Gender {
None = 0,
Female = 1,
Male = 2,
}

View File

@@ -1,15 +1,3 @@
export interface TokenRequestPayload {
grant_type: 'password';
username: string;
password: string;
client_id: string;
scope: string;
}
export interface TokenApiResponse {
access_token: string;
}
export interface GenericApiResponse {
success: boolean;
message: string;

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

@@ -1,26 +1,46 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { type ApiResponse } from '@/types/apiResponse';
type ApiFunction<T> = () => 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 useApi<T>(apiFunction: ApiFunction<T>) {
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(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
const fetchData = async () => {
const execute = useCallback(
async (...args: P) => {
setLoading(true);
setError(null);
try {
const response = await apiFunction();
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],
);
fetchData();
}, [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 };
return { data, loading, error, execute };
}

View File

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

13
src/types/apiResponse.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface ApiResponse {
success: boolean;
errorCode: number;
message: string;
validations: ApiResponseValidation[];
}
export interface ApiResponseValidation {
message: string;
code: number;
property: string;
severity: number;
}