mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
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>
This commit is contained in:
co-authored by
Cursor
parent
f5b2193ba1
commit
66004a0fba
@@ -0,0 +1,357 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user