Refactor address handling in ProfilePage and AddressFormContent to improve name parsing and address payload structure

This commit is contained in:
Estifo77
2026-08-08 11:58:08 +03:00
parent fbf948b3d3
commit afaea36ce2
4 changed files with 113 additions and 39 deletions

View File

@@ -5,7 +5,7 @@ const addressApi = baseApi
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
.injectEndpoints({
endpoints: (builder) => ({
/** Create-or-update the signed-in user's address. */
/** The profile-address endpoint creates or updates through POST. */
saveMyAddress: builder.mutation<unknown, { profileId: string; body: AddressPayload }>({
query: ({ profileId, body }) => ({
url: `/addresss/profile/${profileId}`,

View File

@@ -5,7 +5,7 @@ import { z } from 'zod';
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location } from '../../location/types/location';
import type { Location, LocationType } from '../../location/types/location';
export const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
@@ -17,7 +17,7 @@ export const addressSchema = z.object({
email: z.string().trim().email('Invalid email').optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subcityId: z.string().optional(),
subCityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().trim().optional(),
@@ -45,13 +45,24 @@ export const ID_TYPES = [
* 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',
};
function fieldsForLocationType(type: LocationType): Array<keyof AddressValues> {
// Location types have historically used variants such as SUBCITY,
// SUB_CITY, and SUB-CITY. Normalize both the code and display name so a
// selected sub-city is never dropped from the address payload.
const typeName = `${type.code} ${type.names.en}`
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
if (typeName.includes('SUBCITY')) return ['subCityId'];
if (typeName.includes('WOREDA')) return ['woredaId'];
if (typeName.includes('REGION')) return ['regionId'];
if (typeName.includes('CITY')) {
// The profile-completeness API treats the selected City as its region.
// Keep its conventional cityId too, as other profile consumers use it.
return ['regionId', 'cityId'];
}
return [];
}
interface AddressFormContentProps {
register: UseFormRegister<AddressValues>;
@@ -69,29 +80,30 @@ export function AddressFormContent({
trigger,
}: AddressFormContentProps) {
const { data: typesRes } = useGetLocationTypesQuery();
const locationTypes = typesRes?.items ?? [];
const locationTypes = typesRes?.items;
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);
const map = new Map<string, Array<keyof AddressValues>>();
locationTypes?.forEach((lt) => {
const fields = fieldsForLocationType(lt);
if (fields.length) map.set(lt.id, fields);
});
return map;
}, [locationTypes]);
const leafId = watch('woredaId') || watch('subcityId') || watch('cityId') || watch('regionId') || undefined;
const leafId = watch('woredaId') || watch('subCityId') || watch('cityId') || watch('regionId') || undefined;
const handleChainChange = useCallback(
(chain: Location[]) => {
setValue('regionId', '');
setValue('cityId', '');
setValue('subcityId', '');
setValue('subCityId', '');
setValue('woredaId', '');
chain.forEach((loc) => {
const field = typeFieldMap.get(loc.locationTypeId);
if (field) setValue(field, loc.id);
typeFieldMap.get(loc.locationTypeId)?.forEach((field) => {
setValue(field, loc.id);
});
});
},
[setValue, typeFieldMap],

View File

@@ -86,6 +86,19 @@ function getInitials(name: string, fallback: string) {
return letters.toUpperCase();
}
function splitProfileName(fullName: string) {
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
return { firstName, middleName, lastName: lastName.join(' ') };
}
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
return [firstName, middleName, lastName].filter(Boolean).join(' ');
}
function normalizeName(name: string) {
return name.trim().replace(/\s+/g, ' ');
}
function passwordScore(pw: string) {
if (!pw) return 0;
let score = 0;
@@ -177,11 +190,12 @@ export function ProfilePage() {
// already holds so the form does not flash empty on a refetch.
const currentProfile = resolvedProfile ?? storedProfile;
if (currentProfile) {
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
setLoadedProfile({
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
firstName: currentProfile.firstName || '',
middleName: currentProfile.middleName || '',
lastName: currentProfile.lastName || '',
firstName: accountName?.firstName || currentProfile.firstName || '',
middleName: accountName?.middleName || currentProfile.middleName || '',
lastName: accountName?.lastName || currentProfile.lastName || '',
gender: currentProfile.gender || '',
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
pob: currentProfile.pob || '',
@@ -200,8 +214,8 @@ export function ProfilePage() {
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
email: user?.email || '',
regionId: currentProfile.address?.regionId || '',
cityId: currentProfile.address?.cityId || '',
subcityId: currentProfile.address?.subCityId || '',
cityId: currentProfile.address?.cityId || currentProfile.address?.regionId || '',
subCityId: currentProfile.address?.subCityId || '',
woredaId: currentProfile.address?.woredaId || '',
streetAddress: currentProfile.address?.streetAddress || '',
postalAddress: currentProfile.address?.postalAddress || '',
@@ -222,7 +236,12 @@ export function ProfilePage() {
// ---- Personal form (auth user data) ----
const personalSchema = z.object({
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
nameEn: z
.string()
.refine(
(name) => Object.values(splitProfileName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' },
),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
@@ -251,16 +270,33 @@ export function ProfilePage() {
setIsSavingProfile(true);
try {
await updateTrigger({
url: '/auth/update-profile',
method: 'PATCH',
body: {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
name: { am: values.nameAm, en: values.nameEn },
},
}).unwrap();
const profileName = splitProfileName(values.nameEn);
const saves: Promise<unknown>[] = [
updateTrigger({
url: '/auth/update-profile',
method: 'PATCH',
body: {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
name: { am: values.nameAm, en: values.nameEn },
},
}).unwrap(),
];
// The Profile tab stores names separately as first/middle/last.
// Save those fields alongside the account's display name so either tab
// always describes the same person.
if (profileId) {
saves.push(
updateProfile({
url: `/profiles/${profileId}`,
method: 'PATCH',
body: profileName,
}).unwrap(),
);
}
await Promise.all(saves);
// Update the session from the values that were just accepted. The
// endpoint is allowed to return no body (or a response wrapper), so
@@ -274,6 +310,9 @@ export function ProfilePage() {
name: { am: values.nameAm, en: values.nameEn },
};
dispatch(setUser(updatedUser));
setLoadedProfile((current) =>
current ? { ...current, ...profileName } : current,
);
resetPersonal({
nameEn: updatedUser.name.en,
nameAm: updatedUser.name.am,
@@ -307,6 +346,13 @@ export function ProfilePage() {
const onSaveProfile = async (values: ProfileValues) => {
if (!profileId) return;
const fullName = formatProfileName(values);
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
notify.error('Profile name must match the name in the Personal tab.');
return;
}
setIsSavingMaritime(true);
try {
await updateProfile({
@@ -314,6 +360,7 @@ export function ProfilePage() {
method: 'PUT',
body: values,
}).unwrap();
setLoadedProfile({ ...values, pob: values.pob ?? '' });
// This endpoint doesn't invalidate the `CurrentProfile` tag (unlike
// the address save below) — without this, `missing` stays stale until
@@ -343,7 +390,10 @@ export function ProfilePage() {
const onSaveAddress = async (values: AddressValues) => {
if (!profileId) return;
try {
await saveMyAddress({ profileId, body: toAddressPayload(values) }).unwrap();
await saveMyAddress({
profileId,
body: toAddressPayload(values),
}).unwrap();
notify.success('Address saved');
} catch (e) {
handleError(e);

View File

@@ -8,6 +8,8 @@ export interface AddressPayload {
nationality: string;
regionId?: string;
cityId?: string;
subCityId?: string;
/** Legacy spelling still accepted by the address upsert endpoint. */
subcityId?: string;
woredaId?: string;
kebeleId?: string;
@@ -25,15 +27,25 @@ export interface AddressPayload {
/** 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);
const regionId = clean(values.regionId);
// The location service uses the selected City for the profile's region.
// Send that same id as cityId too, because profile completeness requires
// both fields even when the location tree has no separate region node.
const cityId = clean(values.cityId) ?? regionId;
const subCityId = clean(values.subCityId);
return {
idType: values.idType.trim(),
idNumber: values.idNumber.trim(),
// Form holds the alpha-2 code (CountrySelect); API stores the full name.
nationality: getCountryName(values.nationality),
regionId: clean(values.regionId),
cityId: clean(values.cityId),
subcityId: clean(values.subcityId),
regionId,
cityId,
// `subCityId` is the profile field. The address endpoint has accepted
// the older lower-case spelling too, so submit the same selected id under
// both names until its DTO is fully aligned.
subCityId,
subcityId: subCityId,
woredaId: clean(values.woredaId),
kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId
streetAddress: clean(values.streetAddress),