'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 = ({ initial, submitting = false, onSubmit, onCancel, onDirtyChange }) => { const t = useTranslations('address'); const tc = useTranslations('common'); const form = useForm({ 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 ( 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. */} name="region" rules={{ validate: (value) => (value as CascadingRegionValue)?.cityId != null }} > {({ field, hasError }) => ( )} name="pin" rules={{ validate: (value) => value != null }}> {({ field, hasError }) => ( )} 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 /> name="isPrimary"> {({ field }) => ( field.onChange(event.target.checked)} />} label={t('set_primary_toggle')} /> )} {onCancel ? ( {tc('cancel')} ) : null} {submitting ? tc('saving') : tc('save')} ); }; export default AddressForm;