mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
refactor: modularize profile setup components and update profile completion logic commit
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
export function ProfileGuard() {
|
||||
interface ProfileGuardProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileGuard({ children }: ProfileGuardProps) {
|
||||
const profileId = authStorage.getProfileId();
|
||||
|
||||
if (!profileId) {
|
||||
return <Navigate to="/profile-setup" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,9 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
@@ -25,56 +21,27 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { authStorage, setUser, logout } from '@ema-platform/auth';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
|
||||
const GENDERS = ['MALE', 'FEMALE'];
|
||||
const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'];
|
||||
const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'];
|
||||
import {
|
||||
ProfileFormContent,
|
||||
profileSchema,
|
||||
type ProfileValues,
|
||||
} from '../../profile/components/ProfileFormContent';
|
||||
import {
|
||||
AddressFormContent,
|
||||
addressSchema,
|
||||
type AddressValues,
|
||||
} from '../../profile/components/AddressFormContent';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Profile', icon: IconUser },
|
||||
{ label: 'Address', icon: IconMapPin },
|
||||
];
|
||||
|
||||
const profileSchema = z.object({
|
||||
professionId: z.string().min(1, 'Select your profession'),
|
||||
firstName: z.string().min(3, 'First name must be at least 3 characters'),
|
||||
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
|
||||
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
|
||||
gender: z.string().min(1, 'Select your gender'),
|
||||
dob: z.string().min(1, 'Select your date of birth'),
|
||||
pob: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, 'Select your marital status'),
|
||||
});
|
||||
|
||||
type ProfileValues = z.infer<typeof profileSchema>;
|
||||
|
||||
const addressSchema = z.object({
|
||||
idType: z.string().min(1, 'Select ID type'),
|
||||
idNumber: z.string().min(1, 'Enter ID number'),
|
||||
nationality: z.string().min(1, 'Enter nationality'),
|
||||
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
|
||||
secondaryPhoneNumber: z.string().optional(),
|
||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
subcityId: z.string().optional(),
|
||||
woredaId: z.string().optional(),
|
||||
kebeleId: z.string().optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
postalAddress: z.string().optional(),
|
||||
emergencyContactName: z.string().optional(),
|
||||
emergencyContactPhone: z.string().optional(),
|
||||
emergencyContactRelation: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddressValues = z.infer<typeof addressSchema>;
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
@@ -160,7 +127,7 @@ export function ProfileSetupPage() {
|
||||
const [profileTrigger] = useApiMutation<{ id: string }>();
|
||||
const [addressTrigger] = useApiMutation<unknown>();
|
||||
const [meTrigger] = useApiMutation<{ id: string }>();
|
||||
const [profileCheckTrigger] = useApiMutation<{ count: number; items: Array<{ id: string; userId: string }> }>();
|
||||
const [profileCheckTrigger] = useApiMutation<{ total: number; items: Array<{ id: string; user: { id: string }; isComplete: boolean }> }>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
@@ -179,11 +146,11 @@ export function ProfileSetupPage() {
|
||||
useEffect(() => {
|
||||
if (!user || checkedExistingProfile.current) return;
|
||||
checkedExistingProfile.current = true;
|
||||
profileCheckTrigger({ url: '/profiles?take=1&skip=0', method: 'GET' })
|
||||
profileCheckTrigger({ url: `/profiles?q=${encodeURIComponent('i=user')}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => {
|
||||
const existing = data.items?.find((p) => p.userId === user.id);
|
||||
if (existing) {
|
||||
const existing = data.items?.find((p) => p.user?.id === user.id);
|
||||
if (existing?.isComplete) {
|
||||
authStorage.setProfileId(existing.id);
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
@@ -292,10 +259,16 @@ export function ProfileSetupPage() {
|
||||
maritalStatus: pv.maritalStatus,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
await profileTrigger({
|
||||
url: `/profiles/${profileResult.id}`,
|
||||
method: 'PUT',
|
||||
body: { isComplete: true },
|
||||
}).unwrap();
|
||||
authStorage.setProfileId(profileResult.id);
|
||||
|
||||
await addressTrigger({
|
||||
url: `/addresses/profile/${profileResult.id}`,
|
||||
url: `/addresss/profile/${profileResult.id}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
idType: av.idType,
|
||||
@@ -355,78 +328,15 @@ export function ProfileSetupPage() {
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
Personal Information
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="Profession"
|
||||
placeholder={professionsLoading ? 'Loading...' : 'Select'}
|
||||
required
|
||||
data={professionOptions}
|
||||
error={profileErrors.professionId?.message}
|
||||
value={profileWatch('professionId')}
|
||||
onChange={(val) => profileSetValue('professionId', val || '', { shouldValidate: true })}
|
||||
onBlur={() => profileTriggerValidation('professionId')}
|
||||
name="professionId"
|
||||
searchable
|
||||
disabled={professionsLoading}
|
||||
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="Enter first name"
|
||||
required
|
||||
{...profileRegister('firstName')}
|
||||
error={profileErrors.firstName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Middle Name"
|
||||
placeholder="Enter middle name"
|
||||
required
|
||||
{...profileRegister('middleName')}
|
||||
error={profileErrors.middleName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Enter last name"
|
||||
required
|
||||
{...profileRegister('lastName')}
|
||||
error={profileErrors.lastName?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Gender"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={GENDERS}
|
||||
error={profileErrors.gender?.message}
|
||||
value={profileWatch('gender')}
|
||||
onChange={(val) => profileSetValue('gender', val || '', { shouldValidate: true })}
|
||||
onBlur={() => profileTriggerValidation('gender')}
|
||||
name="gender"
|
||||
/>
|
||||
<TextInput
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
required
|
||||
{...profileRegister('dob')}
|
||||
error={profileErrors.dob?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Place of Birth"
|
||||
placeholder="City, Region"
|
||||
{...profileRegister('pob')}
|
||||
error={profileErrors.pob?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Marital Status"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={MARITAL_STATUSES}
|
||||
error={profileErrors.maritalStatus?.message}
|
||||
value={profileWatch('maritalStatus')}
|
||||
onChange={(val) => profileSetValue('maritalStatus', val || '', { shouldValidate: true })}
|
||||
onBlur={() => profileTriggerValidation('maritalStatus')}
|
||||
name="maritalStatus"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<ProfileFormContent
|
||||
register={profileRegister}
|
||||
errors={profileErrors}
|
||||
setValue={profileSetValue}
|
||||
watch={profileWatch}
|
||||
trigger={profileTriggerValidation}
|
||||
professionsLoading={professionsLoading}
|
||||
professionOptions={professionOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -435,119 +345,13 @@ export function ProfileSetupPage() {
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
Identity & Contact
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="ID Type"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={ID_TYPES}
|
||||
error={addressErrors.idType?.message}
|
||||
value={addressWatch('idType')}
|
||||
onChange={(val) => addressSetValue('idType', val || '', { shouldValidate: true })}
|
||||
onBlur={() => addressTriggerValidation('idType')}
|
||||
name="idType"
|
||||
/>
|
||||
<TextInput
|
||||
label="ID Number"
|
||||
placeholder="Enter ID number"
|
||||
required
|
||||
{...addressRegister('idNumber')}
|
||||
error={addressErrors.idNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nationality"
|
||||
placeholder="e.g. Ethiopian"
|
||||
required
|
||||
{...addressRegister('nationality')}
|
||||
error={addressErrors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Primary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
required
|
||||
{...addressRegister('primaryPhoneNumber')}
|
||||
error={addressErrors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Secondary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
{...addressRegister('secondaryPhoneNumber')}
|
||||
error={addressErrors.secondaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
{...addressRegister('email')}
|
||||
error={addressErrors.email?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Region ID"
|
||||
placeholder="Region UUID (optional)"
|
||||
{...addressRegister('regionId')}
|
||||
error={addressErrors.regionId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="City ID"
|
||||
placeholder="City UUID (optional)"
|
||||
{...addressRegister('cityId')}
|
||||
error={addressErrors.cityId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Subcity ID"
|
||||
placeholder="Subcity UUID (optional)"
|
||||
{...addressRegister('subcityId')}
|
||||
error={addressErrors.subcityId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Woreda ID"
|
||||
placeholder="Woreda UUID (optional)"
|
||||
{...addressRegister('woredaId')}
|
||||
error={addressErrors.woredaId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele ID"
|
||||
placeholder="Kebele UUID (optional)"
|
||||
{...addressRegister('kebeleId')}
|
||||
error={addressErrors.kebeleId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Street Address"
|
||||
placeholder="Street name, house number"
|
||||
{...addressRegister('streetAddress')}
|
||||
error={addressErrors.streetAddress?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Emergency Contact
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Contact Name"
|
||||
placeholder="Full name"
|
||||
{...addressRegister('emergencyContactName')}
|
||||
error={addressErrors.emergencyContactName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
{...addressRegister('emergencyContactPhone')}
|
||||
error={addressErrors.emergencyContactPhone?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Relationship"
|
||||
placeholder="Spouse, Parent, etc."
|
||||
{...addressRegister('emergencyContactRelation')}
|
||||
error={addressErrors.emergencyContactRelation?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<AddressFormContent
|
||||
register={addressRegister}
|
||||
errors={addressErrors}
|
||||
setValue={addressSetValue}
|
||||
watch={addressWatch}
|
||||
trigger={addressTriggerValidation}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const addressSchema = z.object({
|
||||
idType: z.string().min(1, 'Select ID type'),
|
||||
idNumber: z.string().min(1, 'Enter ID number'),
|
||||
nationality: z.string().min(1, 'Enter nationality'),
|
||||
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
|
||||
secondaryPhoneNumber: z.string().optional(),
|
||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
subcityId: z.string().optional(),
|
||||
woredaId: z.string().optional(),
|
||||
kebeleId: z.string().optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
postalAddress: z.string().optional(),
|
||||
emergencyContactName: z.string().optional(),
|
||||
emergencyContactPhone: z.string().optional(),
|
||||
emergencyContactRelation: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AddressValues = z.infer<typeof addressSchema>;
|
||||
|
||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
||||
|
||||
interface AddressFormContentProps {
|
||||
register: UseFormRegister<AddressValues>;
|
||||
errors: FieldErrors<AddressValues>;
|
||||
setValue: UseFormSetValue<AddressValues>;
|
||||
watch: UseFormWatch<AddressValues>;
|
||||
trigger: UseFormTrigger<AddressValues>;
|
||||
}
|
||||
|
||||
export function AddressFormContent({
|
||||
register,
|
||||
errors,
|
||||
setValue,
|
||||
watch,
|
||||
trigger,
|
||||
}: AddressFormContentProps) {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="ID Type"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={[...ID_TYPES]}
|
||||
error={errors.idType?.message}
|
||||
value={watch('idType')}
|
||||
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('idType')}
|
||||
name="idType"
|
||||
/>
|
||||
<TextInput
|
||||
label="ID Number"
|
||||
placeholder="Enter ID number"
|
||||
required
|
||||
{...register('idNumber')}
|
||||
error={errors.idNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nationality"
|
||||
placeholder="e.g. Ethiopian"
|
||||
required
|
||||
{...register('nationality')}
|
||||
error={errors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Primary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
required
|
||||
{...register('primaryPhoneNumber')}
|
||||
error={errors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Secondary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
{...register('secondaryPhoneNumber')}
|
||||
error={errors.secondaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
{...register('email')}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Region ID"
|
||||
placeholder="Region UUID (optional)"
|
||||
{...register('regionId')}
|
||||
error={errors.regionId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="City ID"
|
||||
placeholder="City UUID (optional)"
|
||||
{...register('cityId')}
|
||||
error={errors.cityId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Subcity ID"
|
||||
placeholder="Subcity UUID (optional)"
|
||||
{...register('subcityId')}
|
||||
error={errors.subcityId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Woreda ID"
|
||||
placeholder="Woreda UUID (optional)"
|
||||
{...register('woredaId')}
|
||||
error={errors.woredaId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele ID"
|
||||
placeholder="Kebele UUID (optional)"
|
||||
{...register('kebeleId')}
|
||||
error={errors.kebeleId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Street Address"
|
||||
placeholder="Street name, house number"
|
||||
{...register('streetAddress')}
|
||||
error={errors.streetAddress?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Emergency Contact
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Contact Name"
|
||||
placeholder="Full name"
|
||||
{...register('emergencyContactName')}
|
||||
error={errors.emergencyContactName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
{...register('emergencyContactPhone')}
|
||||
error={errors.emergencyContactPhone?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Relationship"
|
||||
placeholder="Spouse, Parent, etc."
|
||||
{...register('emergencyContactRelation')}
|
||||
error={errors.emergencyContactRelation?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const profileSchema = z.object({
|
||||
professionId: z.string().min(1, 'Select your profession'),
|
||||
firstName: z.string().min(3, 'First name must be at least 3 characters'),
|
||||
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
|
||||
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
|
||||
gender: z.string().min(1, 'Select your gender'),
|
||||
dob: z.string().min(1, 'Select your date of birth'),
|
||||
pob: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, 'Select your marital status'),
|
||||
});
|
||||
|
||||
export type ProfileValues = z.infer<typeof profileSchema>;
|
||||
|
||||
export const GENDERS = ['MALE', 'FEMALE'] as const;
|
||||
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
|
||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
||||
|
||||
interface ProfileFormContentProps {
|
||||
register: UseFormRegister<ProfileValues>;
|
||||
errors: FieldErrors<ProfileValues>;
|
||||
setValue: UseFormSetValue<ProfileValues>;
|
||||
watch: UseFormWatch<ProfileValues>;
|
||||
trigger: UseFormTrigger<ProfileValues>;
|
||||
professionsLoading: boolean;
|
||||
professionOptions: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export function ProfileFormContent({
|
||||
register,
|
||||
errors,
|
||||
setValue,
|
||||
watch,
|
||||
trigger,
|
||||
professionsLoading,
|
||||
professionOptions,
|
||||
}: ProfileFormContentProps) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="Profession"
|
||||
placeholder={professionsLoading ? 'Loading...' : 'Select'}
|
||||
required
|
||||
data={professionOptions}
|
||||
error={errors.professionId?.message}
|
||||
value={watch('professionId')}
|
||||
onChange={(val) => setValue('professionId', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('professionId')}
|
||||
name="professionId"
|
||||
searchable
|
||||
disabled={professionsLoading}
|
||||
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="Enter first name"
|
||||
required
|
||||
{...register('firstName')}
|
||||
error={errors.firstName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Middle Name"
|
||||
placeholder="Enter middle name"
|
||||
required
|
||||
{...register('middleName')}
|
||||
error={errors.middleName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Enter last name"
|
||||
required
|
||||
{...register('lastName')}
|
||||
error={errors.lastName?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Gender"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={[...GENDERS]}
|
||||
error={errors.gender?.message}
|
||||
value={watch('gender')}
|
||||
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('gender')}
|
||||
name="gender"
|
||||
/>
|
||||
<TextInput
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
required
|
||||
{...register('dob')}
|
||||
error={errors.dob?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Place of Birth"
|
||||
placeholder="City, Region"
|
||||
{...register('pob')}
|
||||
error={errors.pob?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Marital Status"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={[...MARITAL_STATUSES]}
|
||||
error={errors.maritalStatus?.message}
|
||||
value={watch('maritalStatus')}
|
||||
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}
|
||||
onBlur={() => trigger('maritalStatus')}
|
||||
name="maritalStatus"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
IconDeviceFloppy,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconMapPin,
|
||||
IconMoon,
|
||||
IconPhone,
|
||||
IconSettings,
|
||||
@@ -42,10 +45,20 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage, setUser } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
import {
|
||||
ProfileFormContent,
|
||||
profileSchema,
|
||||
type ProfileValues,
|
||||
} from '../components/ProfileFormContent';
|
||||
import {
|
||||
AddressFormContent,
|
||||
addressSchema,
|
||||
type AddressValues,
|
||||
} from '../components/AddressFormContent';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
function getInitials(name: string, fallback: string) {
|
||||
@@ -56,7 +69,6 @@ function getInitials(name: string, fallback: string) {
|
||||
return letters.toUpperCase();
|
||||
}
|
||||
|
||||
/** 0–4 rough strength score used by the meter on the security tab. */
|
||||
function passwordScore(pw: string) {
|
||||
if (!pw) return 0;
|
||||
let score = 0;
|
||||
@@ -76,16 +88,102 @@ export function ProfilePage() {
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [passwordTrigger] = useApiMutation<unknown>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
||||
const [isSavingAddress, setIsSavingAddress] = useState(false);
|
||||
|
||||
// UI-only preferences (no backend wiring yet).
|
||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
|
||||
// Load the latest profile from the server on mount so the form always
|
||||
// reflects the current account information (the cached user may be stale).
|
||||
// ---- Profession list (for Profile tab) ----
|
||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
|
||||
const [professionsLoading, setProfessionsLoading] = useState(true);
|
||||
const professionsFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (professionsFetched.current) return;
|
||||
professionsFetched.current = true;
|
||||
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setProfessions(data.items ?? []))
|
||||
.catch(() => setProfessions([]))
|
||||
.finally(() => setProfessionsLoading(false));
|
||||
}, [fetchProfessions]);
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
||||
[professions],
|
||||
);
|
||||
|
||||
const professionNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
professions.forEach((p) => { map[p.id] = p.name.en; });
|
||||
return map;
|
||||
}, [professions]);
|
||||
|
||||
// ---- Fetch profile data (for Profile & Address tabs) ----
|
||||
const [fetchProfile] = useApiMutation<Record<string, any>>();
|
||||
const [updateProfile] = useApiMutation<unknown>();
|
||||
const [updateAddress] = useApiMutation<unknown>();
|
||||
|
||||
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
||||
const [addressId, setAddressId] = useState<string | null>(null);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
|
||||
const profileId = authStorage.getProfileId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileId) {
|
||||
setDataLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchProfile({ url: `/profiles/${profileId}?i=address,profession`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => {
|
||||
setLoadedProfile({
|
||||
professionId: data.profession?.id || '',
|
||||
firstName: data.firstName || '',
|
||||
middleName: data.middleName || '',
|
||||
lastName: data.lastName || '',
|
||||
gender: data.gender || '',
|
||||
dob: data.dob ? data.dob.split('T')[0] : '',
|
||||
pob: data.pob || '',
|
||||
maritalStatus: data.maritalStatus || '',
|
||||
});
|
||||
|
||||
if (data.address) {
|
||||
setAddressId(data.address.id);
|
||||
setLoadedAddress({
|
||||
idType: data.address.idType || '',
|
||||
idNumber: data.address.idNumber || '',
|
||||
nationality: data.address.nationality || '',
|
||||
primaryPhoneNumber: data.address.primaryPhoneNumber || '',
|
||||
secondaryPhoneNumber: data.address.secondaryPhoneNumber || '',
|
||||
email: data.address.email || '',
|
||||
regionId: data.address.regionId || '',
|
||||
cityId: data.address.cityId || '',
|
||||
subcityId: data.address.subCityId || '',
|
||||
woredaId: data.address.woredaId || '',
|
||||
kebeleId: data.address.kebeleId || '',
|
||||
streetAddress: data.address.streetAddress || '',
|
||||
postalAddress: data.address.postalAddress || '',
|
||||
emergencyContactName: data.address.emergencyContactName || '',
|
||||
emergencyContactPhone: data.address.emergencyContactPhone || '',
|
||||
emergencyContactRelation: data.address.emergencycontactRelation || '',
|
||||
});
|
||||
}
|
||||
setDataLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setDataLoading(false);
|
||||
});
|
||||
}, [profileId, fetchProfile]);
|
||||
|
||||
// Load the latest user from the server on mount
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
meTrigger({ url: '/auth/me', method: 'GET' })
|
||||
@@ -93,37 +191,28 @@ export function ProfilePage() {
|
||||
.then((me) => {
|
||||
if (active) dispatch(setUser(me));
|
||||
})
|
||||
.catch(() => {
|
||||
/* fall back to the cached user already in the store */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// meTrigger/dispatch are stable; run once on mount.
|
||||
.catch(() => {});
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ---- Profile form ----
|
||||
const profileSchema = z.object({
|
||||
// ---- Personal form (auth user data) ----
|
||||
const personalSchema = z.object({
|
||||
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.phoneRequired') }),
|
||||
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
|
||||
});
|
||||
type ProfileValues = z.infer<typeof profileSchema>;
|
||||
type PersonalValues = z.infer<typeof personalSchema>;
|
||||
|
||||
const {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
reset: resetProfile,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
register: registerPersonal,
|
||||
handleSubmit: handlePersonalSubmit,
|
||||
reset: resetPersonal,
|
||||
formState: { errors: personalErrors },
|
||||
} = useForm<PersonalValues>({
|
||||
resolver: zodResolver(personalSchema),
|
||||
values: {
|
||||
nameEn: user?.name?.en ?? '',
|
||||
nameAm: user?.name?.am ?? '',
|
||||
@@ -133,7 +222,7 @@ export function ProfilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSaveProfile = async (values: ProfileValues) => {
|
||||
const onSavePersonal = async (values: PersonalValues) => {
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
await updateTrigger({
|
||||
@@ -147,7 +236,6 @@ export function ProfilePage() {
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
// Refresh the cached user so the rest of the app stays in sync.
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
@@ -159,18 +247,77 @@ export function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Maritime Profile form ----
|
||||
const {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
setValue: profileSetValue,
|
||||
watch: profileWatch,
|
||||
trigger: profileTriggerValidation,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
values: loadedProfile ?? undefined,
|
||||
});
|
||||
|
||||
const onSaveProfile = async (values: ProfileValues) => {
|
||||
if (!profileId) return;
|
||||
setIsSavingMaritime(true);
|
||||
try {
|
||||
await updateProfile({
|
||||
url: `/profiles/${profileId}`,
|
||||
method: 'PUT',
|
||||
body: values,
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Profile updated');
|
||||
} catch {
|
||||
notify.error('Failed to update profile');
|
||||
} finally {
|
||||
setIsSavingMaritime(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Address form ----
|
||||
const {
|
||||
register: registerAddress,
|
||||
handleSubmit: handleAddressSubmit,
|
||||
setValue: addressSetValue,
|
||||
watch: addressWatch,
|
||||
trigger: addressTriggerValidation,
|
||||
formState: { errors: addressErrors },
|
||||
} = useForm<AddressValues>({
|
||||
resolver: zodResolver(addressSchema),
|
||||
values: loadedAddress ?? undefined,
|
||||
});
|
||||
|
||||
const onSaveAddress = async (values: AddressValues) => {
|
||||
if (!addressId) return;
|
||||
setIsSavingAddress(true);
|
||||
try {
|
||||
await updateAddress({
|
||||
url: `/addresss/${addressId}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...values,
|
||||
postalAddess: values.postalAddress,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Address updated');
|
||||
} catch {
|
||||
notify.error('Failed to update address');
|
||||
} finally {
|
||||
setIsSavingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Password form ----
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
||||
confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t('profile.validation.passwordMismatch'),
|
||||
@@ -214,21 +361,13 @@ export function ProfilePage() {
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const score = passwordScore(watchPassword('newPassword'));
|
||||
const strengthLabels = [
|
||||
'',
|
||||
t('profile.strength.weak'),
|
||||
t('profile.strength.fair'),
|
||||
t('profile.strength.good'),
|
||||
t('profile.strength.strong'),
|
||||
'', t('profile.strength.weak'), t('profile.strength.fair'),
|
||||
t('profile.strength.good'), t('profile.strength.strong'),
|
||||
];
|
||||
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
|
||||
|
||||
const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' };
|
||||
// Mantine uses 'auto' for the system option.
|
||||
const appearanceOptions: {
|
||||
value: MantineColorScheme;
|
||||
label: string;
|
||||
icon: typeof IconSun;
|
||||
}[] = [
|
||||
const appearanceOptions: { value: MantineColorScheme; label: string; icon: typeof IconSun }[] = [
|
||||
{ value: 'light', label: t('profile.appearance.light'), icon: IconSun },
|
||||
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
|
||||
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
|
||||
@@ -297,13 +436,19 @@ export function ProfilePage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
defaultValue="profile"
|
||||
defaultValue="personal"
|
||||
variant="pills"
|
||||
classNames={{ list: classes.list, tab: classes.tab }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
||||
{t('profile.tabs.profile')}
|
||||
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
|
||||
Personal
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
|
||||
Profile
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
|
||||
Address
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||
{t('profile.tabs.security')}
|
||||
@@ -313,10 +458,10 @@ export function ProfilePage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ---- Profile ---- */}
|
||||
<Tabs.Panel value="profile" pt="md">
|
||||
{/* ---- Personal (auth user data) ---- */}
|
||||
<Tabs.Panel value="personal" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
|
||||
<form onSubmit={handlePersonalSubmit(onSavePersonal)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.personal')}</Title>
|
||||
@@ -327,14 +472,14 @@ export function ProfilePage() {
|
||||
<TextInput
|
||||
label={t('profile.fields.fullNameEn')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={profileErrors.nameEn?.message}
|
||||
{...registerProfile('nameEn')}
|
||||
error={personalErrors.nameEn?.message}
|
||||
{...registerPersonal('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.fullNameAm')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={profileErrors.nameAm?.message}
|
||||
{...registerProfile('nameAm')}
|
||||
error={personalErrors.nameAm?.message}
|
||||
{...registerPersonal('nameAm')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.username')}
|
||||
@@ -342,8 +487,8 @@ export function ProfilePage() {
|
||||
readOnly
|
||||
variant="filled"
|
||||
leftSection={<IconAt size={18} />}
|
||||
error={profileErrors.username?.message}
|
||||
{...registerProfile('username')}
|
||||
error={personalErrors.username?.message}
|
||||
{...registerPersonal('username')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
@@ -358,14 +503,14 @@ export function ProfilePage() {
|
||||
<TextInput
|
||||
label={t('profile.fields.email')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={profileErrors.email?.message}
|
||||
{...registerProfile('email')}
|
||||
error={personalErrors.email?.message}
|
||||
{...registerPersonal('email')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.phone')}
|
||||
leftSection={<IconPhone size={18} />}
|
||||
error={profileErrors.phoneNumber?.message}
|
||||
{...registerProfile('phoneNumber')}
|
||||
error={personalErrors.phoneNumber?.message}
|
||||
{...registerPersonal('phoneNumber')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
@@ -374,7 +519,7 @@ export function ProfilePage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => resetProfile()}
|
||||
onClick={() => resetPersonal()}
|
||||
>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
@@ -391,6 +536,90 @@ export function ProfilePage() {
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Maritime Profile ---- */}
|
||||
<Tabs.Panel value="profile" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
{dataLoading ? (
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : !loadedProfile ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No profile found. Complete your profile setup first.
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>Maritime Profile</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Your professional maritime details
|
||||
</Text>
|
||||
<ProfileFormContent
|
||||
register={registerProfile}
|
||||
errors={profileErrors}
|
||||
setValue={profileSetValue}
|
||||
watch={profileWatch}
|
||||
trigger={profileTriggerValidation}
|
||||
professionsLoading={professionsLoading}
|
||||
professionOptions={professionOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSavingMaritime}
|
||||
leftSection={<IconDeviceFloppy size={18} />}
|
||||
>
|
||||
Save Profile
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Address ---- */}
|
||||
<Tabs.Panel value="address" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
{dataLoading ? (
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : !loadedAddress ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No address found. Complete your profile setup first.
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>Address & Contact</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Your identity documents, contact details and emergency contact
|
||||
</Text>
|
||||
<AddressFormContent
|
||||
register={registerAddress}
|
||||
errors={addressErrors}
|
||||
setValue={addressSetValue}
|
||||
watch={addressWatch}
|
||||
trigger={addressTriggerValidation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSavingAddress}
|
||||
leftSection={<IconDeviceFloppy size={18} />}
|
||||
>
|
||||
Save Address
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
|
||||
Reference in New Issue
Block a user