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:
estifanos
2026-08-08 07:32:33 +00:00
parent 0f7c1dba4b
commit 85c9930363
9 changed files with 160 additions and 62 deletions

View File

@@ -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) {

View 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;

View File

@@ -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">

View File

@@ -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">

View 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),
};
}

View File

@@ -247,6 +247,7 @@ export const am: Translations = {
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
subLocation: 'ንዑስ አካባቢ',
loading: 'አካባቢዎች በመጫን ላይ...',
loadFailed: 'አካባቢዎችን መጫን አልተቻለም',
},
support: {

View File

@@ -245,6 +245,7 @@ export const en = {
chooseFirst: 'Choose a location first',
subLocation: 'Sub-location',
loading: 'Loading locations...',
loadFailed: 'Could not load locations',
},
support: {

View File

@@ -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";

View 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();