Files
baya-monorepo/client/src/components/geography/AddressForm.tsx
T
2026-07-17 14:00:22 +03:30

165 lines
5.7 KiB
TypeScript

'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import { AppButton } from '@/components/common';
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;
}
// 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.
* @component AddressForm
*/
const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting = false, onSubmit, onCancel }) => {
const t = useTranslations('address');
const tc = useTranslations('common');
const [title, setTitle] = useState(initial?.title ?? '');
const [region, setRegion] = useState<CascadingRegionValue>(() => initialRegion(initial));
const [addressLine, setAddressLine] = useState(initial?.addressLine ?? '');
const [pin, setPin] = useState<LatLng | null>(() => initialPin(initial));
const [isPrimary, setIsPrimary] = useState(initial?.isPrimary ?? false);
const [titleError, setTitleError] = useState(false);
const [cityError, setCityError] = useState(false);
const [lineError, setLineError] = useState(false);
const [pinError, setPinError] = useState(false);
const handleSubmit = () => {
const titleInvalid = title.trim().length === 0;
const cityInvalid = region.cityId == null;
const lineInvalid = addressLine.trim().length === 0;
const pinInvalid = pin == null;
setTitleError(titleInvalid);
setCityError(cityInvalid);
setLineError(lineInvalid);
setPinError(pinInvalid);
if (titleInvalid || cityInvalid || lineInvalid || pinInvalid) return;
onSubmit({
title: title.trim(),
provinceId: region.provinceId as number,
cityId: region.cityId as number,
districtId: region.districtId,
addressLine: addressLine.trim(),
latitude: pin!.latitude,
longitude: pin!.longitude,
isPrimary,
});
};
return (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('title_label')}
placeholder={t('title_placeholder')}
value={title}
onChange={(event) => {
setTitle(event.target.value);
if (titleError) setTitleError(false);
}}
error={titleError}
helperText={titleError ? t('title_required') : undefined}
fullWidth
/>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
}}
cityError={cityError}
cityErrorText={t('city_required')}
/>
<AddressMapPicker
value={pin}
onChange={(next) => {
setPin(next);
if (pinError) setPinError(false);
}}
center={cityCentroid(region.cityId)}
helperText={t('map_hint')}
latLabel={t('map_lat')}
lngLabel={t('map_lng')}
error={pinError}
errorText={t('map_required')}
/>
<TextField
label={t('line_label')}
value={addressLine}
onChange={(event) => {
setAddressLine(event.target.value);
if (lineError) setLineError(false);
}}
error={lineError}
helperText={lineError ? t('line_required') : t('line_hint')}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Switch checked={isPrimary} onChange={(event) => setIsPrimary(event.target.checked)} />}
label={t('set_primary_toggle')}
/>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" onClick={handleSubmit} disabled={submitting}>
{submitting ? tc('saving') : tc('save')}
</AppButton>
</Stack>
</Stack>
);
};
export default AddressForm;