Files
dashboards/apps/customer/src/components/AddressFormModal.tsx
T
Alireza HassaniandCursor 66004a0fba Add FA/EN locale, home activity charts, and theme-aware polish.
Ship shared LocaleProvider, business/customer i18n, branding defaultLocale, curated home tiles with dual 30-day charts, and chart/page aura tokens; refresh PROJECT_CONTEXT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 09:35:27 +03:30

358 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import {
getLocationOptionLabel,
matchCityByName,
matchProvinceByName,
useLocale,
useToast,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createAddress,
updateAddress,
type UserAddress,
} from '../services/addressService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
type CityOption,
} from '../services/citiesService'
import modalStyles from './VariationsModal.module.css'
import panelStyles from './checkout/CheckoutAddAddressPanel.module.css'
import styles from './AddressFormModal.module.css'
interface AddressFormModalProps {
open: boolean
address: UserAddress | null
onClose: () => void
onSaved: (address: UserAddress) => void
}
const ANIMATION_MS = 220
export function AddressFormModal({ open, address, onClose, onSaved }: AddressFormModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [formKey, setFormKey] = useState(0)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setFormKey((key) => key + 1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
const isEdit = Boolean(address?.id)
return createPortal(
<div
className={`${modalStyles.overlay} ${styles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="address-form-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="address-form-title" className={modalStyles.title}>
{isEdit ? t('addresses.modal.editTitle') : t('addresses.modal.addTitle')}
</h2>
<p className={modalStyles.subtitle}>{t('addresses.modal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
<AddressFormFields
key={formKey}
address={address}
onCancel={onClose}
onSaved={(saved) => {
showToast(
isEdit ? t('addresses.toast.updated') : t('addresses.toast.created'),
'success',
)
onSaved(saved)
onClose()
}}
/>
</div>
</div>
</div>,
document.body,
)
}
function AddressFormFields({
address,
onCancel,
onSaved,
}: {
address: UserAddress | null
onCancel: () => void
onSaved: (address: UserAddress) => void
}) {
const t = useT()
const { locale } = useLocale()
const [provinces, setProvinces] = useState<CityOption[]>([])
const [cities, setCities] = useState<CityOption[]>([])
const [loadingLocations, setLoadingLocations] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [label, setLabel] = useState(address?.label ?? '')
const [provinceSlug, setProvinceSlug] = useState('')
const [city, setCity] = useState('')
const [street, setStreet] = useState(address?.address ?? '')
const [postalCode, setPostalCode] = useState(address?.postalCode ?? '')
const [landline, setLandline] = useState(address?.landline ?? '')
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoadingLocations(true)
try {
const items = await listIranProvinces(controller.signal)
if (controller.signal.aborted) return
setProvinces(items)
if (!address) return
const province = matchProvinceByName(address.province, items)
if (!province) return
setProvinceSlug(province.slug)
const cityItems = await listCitiesByProvinceSlug(province.slug, controller.signal)
if (controller.signal.aborted) return
setCities(cityItems)
const matchedCity = matchCityByName(address.city, cityItems)
setCity(
matchedCity ? getLocationOptionLabel(matchedCity, locale) : address.city,
)
} catch {
if (!controller.signal.aborted) setError(t('addresses.error.loadLocations'))
} finally {
if (!controller.signal.aborted) setLoadingLocations(false)
}
}
void load()
return () => controller.abort()
}, [address, locale, t])
async function handleProvinceChange(slug: string) {
setProvinceSlug(slug)
setCity('')
setCities([])
if (!slug) return
try {
const items = await listCitiesByProvinceSlug(slug)
setCities(items)
} catch {
setError(t('addresses.error.loadCities'))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const province = provinces.find((item) => item.slug === provinceSlug)
if (!label.trim() || !province || !city.trim() || !street.trim()) {
setError(t('addresses.error.incompleteForm'))
return
}
const payload = {
label: label.trim(),
province: getLocationOptionLabel(province, locale),
city: city.trim(),
address: street.trim(),
postalCode: postalCode.trim() || undefined,
landline: landline.trim() || undefined,
}
setSaving(true)
try {
const result = address?.id
? await updateAddress(address.id, payload)
: await createAddress(payload)
onSaved(result.address)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('addresses.error.save'))
} finally {
setSaving(false)
}
}
return (
<form
className={[panelStyles.form, panelStyles.formInModal].join(' ')}
onSubmit={(e) => void handleSubmit(e)}
>
{error && (
<div className={panelStyles.error} role="alert">
{error}
</div>
)}
<div className={panelStyles.fieldRowTriple}>
<div className={panelStyles.field}>
<label htmlFor="address-label">{t('addresses.label')}</label>
<input
id="address-label"
type="text"
value={label}
disabled={saving}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('addresses.labelPlaceholder')}
/>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-province">{t('addresses.province')}</label>
<select
id="address-province"
value={provinceSlug}
disabled={loadingLocations || saving}
onChange={(e) => void handleProvinceChange(e.target.value)}
>
<option value="">{t('addresses.selectProvince')}</option>
{provinces.map((province) => (
<option key={province.id} value={province.slug}>
{getLocationOptionLabel(province, locale)}
</option>
))}
</select>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-city">{t('addresses.city')}</label>
<select
id="address-city"
value={city}
disabled={!provinceSlug || saving}
onChange={(e) => setCity(e.target.value)}
>
<option value="">{t('addresses.selectCity')}</option>
{cities.map((item) => (
<option key={item.id} value={getLocationOptionLabel(item, locale)}>
{getLocationOptionLabel(item, locale)}
</option>
))}
</select>
</div>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-street">{t('addresses.address')}</label>
<input
id="address-street"
type="text"
value={street}
disabled={saving}
onChange={(e) => setStreet(e.target.value)}
placeholder={t('addresses.streetPlaceholder')}
/>
</div>
<div className={panelStyles.fieldRow}>
<div className={panelStyles.field}>
<label htmlFor="address-postal">
{t('addresses.postalCode')}
<span className={panelStyles.optionalMark}> {t('addresses.optional')}</span>
</label>
<input
id="address-postal"
type="text"
inputMode="numeric"
value={postalCode}
disabled={saving}
onChange={(e) => setPostalCode(e.target.value)}
placeholder={t('addresses.postalPlaceholder')}
dir="ltr"
/>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-landline">
{t('addresses.landline')}
<span className={panelStyles.optionalMark}> {t('addresses.optional')}</span>
</label>
<input
id="address-landline"
type="tel"
value={landline}
disabled={saving}
onChange={(e) => setLandline(e.target.value)}
placeholder={t('addresses.landlinePlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={panelStyles.formActions}>
<button
type="button"
className={panelStyles.cancelBtn}
onClick={onCancel}
disabled={saving}
>
{t('addresses.cancel')}
</button>
<button type="submit" className={panelStyles.saveBtn} disabled={saving}>
{saving ? t('addresses.saving') : t('addresses.saveOne')}
</button>
</div>
</form>
)
}