canina/frontend/application/components/AddressModal.tsx
2026-08-07 07:39:26 +03:30

333 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, MapPin, Check, Save } from "lucide-react";
import { cn } from "../lib/utils";
import { Address } from "../lib/store/userStore";
import { IRAN_PROVINCES, PROVINCE_CITIES } from "../lib/data/provinces";
import SearchableSelect from "./SearchableSelect";
import { toast } from "sonner";
interface AddressModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (address: Address) => void;
editingAddress?: Address | null;
}
export default function AddressModal({ isOpen, onClose, onSave, editingAddress }: AddressModalProps) {
const [formData, setFormData] = useState<Omit<Address, "id">>({
title: "",
receptorName: "",
phone: "",
province: "",
city: "",
detail: "",
zipCode: "",
isDefault: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
Promise.resolve().then(() => {
if (editingAddress) {
setFormData({
title: editingAddress.title,
receptorName: editingAddress.receptorName,
phone: editingAddress.phone,
province: editingAddress.province,
city: editingAddress.city,
detail: editingAddress.detail,
zipCode: editingAddress.zipCode,
isDefault: editingAddress.isDefault
});
} else {
setFormData({
title: "",
receptorName: "",
phone: "",
province: "",
city: "",
detail: "",
zipCode: "",
isDefault: false
});
}
setErrors({});
setIsSubmitting(false);
});
}, [editingAddress, isOpen]);
const normalizeDigits = (str: string) => {
const persianDigits = [/۰/g, /۱/g, /۲/g, /۳/g, /۴/g, /۵/g, /۶/g, /۷/g, /۸/g, /۹/g];
const arabicDigits = [/٠/g, /١/g, /٢/g, /٣/g, /٤/g, /٥/g, /٦/g, /٧/g, /٨/g, /٩/g];
for (let i = 0; i < 10; i++) {
str = str.replace(persianDigits[i], i.toString()).replace(arabicDigits[i], i.toString());
}
return str;
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.title) newErrors.title = "لطفاً عنوان آدرس را وارد کنید";
if (!formData.receptorName) newErrors.receptorName = "نام گیرنده را وارد کنید";
const normalizedPhone = normalizeDigits(formData.phone);
if (!normalizedPhone) {
newErrors.phone = "شماره تماس الزامی است";
} else if (!/^09\d{9}$/.test(normalizedPhone)) {
newErrors.phone = "شماره موبایل وارد شده معتبر نیست";
}
if (!formData.province) newErrors.province = "استان را مشخص کنید";
if (!formData.city) newErrors.city = "شهر را مشخص کنید";
if (!formData.detail) newErrors.detail = "آدرس دقیق پستی را وارد کنید";
const normalizedZip = normalizeDigits(formData.zipCode);
if (!normalizedZip) {
newErrors.zipCode = "کد پستی الزامی است";
} else if (!/^\d{10}$/.test(normalizedZip)) {
newErrors.zipCode = "کد پستی باید ۱۰ رقم باشد";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
setIsSubmitting(true);
// Simulate small delay for UX
setTimeout(() => {
onSave({
...formData,
phone: normalizeDigits(formData.phone),
zipCode: normalizeDigits(formData.zipCode),
id: editingAddress?.id || Math.random().toString(36).substr(2, 9),
isDefault: formData.isDefault
});
toast.success(editingAddress ? "تغییرات آدرس با موفقیت ذخیره شد" : "آدرس جدید با موفقیت اضافه شد");
setIsSubmitting(false);
onClose();
}, 600);
} else {
toast.error("لطفاً موارد خطای فرم را اصلاح کنید");
}
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white w-full max-w-xl rounded-[3rem] relative z-10 shadow-2xl overflow-hidden font-vazir text-right pointer-events-auto"
dir="rtl"
>
<div className="bg-medical-gray-50 px-8 py-6 border-b border-medical-gray-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white">
<MapPin className="w-5 h-5" />
</div>
<h3 className="text-xl font-black text-medical-gray-900 italic">
{editingAddress ? "ویرایش آدرس ارسال" : "افزودن آدرس جدید"}
</h3>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white border border-medical-gray-200 text-medical-gray-400 hover:text-red-500 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="space-y-4">
{/* Row 1: Title */}
<div className="space-y-1">
<label htmlFor="addr-title" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">عنوان آدرس (مثلاً: خانه، محل کار)</label>
<input
autoFocus
id="addr-title"
name="address-line1"
autoComplete="address-line1"
type="text"
value={formData.title}
onChange={e => setFormData({...formData, title: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.title ? "border-red-300" : "border-medical-gray-100")}
placeholder="مثال: منزل اصلی"
/>
{errors.title && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.title}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Row 2: Name & Phone */}
<div className="space-y-1">
<label htmlFor="addr-name" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">نام گیرنده</label>
<input
id="addr-name"
name="name"
autoComplete="name"
type="text"
value={formData.receptorName}
onChange={e => setFormData({...formData, receptorName: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.receptorName ? "border-red-300" : "border-medical-gray-100")}
placeholder="نام و نام‌خانوادگی..."
/>
{errors.receptorName && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.receptorName}</p>}
</div>
<div className="space-y-1">
<label htmlFor="addr-tel" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شماره تماس</label>
<input
id="addr-tel"
name="tel"
autoComplete="tel"
type="text"
value={formData.phone}
onChange={e => setFormData({...formData, phone: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-left", errors.phone ? "border-red-300" : "border-medical-gray-100")}
placeholder="09120000000"
dir="ltr"
/>
{errors.phone && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.phone}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Row 3: Province & City */}
<div className="space-y-1">
<label htmlFor="addr-province" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">استان</label>
<SearchableSelect
id="addr-province"
options={IRAN_PROVINCES}
value={formData.province}
placeholder="انتخاب استان..."
searchPlaceholder="جستجوی استان..."
onChange={(newProv) => {
const cities = PROVINCE_CITIES[newProv] || [];
setFormData({
...formData,
province: newProv,
city: cities[0] || ""
});
}}
/>
{errors.province && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.province}</p>}
</div>
<div className="space-y-1">
<label htmlFor="addr-city" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شهر</label>
{formData.province && PROVINCE_CITIES[formData.province] ? (
<SearchableSelect
id="addr-city"
options={PROVINCE_CITIES[formData.province]}
value={formData.city}
placeholder="انتخاب شهر..."
searchPlaceholder="جستجوی شهر..."
onChange={(c) => setFormData({...formData, city: c})}
/>
) : (
<input
id="addr-city"
name="address-level2"
type="text"
value={formData.city}
onChange={e => setFormData({...formData, city: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20", errors.city ? "border-red-300" : "border-medical-gray-100")}
placeholder="نام شهر..."
/>
)}
{errors.city && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.city}</p>}
</div>
</div>
{/* Row 4: Detail Address */}
<div className="space-y-1">
<label htmlFor="addr-street" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">آدرس دقیق</label>
<textarea
id="addr-street"
name="street-address"
autoComplete="street-address"
rows={2}
value={formData.detail}
onChange={e => setFormData({...formData, detail: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 resize-none", errors.detail ? "border-red-300" : "border-medical-gray-100")}
placeholder="خیابان، کوچه، پلاک، واحد..."
/>
{errors.detail && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.detail}</p>}
</div>
{/* Row 5: Zip Code */}
<div className="space-y-1">
<label htmlFor="addr-zip" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">کد پستی (۱۰ رقم)</label>
<input
id="addr-zip"
name="postal-code"
autoComplete="postal-code"
type="text"
value={formData.zipCode}
onChange={e => setFormData({...formData, zipCode: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 text-left", errors.zipCode ? "border-red-300" : "border-medical-gray-100")}
placeholder="1234567890"
dir="ltr"
/>
{errors.zipCode && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.zipCode}</p>}
</div>
{/* Row 6: Default Toggle */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={() => setFormData({...formData, isDefault: !formData.isDefault})}
className={cn(
"w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all",
formData.isDefault ? "bg-canina-blue border-canina-blue text-white" : "border-medical-gray-200 text-transparent"
)}
>
<Check className="w-4 h-4" />
</button>
<label className="text-sm font-bold text-medical-gray-600">انتخاب به عنوان آدرس پیش‌فرض ارسال</label>
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 py-4 bg-medical-gray-50 text-medical-gray-400 rounded-2xl font-black text-sm hover:bg-medical-gray-100 transition-all"
>
انصراف
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-[2] py-4 bg-canina-blue text-white rounded-2xl font-black text-sm flex items-center justify-center gap-3 hover:bg-medical-gray-900 transition-all shadow-lg shadow-canina-blue/20 disabled:opacity-50 disabled:cursor-wait"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
{isSubmitting ? "در حال ثبت..." : (editingAddress ? "ثبت تغییرات" : "ثبت آدرس نهایی")}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
);
}