mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 01:22:49 +00:00
Add address management features and improve validation
- Implement saveMyAddress mutation for updating user addresses. - Enhance address validation schema with additional fields. - Add error handling for location loading in LocationPicker. - Update translations for loading errors in English and Amharic. - Introduce phone number validation for Ethiopian formats. - Refactor AddressFormContent to accommodate new address structure.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core';
|
||||
import { ErrorState } from '@ema-platform/ui';
|
||||
import { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location, LocationType } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,8 +14,8 @@ interface LocationPickerProps {
|
||||
|
||||
export function LocationPicker({ value, onChange, onChainChange, required }: LocationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
|
||||
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
||||
const { data: locsRes, isLoading: locsLoading, isError: locsError, refetch: refetchLocs } = useGetLocationsQuery({ take: 10000 });
|
||||
|
||||
const locationTypes = typesRes?.items ?? [];
|
||||
const allLocations = locsRes?.items ?? [];
|
||||
@@ -160,6 +161,18 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
);
|
||||
}
|
||||
|
||||
if (typesError || locsError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={t('location.loadFailed')}
|
||||
onRetry={() => {
|
||||
refetchTypes();
|
||||
refetchLocs();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const roots = childrenByParentId.get('__root__') ?? [];
|
||||
|
||||
if (roots.length === 0) {
|
||||
|
||||
21
apps/portal/src/app/features/profile/api/address-api.ts
Normal file
21
apps/portal/src/app/features/profile/api/address-api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type { AddressPayload } from '../types/address';
|
||||
|
||||
const addressApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
/** Create-or-update the signed-in user's address. */
|
||||
saveMyAddress: builder.mutation<unknown, { profileId: string; body: AddressPayload }>({
|
||||
query: ({ profileId, body }) => ({
|
||||
url: `/addresss/profile/${profileId}`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_result, error) => (error ? [] : ['CurrentProfile']),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useSaveMyAddressMutation } = addressApi;
|
||||
@@ -2,40 +2,52 @@ import { useCallback, useMemo } from 'react';
|
||||
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { ethiopianPhone, optionalEthiopianPhone } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
||||
import type { Location } from '../../location/types/location';
|
||||
|
||||
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('')),
|
||||
idNumber: z.string().trim().min(1, 'Enter ID number'),
|
||||
nationality: z.string().trim().min(1, 'Enter nationality'),
|
||||
primaryPhoneNumber: ethiopianPhone,
|
||||
secondaryPhoneNumber: optionalEthiopianPhone,
|
||||
email: z.string().trim().email('Invalid email').optional().or(z.literal('')),
|
||||
website: z.string().trim().url('Enter a valid URL').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(),
|
||||
streetAddress: z.string().trim().optional(),
|
||||
postalAddress: z.string().trim().optional(),
|
||||
// Emergency contact is collected but never required — leaving it blank must
|
||||
// not stop an applicant moving on.
|
||||
emergencyContactName: z.string().optional(),
|
||||
emergencyContactPhone: z.string().optional(),
|
||||
emergencyContactRelation: z.string().optional(),
|
||||
emergencyContactName: z.string().trim().optional(),
|
||||
emergencyContactPhone: optionalEthiopianPhone,
|
||||
emergencyContactRelation: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type AddressValues = z.infer<typeof addressSchema>;
|
||||
|
||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
||||
export const ID_TYPES = [
|
||||
{ value: 'KEBELE_ID', label: 'Kebele Id' },
|
||||
{ value: 'PASSPORT', label: 'Passport' },
|
||||
{ value: 'NID', label: 'National Id' },
|
||||
] as const;
|
||||
|
||||
const LEVEL_TO_FIELD: Record<number, keyof AddressValues> = {
|
||||
1: 'cityId',
|
||||
2: 'subcityId',
|
||||
3: 'woredaId',
|
||||
4: 'kebeleId',
|
||||
/**
|
||||
* Location types are data-driven rows (no fixed depth), so the chain is
|
||||
* mapped to a field by type *code* rather than by numeric level — this
|
||||
* resolves correctly whether or not a COUNTRY type sits above REGION.
|
||||
*/
|
||||
const FIELD_BY_CODE: Record<string, keyof AddressValues> = {
|
||||
REGION: 'regionId',
|
||||
CITY: 'cityId',
|
||||
SUBCITY: 'subcityId',
|
||||
SUB_CITY: 'subcityId',
|
||||
WOREDA: 'woredaId',
|
||||
};
|
||||
|
||||
interface AddressFormContentProps {
|
||||
@@ -56,33 +68,30 @@ export function AddressFormContent({
|
||||
const { data: typesRes } = useGetLocationTypesQuery();
|
||||
const locationTypes = typesRes?.items ?? [];
|
||||
|
||||
const typeLevelMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
locationTypes.forEach((lt) => map.set(lt.id, lt.level));
|
||||
const typeFieldMap = useMemo(() => {
|
||||
const map = new Map<string, keyof AddressValues>();
|
||||
locationTypes.forEach((lt) => {
|
||||
const field = FIELD_BY_CODE[lt.code.toUpperCase()];
|
||||
if (field) map.set(lt.id, field);
|
||||
});
|
||||
return map;
|
||||
}, [locationTypes]);
|
||||
|
||||
const leafId = watch('kebeleId') || watch('woredaId') || watch('subcityId') || watch('cityId') || undefined;
|
||||
const leafId = watch('woredaId') || watch('subcityId') || watch('cityId') || watch('regionId') || undefined;
|
||||
|
||||
const handleChainChange = useCallback(
|
||||
(chain: Location[]) => {
|
||||
if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('regionId', '');
|
||||
setValue('cityId', '');
|
||||
setValue('subcityId', '');
|
||||
setValue('woredaId', '');
|
||||
setValue('kebeleId', '');
|
||||
|
||||
chain.forEach((loc) => {
|
||||
const level = typeLevelMap.get(loc.locationTypeId);
|
||||
if (level && LEVEL_TO_FIELD[level]) {
|
||||
setValue(LEVEL_TO_FIELD[level], loc.id);
|
||||
}
|
||||
const field = typeFieldMap.get(loc.locationTypeId);
|
||||
if (field) setValue(field, loc.id);
|
||||
});
|
||||
},
|
||||
[setValue, typeLevelMap],
|
||||
[setValue, typeFieldMap],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -92,7 +101,7 @@ export function AddressFormContent({
|
||||
label="ID Type"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={[...ID_TYPES]}
|
||||
data={ID_TYPES}
|
||||
error={errors.idType?.message}
|
||||
value={watch('idType')}
|
||||
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
|
||||
@@ -133,15 +142,18 @@ export function AddressFormContent({
|
||||
{...register('email')}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Website"
|
||||
placeholder="https://example.com"
|
||||
{...register('website')}
|
||||
error={errors.website?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
</Text>
|
||||
<LocationPicker
|
||||
value={leafId}
|
||||
onChainChange={handleChainChange}
|
||||
/>
|
||||
<LocationPicker value={leafId} onChainChange={handleChainChange} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||
<TextInput
|
||||
label="Street Address"
|
||||
@@ -149,6 +161,12 @@ export function AddressFormContent({
|
||||
{...register('streetAddress')}
|
||||
error={errors.streetAddress?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Postal Address"
|
||||
placeholder="P.O. Box"
|
||||
{...register('postalAddress')}
|
||||
error={errors.postalAddress?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
addressSchema,
|
||||
type AddressValues,
|
||||
} from '../components/AddressFormContent';
|
||||
import { useSaveMyAddressMutation } from '../api/address-api';
|
||||
import { toAddressPayload } from '../types/address';
|
||||
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
@@ -111,7 +113,6 @@ export function ProfilePage() {
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
||||
const [isSavingAddress, setIsSavingAddress] = useState(false);
|
||||
|
||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
@@ -148,18 +149,17 @@ export function ProfilePage() {
|
||||
// the deleted setup wizard ever wrote, so it rendered an empty form forever
|
||||
// for anyone who signed up after the wizard was removed.
|
||||
const {
|
||||
profileId,
|
||||
profile: resolvedProfile,
|
||||
isLoading: profileResolving,
|
||||
completeness,
|
||||
missing,
|
||||
} = useCurrentProfile();
|
||||
const [updateProfile] = useApiMutation<unknown>();
|
||||
const [updateAddress] = useApiMutation<unknown>();
|
||||
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
|
||||
|
||||
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
||||
const [profileId, setProfileId] = useState<string | null>(null);
|
||||
const [addressId, setAddressId] = useState<string | null>(null);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
|
||||
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
|
||||
@@ -184,7 +184,6 @@ export function ProfilePage() {
|
||||
// already holds so the form does not flash empty on a refetch.
|
||||
const currentProfile = resolvedProfile ?? storedProfile;
|
||||
if (currentProfile) {
|
||||
setProfileId(currentProfile.id);
|
||||
setLoadedProfile({
|
||||
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
||||
firstName: currentProfile.firstName || '',
|
||||
@@ -197,7 +196,6 @@ export function ProfilePage() {
|
||||
});
|
||||
|
||||
if (currentProfile.address) {
|
||||
setAddressId(currentProfile.address.id);
|
||||
setLoadedAddress({
|
||||
idType: currentProfile.address.idType || '',
|
||||
idNumber: currentProfile.address.idNumber || '',
|
||||
@@ -205,11 +203,11 @@ export function ProfilePage() {
|
||||
primaryPhoneNumber: currentProfile.address.primaryPhoneNumber || '',
|
||||
secondaryPhoneNumber: currentProfile.address.secondaryPhoneNumber || '',
|
||||
email: currentProfile.address.email || '',
|
||||
website: currentProfile.address.website || '',
|
||||
regionId: currentProfile.address.regionId || '',
|
||||
cityId: currentProfile.address.cityId || '',
|
||||
subcityId: currentProfile.address.subCityId || '',
|
||||
woredaId: currentProfile.address.woredaId || '',
|
||||
kebeleId: currentProfile.address.kebeleId || '',
|
||||
streetAddress: currentProfile.address.streetAddress || '',
|
||||
postalAddress: currentProfile.address.postalAddress || '',
|
||||
emergencyContactName: currentProfile.address.emergencyContactName || '',
|
||||
@@ -339,23 +337,12 @@ export function ProfilePage() {
|
||||
});
|
||||
|
||||
const onSaveAddress = async (values: AddressValues) => {
|
||||
if (!addressId) return;
|
||||
setIsSavingAddress(true);
|
||||
if (!profileId) return;
|
||||
try {
|
||||
await updateAddress({
|
||||
url: `/addresss/${addressId}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...values,
|
||||
postalAddess: values.postalAddress,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Address updated');
|
||||
await saveMyAddress({ profileId, body: toAddressPayload(values) }).unwrap();
|
||||
notify.success('Address saved');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -675,10 +662,6 @@ export function ProfilePage() {
|
||||
<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">
|
||||
|
||||
47
apps/portal/src/app/features/profile/types/address.ts
Normal file
47
apps/portal/src/app/features/profile/types/address.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AddressValues } from '../components/AddressFormContent';
|
||||
|
||||
/** Exact request body of `POST /addresss/profile/{profileId}`. */
|
||||
export interface AddressPayload {
|
||||
idType: string;
|
||||
idNumber: string;
|
||||
nationality: string;
|
||||
regionId?: string;
|
||||
cityId?: string;
|
||||
subcityId?: string;
|
||||
woredaId?: string;
|
||||
kebeleId?: string;
|
||||
streetAddress?: string;
|
||||
primaryPhoneNumber: string;
|
||||
secondaryPhoneNumber?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
postalAddress?: string;
|
||||
emergencyContactName?: string;
|
||||
emergencyContactPhone?: string;
|
||||
emergencyContactRelation?: string;
|
||||
}
|
||||
|
||||
/** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */
|
||||
export function toAddressPayload(values: AddressValues): AddressPayload {
|
||||
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
|
||||
|
||||
return {
|
||||
idType: values.idType.trim(),
|
||||
idNumber: values.idNumber.trim(),
|
||||
nationality: values.nationality.trim(),
|
||||
regionId: clean(values.regionId),
|
||||
cityId: clean(values.cityId),
|
||||
subcityId: clean(values.subcityId),
|
||||
woredaId: clean(values.woredaId),
|
||||
kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId
|
||||
streetAddress: clean(values.streetAddress),
|
||||
primaryPhoneNumber: values.primaryPhoneNumber,
|
||||
secondaryPhoneNumber: clean(values.secondaryPhoneNumber),
|
||||
email: clean(values.email),
|
||||
website: clean(values.website),
|
||||
postalAddress: clean(values.postalAddress),
|
||||
emergencyContactName: clean(values.emergencyContactName),
|
||||
emergencyContactPhone: clean(values.emergencyContactPhone),
|
||||
emergencyContactRelation: clean(values.emergencyContactRelation),
|
||||
};
|
||||
}
|
||||
@@ -247,6 +247,7 @@ export const am: Translations = {
|
||||
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
|
||||
subLocation: 'ንዑስ አካባቢ',
|
||||
loading: 'አካባቢዎች በመጫን ላይ...',
|
||||
loadFailed: 'አካባቢዎችን መጫን አልተቻለም',
|
||||
},
|
||||
|
||||
support: {
|
||||
|
||||
@@ -245,6 +245,7 @@ export const en = {
|
||||
chooseFirst: 'Choose a location first',
|
||||
subLocation: 'Sub-location',
|
||||
loading: 'Loading locations...',
|
||||
loadFailed: 'Could not load locations',
|
||||
},
|
||||
|
||||
support: {
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./lib/layout/LanguageSwitcher";
|
||||
export * from "./lib/layout/PageHeader";
|
||||
export * from "./lib/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
export * from "./lib/data/useServerTable";
|
||||
|
||||
13
libs/ui/src/lib/input/phone.ts
Normal file
13
libs/ui/src/lib/input/phone.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
|
||||
export const ethiopianPhone = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
|
||||
.refine((v) => /^\+2519\d{8}$/.test(v), {
|
||||
message: 'Enter a valid phone number (+2519xxxxxxxx)',
|
||||
});
|
||||
|
||||
/** Same rules, but blank is allowed. */
|
||||
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
|
||||
Reference in New Issue
Block a user