mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Refactor address handling in ProfilePage and AddressFormContent to improve name parsing and address payload structure
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user