mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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>
358 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|