diff --git a/apps/portal/src/app/features/location/components/LocationPicker.tsx b/apps/portal/src/app/features/location/components/LocationPicker.tsx index a68fb839a..3f9f2be26 100644 --- a/apps/portal/src/app/features/location/components/LocationPicker.tsx +++ b/apps/portal/src/app/features/location/components/LocationPicker.tsx @@ -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 ( + { + refetchTypes(); + refetchLocs(); + }} + /> + ); + } + const roots = childrenByParentId.get('__root__') ?? []; if (roots.length === 0) { diff --git a/apps/portal/src/app/features/profile/api/address-api.ts b/apps/portal/src/app/features/profile/api/address-api.ts new file mode 100644 index 000000000..28f85ecd2 --- /dev/null +++ b/apps/portal/src/app/features/profile/api/address-api.ts @@ -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({ + query: ({ profileId, body }) => ({ + url: `/addresss/profile/${profileId}`, + method: 'POST', + body, + }), + invalidatesTags: (_result, error) => (error ? [] : ['CurrentProfile']), + }), + }), + overrideExisting: false, + }); + +export const { useSaveMyAddressMutation } = addressApi; diff --git a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx index ddd198725..617a34751 100644 --- a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx @@ -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; -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 = { - 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 = { + 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(); - locationTypes.forEach((lt) => map.set(lt.id, lt.level)); + const typeFieldMap = useMemo(() => { + const map = new Map(); + 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} /> + Address - + + diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index f3d9b33bc..50e5c3550 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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(); - const [updateAddress] = useApiMutation(); + const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation(); const [loadedProfile, setLoadedProfile] = useState(null); const [loadedAddress, setLoadedAddress] = useState(null); - const [profileId, setProfileId] = useState(null); - const [addressId, setAddressId] = useState(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() { {dataLoading ? (
- ) : !loadedAddress ? ( - - No address found. Complete your profile setup first. - ) : (
diff --git a/apps/portal/src/app/features/profile/types/address.ts b/apps/portal/src/app/features/profile/types/address.ts new file mode 100644 index 000000000..4dedbbcba --- /dev/null +++ b/apps/portal/src/app/features/profile/types/address.ts @@ -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), + }; +} diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index f9bf62f46..36669d8c6 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -247,6 +247,7 @@ export const am: Translations = { chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ', subLocation: 'ንዑስ አካባቢ', loading: 'አካባቢዎች በመጫን ላይ...', + loadFailed: 'አካባቢዎችን መጫን አልተቻለም', }, support: { diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index f160308f5..634e60d87 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -245,6 +245,7 @@ export const en = { chooseFirst: 'Choose a location first', subLocation: 'Sub-location', loading: 'Loading locations...', + loadFailed: 'Could not load locations', }, support: { diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 17a8d057b..e713c8ee3 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -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"; diff --git a/libs/ui/src/lib/input/phone.ts b/libs/ui/src/lib/input/phone.ts new file mode 100644 index 000000000..895529bfc --- /dev/null +++ b/libs/ui/src/lib/input/phone.ts @@ -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();