181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
'use client';
|
|
import { FunctionComponent, useEffect } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
import Stack from '@mui/material/Stack';
|
|
import Switch from '@mui/material/Switch';
|
|
import { AppButton } from '@/components/common';
|
|
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
|
|
import { cityCentroid } from '@/services/geography/constants';
|
|
import type { CreateAddressInput, LatLng } from '@/services/addresses/types';
|
|
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
|
|
import AddressMapPicker from './AddressMapPicker';
|
|
|
|
/** Prefill for edit (or empty for add). `provinceId` is needed to prefill the cascade. */
|
|
export interface AddressFormInitial {
|
|
title?: string;
|
|
provinceId?: number | null;
|
|
cityId?: number | null;
|
|
districtId?: number | null;
|
|
addressLine?: string | null;
|
|
latitude?: number | null;
|
|
longitude?: number | null;
|
|
isPrimary?: boolean;
|
|
}
|
|
|
|
export interface AddressFormProps {
|
|
initial?: AddressFormInitial;
|
|
submitting?: boolean;
|
|
onSubmit: (input: CreateAddressInput) => void;
|
|
onCancel?: () => void;
|
|
/** Reports whether any field differs from `initial` — drives a host `FormDialogShell`'s discard-confirm. */
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
}
|
|
|
|
interface AddressFormValues {
|
|
title: string;
|
|
region: CascadingRegionValue;
|
|
pin: LatLng | null;
|
|
addressLine: string;
|
|
isPrimary: boolean;
|
|
}
|
|
|
|
// Only prefill the region when we have the province — a city id without its province can't drive
|
|
// the (per-province) city query, so the cascade starts fresh instead of showing a dead value.
|
|
function initialRegion(initial?: AddressFormInitial): CascadingRegionValue {
|
|
if (initial?.provinceId == null) return { provinceId: null, cityId: null, districtId: null };
|
|
return { provinceId: initial.provinceId, cityId: initial.cityId ?? null, districtId: initial.districtId ?? null };
|
|
}
|
|
|
|
function initialPin(initial?: AddressFormInitial): LatLng | null {
|
|
return initial?.latitude != null && initial?.longitude != null
|
|
? { latitude: initial.latitude, longitude: initial.longitude }
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* The add/edit address form body — the cascading region dropdowns + the map-pin picker + a
|
|
* title and street address + a "set as primary" toggle. Validation: **city required**, **pin
|
|
* required** (surfaced inline), title + street required, **district optional**. Emits a
|
|
* `CreateAddressInput` carrying the picked coordinates. Reused for create and edit.
|
|
*
|
|
* The region cascade and the map pin are composite values (`{province, city, district}` and a
|
|
* lat/lng pair), which is exactly why they belong in form state rather than beside it: as
|
|
* react-hook-form fields they carry their own required-rule and their own error, instead of the four
|
|
* parallel `xxxError` booleans the submit handler used to set by hand.
|
|
* @component AddressForm
|
|
*/
|
|
const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting = false, onSubmit, onCancel, onDirtyChange }) => {
|
|
const t = useTranslations('address');
|
|
const tc = useTranslations('common');
|
|
|
|
const form = useForm<AddressFormValues>({
|
|
mode: 'onTouched',
|
|
defaultValues: {
|
|
title: initial?.title ?? '',
|
|
region: initialRegion(initial),
|
|
pin: initialPin(initial),
|
|
addressLine: initial?.addressLine ?? '',
|
|
isPrimary: initial?.isPrimary ?? false,
|
|
},
|
|
});
|
|
const { control, handleSubmit, formState } = form;
|
|
const { isDirty } = formState;
|
|
const region = useWatch({ control, name: 'region' });
|
|
|
|
useEffect(() => {
|
|
onDirtyChange?.(isDirty);
|
|
}, [isDirty, onDirtyChange]);
|
|
|
|
const submit = (values: AddressFormValues) => {
|
|
onSubmit({
|
|
title: values.title.trim(),
|
|
provinceId: values.region.provinceId as number,
|
|
cityId: values.region.cityId as number,
|
|
districtId: values.region.districtId,
|
|
addressLine: values.addressLine.trim(),
|
|
latitude: (values.pin as LatLng).latitude,
|
|
longitude: (values.pin as LatLng).longitude,
|
|
isPrimary: values.isPrimary,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<FormProvider {...form}>
|
|
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2.5 }}>
|
|
<RhfTextField<AddressFormValues>
|
|
name="title"
|
|
label={t('title_label')}
|
|
placeholder={t('title_placeholder')}
|
|
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('title_required') }}
|
|
fullWidth
|
|
/>
|
|
|
|
{/* Message-less rules on purpose: both controls below already render their own error copy, so
|
|
`RhfControlGroup` must flag the field without printing a second identical line. */}
|
|
<RhfControlGroup<AddressFormValues>
|
|
name="region"
|
|
rules={{ validate: (value) => (value as CascadingRegionValue)?.cityId != null }}
|
|
>
|
|
{({ field, hasError }) => (
|
|
<CascadingRegionSelect
|
|
value={field.value as CascadingRegionValue}
|
|
onChange={field.onChange}
|
|
cityError={hasError}
|
|
cityErrorText={t('city_required')}
|
|
/>
|
|
)}
|
|
</RhfControlGroup>
|
|
|
|
<RhfControlGroup<AddressFormValues> name="pin" rules={{ validate: (value) => value != null }}>
|
|
{({ field, hasError }) => (
|
|
<AddressMapPicker
|
|
value={field.value as LatLng | null}
|
|
onChange={field.onChange}
|
|
center={cityCentroid(region.cityId)}
|
|
helperText={t('map_hint')}
|
|
latLabel={t('map_lat')}
|
|
lngLabel={t('map_lng')}
|
|
error={hasError}
|
|
errorText={t('map_required')}
|
|
/>
|
|
)}
|
|
</RhfControlGroup>
|
|
|
|
<RhfTextField<AddressFormValues>
|
|
name="addressLine"
|
|
label={t('line_label')}
|
|
helperText={t('line_hint')}
|
|
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('line_required') }}
|
|
multiline
|
|
minRows={2}
|
|
fullWidth
|
|
/>
|
|
|
|
<RhfControlGroup<AddressFormValues> name="isPrimary">
|
|
{({ field }) => (
|
|
<FormControlLabel
|
|
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
|
|
label={t('set_primary_toggle')}
|
|
/>
|
|
)}
|
|
</RhfControlGroup>
|
|
|
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
|
{onCancel ? (
|
|
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
|
|
{tc('cancel')}
|
|
</AppButton>
|
|
) : null}
|
|
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
|
|
{submitting ? tc('saving') : tc('save')}
|
|
</AppButton>
|
|
</Stack>
|
|
</Stack>
|
|
</FormProvider>
|
|
);
|
|
};
|
|
|
|
export default AddressForm;
|