manual improvement 2 & add telegram bot
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect, useState } from 'react';
|
||||
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 TextField from '@mui/material/TextField';
|
||||
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';
|
||||
@@ -32,6 +33,14 @@ export interface AddressFormProps {
|
||||
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 {
|
||||
@@ -50,136 +59,121 @@ function initialPin(initial?: AddressFormInitial): LatLng | null {
|
||||
* 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 initialTitle = initial?.title ?? '';
|
||||
const initialAddressLine = initial?.addressLine ?? '';
|
||||
const initialIsPrimary = initial?.isPrimary ?? false;
|
||||
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [region, setRegion] = useState<CascadingRegionValue>(() => initialRegion(initial));
|
||||
const [addressLine, setAddressLine] = useState(initialAddressLine);
|
||||
const [pin, setPin] = useState<LatLng | null>(() => initialPin(initial));
|
||||
const [isPrimary, setIsPrimary] = useState(initialIsPrimary);
|
||||
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
const [cityError, setCityError] = useState(false);
|
||||
const [lineError, setLineError] = useState(false);
|
||||
const [pinError, setPinError] = useState(false);
|
||||
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(() => {
|
||||
if (!onDirtyChange) return;
|
||||
const initialPinValue = initialPin(initial);
|
||||
const dirty =
|
||||
title !== initialTitle ||
|
||||
addressLine !== initialAddressLine ||
|
||||
isPrimary !== initialIsPrimary ||
|
||||
region.provinceId !== (initial?.provinceId ?? null) ||
|
||||
region.cityId !== (initial?.cityId ?? null) ||
|
||||
region.districtId !== (initial?.districtId ?? null) ||
|
||||
pin?.latitude !== initialPinValue?.latitude ||
|
||||
pin?.longitude !== initialPinValue?.longitude;
|
||||
onDirtyChange(dirty);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a stable prefill snapshot (the caller re-keys the form on edit-target change), not reactive state to track.
|
||||
}, [title, addressLine, isPrimary, region, pin]);
|
||||
|
||||
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;
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const submit = (values: AddressFormValues) => {
|
||||
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,
|
||||
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 (
|
||||
<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
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
|
||||
<CascadingRegionSelect
|
||||
value={region}
|
||||
onChange={(next) => {
|
||||
setRegion(next);
|
||||
if (cityError && next.cityId != null) setCityError(false);
|
||||
}}
|
||||
cityError={cityError}
|
||||
cityErrorText={t('city_required')}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
<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')}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={isPrimary} onChange={(event) => setIsPrimary(event.target.checked)} />}
|
||||
label={t('set_primary_toggle')}
|
||||
/>
|
||||
<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')}
|
||||
<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>
|
||||
) : null}
|
||||
<AppButton color="primary" variant="contained" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? tc('saving') : tc('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user