feat: add i18n support and localized labels to certification and profession features commit

This commit is contained in:
mengstabketemaw
2026-06-30 15:57:25 +03:00
parent ef69448bbd
commit 9fd59f52ed
19 changed files with 593 additions and 354 deletions

View File

@@ -1,76 +0,0 @@
import { useState } from 'react';
import {
TextInput,
UnstyledButton,
rem,
type TextInputProps,
} from '@mantine/core';
export interface BilingualValue {
en: string;
am: string;
}
interface BilingualInputProps
extends Omit<TextInputProps, 'value' | 'onChange' | 'rightSection' | 'rightSectionWidth'> {
value: BilingualValue;
onChange: (value: BilingualValue) => void;
}
export function BilingualInput({
label,
value,
onChange,
required,
placeholder,
...rest
}: BilingualInputProps) {
const [lang, setLang] = useState<'en' | 'am'>('en');
const toggle = () => setLang((l) => (l === 'en' ? 'am' : 'en'));
return (
<TextInput
label={label}
required={required}
placeholder={placeholder ?? (lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ')}
value={value[lang]}
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
rightSection={
<UnstyledButton
onClick={toggle}
aria-label={`Switch to ${lang === 'en' ? 'Amharic' : 'English'}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(28),
height: rem(20),
borderRadius: rem(4),
fontSize: rem(10),
fontWeight: 700,
letterSpacing: '0.05em',
background:
lang === 'en'
? 'var(--mantine-color-blue-1)'
: 'var(--mantine-color-teal-1)',
color:
lang === 'en'
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-teal-7)',
cursor: 'pointer',
transition: 'background 150ms ease',
}}
>
{lang === 'en' ? 'EN' : 'AM'}
</UnstyledButton>
}
styles={{
input: {
paddingRight: rem(42),
},
}}
{...rest}
/>
);
}

View File

@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
interface LocationPickerProps {
value?: string;
onChange: (locationId: string | null) => void;
onChange?: (locationId: string | null) => void;
onChainChange?: (chain: Location[]) => void;
required?: boolean;
}
export function LocationPicker({ value, onChange, required }: 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 });
@@ -54,7 +55,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
current = current.parentId ? locMap.get(current.parentId) : undefined;
}
setSelectedChain(chain);
}, [value, locMap]);
onChainChange?.(chain);
}, [value, locMap, onChainChange]);
const currentLevelChildren = useMemo(() => {
const parentId =
@@ -89,7 +91,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
if (!id) {
const newChain = selectedChain.slice(0, -1);
setSelectedChain(newChain);
onChange(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
onChange?.(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
onChainChange?.(newChain);
return;
}
@@ -100,9 +103,10 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
newChain.push(loc);
setSelectedChain(newChain);
onChange(id);
onChange?.(id);
onChainChange?.(newChain);
},
[selectedChain, locMap, onChange, depth],
[selectedChain, locMap, onChange, onChainChange, depth],
);
const buildOptions = (levelIdx: number) => {

View File

@@ -1,6 +1,10 @@
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 { 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'),
@@ -25,6 +29,13 @@ export type AddressValues = z.infer<typeof addressSchema>;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
const LEVEL_TO_FIELD: Record<number, keyof AddressValues> = {
1: 'cityId',
2: 'subcityId',
3: 'woredaId',
4: 'kebeleId',
};
interface AddressFormContentProps {
register: UseFormRegister<AddressValues>;
errors: FieldErrors<AddressValues>;
@@ -40,6 +51,38 @@ export function AddressFormContent({
watch,
trigger,
}: AddressFormContentProps) {
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));
return map;
}, [locationTypes]);
const leafId = watch('kebeleId') || watch('woredaId') || watch('subcityId') || watch('cityId') || undefined;
const handleChainChange = useCallback(
(chain: Location[]) => {
if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
return;
}
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);
}
});
},
[setValue, typeLevelMap],
);
return (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
@@ -93,37 +136,11 @@ export function AddressFormContent({
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Region ID"
placeholder="Region UUID (optional)"
{...register('regionId')}
error={errors.regionId?.message}
/>
<TextInput
label="City ID"
placeholder="City UUID (optional)"
{...register('cityId')}
error={errors.cityId?.message}
/>
<TextInput
label="Subcity ID"
placeholder="Subcity UUID (optional)"
{...register('subcityId')}
error={errors.subcityId?.message}
/>
<TextInput
label="Woreda ID"
placeholder="Woreda UUID (optional)"
{...register('woredaId')}
error={errors.woredaId?.message}
/>
<TextInput
label="Kebele ID"
placeholder="Kebele UUID (optional)"
{...register('kebeleId')}
error={errors.kebeleId?.message}
/>
<LocationPicker
value={leafId}
onChainChange={handleChainChange}
/>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<TextInput
label="Street Address"
placeholder="Street name, house number"

View File

@@ -33,9 +33,8 @@ import {
IconUser,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';