1817 lines
73 KiB
JavaScript
1817 lines
73 KiB
JavaScript
// 'use client';
|
|
|
|
// import { useState, useEffect, useCallback } from 'react';
|
|
// import { motion } from 'framer-motion';
|
|
// import { useRouter } from 'next/navigation';
|
|
// import {
|
|
// Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
|
|
// MapPin, DollarSign, Home, ArrowLeft, CreditCard, Timer, Star,
|
|
// } from 'lucide-react';
|
|
// import toast, { Toaster } from 'react-hot-toast';
|
|
// import AuthService from '../services/AuthService';
|
|
// import { getRentProperties, getUserReservations, payDeposit } from '../utils/api';
|
|
// import { addPropertyRating } from '../utils/ratings';
|
|
|
|
// const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
|
|
|
// const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
|
|
|
|
// const STATUS_UI = {
|
|
// pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
|
// ownerConfirmed: { label: 'مؤكد من المالك', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
|
// depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
|
|
// depositConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
|
// completed: { label: 'منتهي', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
|
// cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
|
// };
|
|
|
|
// function statusLabel(code) { return STATUS_UI[STATUS_MAP[code]]?.label ?? String(code); }
|
|
// function statusColor(code) { return STATUS_UI[STATUS_MAP[code]]?.color ?? 'bg-gray-100 text-gray-700'; }
|
|
// function statusIcon(code) { return STATUS_UI[STATUS_MAP[code]]?.icon ?? Clock; }
|
|
|
|
// function StatusBadge({ code }) {
|
|
// const Icon = statusIcon(code);
|
|
// return (
|
|
// <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${statusColor(code)}`}>
|
|
// <Icon className="w-3 h-3" /> {statusLabel(code)}
|
|
// </span>
|
|
// );
|
|
// }
|
|
|
|
// const propAddr = (p, r) => p?.address ?? r?.propertyAddress ?? '';
|
|
// const propImages = (p, r) => {
|
|
// if (p?.images && Array.isArray(p.images)) return p.images;
|
|
// if (r?.property?.images && Array.isArray(r.property.images)) return r.property.images;
|
|
// return [];
|
|
// };
|
|
// const propBeds = (p, r) => p?.numberOfBedRooms ?? r?.property?.numberOfBedRooms ?? 0;
|
|
// const propBaths = (p, r) => p?.numberOfBathRooms ?? r?.property?.numberOfBathRooms ?? 0;
|
|
|
|
// function parseTimeSpan(str) {
|
|
// if (!str) return 0;
|
|
// const clean = str.replace(/-/g, '');
|
|
// const dotIdx = clean.indexOf('.');
|
|
// let days = 0, timePart = clean;
|
|
// if (dotIdx !== -1) {
|
|
// days = parseInt(clean.substring(0, dotIdx), 10) || 0;
|
|
// timePart = clean.substring(dotIdx + 1);
|
|
// }
|
|
// const parts = timePart.split(':');
|
|
// if (parts.length < 2) return days * 86400000;
|
|
// const hh = parseInt(parts[0], 10) || 0;
|
|
// const mm = parseInt(parts[1], 10) || 0;
|
|
// const ss = parts.length > 2 ? (parseInt(parts[2], 10) || 0) : 0;
|
|
// return ((days * 86400) + (hh * 3600) + (mm * 60) + ss) * 1000;
|
|
// }
|
|
|
|
// function formatWindowDuration(str) {
|
|
// if (!str) return '';
|
|
// const clean = str.replace(/-/g, '');
|
|
// const dotIdx = clean.indexOf('.');
|
|
// let totalHours = 0, timePart = clean;
|
|
// if (dotIdx !== -1) {
|
|
// const days = parseInt(clean.substring(0, dotIdx), 10) || 0;
|
|
// totalHours += days * 24;
|
|
// timePart = clean.substring(dotIdx + 1);
|
|
// }
|
|
// const parts = timePart.split(':');
|
|
// if (parts.length >= 2) {
|
|
// totalHours += parseInt(parts[0], 10) || 0;
|
|
// }
|
|
// if (totalHours > 0) return `${String(totalHours).padStart(2, '0')}:00:00`;
|
|
// return timePart.substring(0, 8);
|
|
// }
|
|
|
|
// function CountdownTimer({ deadline }) {
|
|
// const [remaining, setRemaining] = useState(deadline ? Math.max(0, deadline - Date.now()) : 0);
|
|
// useEffect(() => {
|
|
// if (!deadline) return;
|
|
// const tick = () => setRemaining(Math.max(0, deadline - Date.now()));
|
|
// tick();
|
|
// const id = setInterval(tick, 1000);
|
|
// return () => clearInterval(id);
|
|
// }, [deadline]);
|
|
// if (remaining <= 0) return <span className="text-red-500 text-sm font-medium">انتهت المهلة</span>;
|
|
// const h = Math.floor(remaining / 3600000);
|
|
// const m = Math.floor((remaining % 3600000) / 60000);
|
|
// const s = Math.floor((remaining % 60000) / 1000);
|
|
// const pad = (n) => String(n).padStart(2, '0');
|
|
// return <span className="text-amber-600 text-sm font-mono font-bold" dir="ltr">{pad(h)}:{pad(m)}:{pad(s)}</span>;
|
|
// }
|
|
|
|
// function ReservationCard({ r, onViewDetails, onPay, payingId }) {
|
|
// const p = r._prop;
|
|
// const imgs = propImages(p, r);
|
|
// const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
|
// const addr = propAddr(p, r);
|
|
// const beds = propBeds(p, r);
|
|
// const baths = propBaths(p, r);
|
|
// const isOwnerConfirmed = STATUS_MAP[r.status] === 'ownerConfirmed';
|
|
// const canRate = STATUS_MAP[r.status] === 'depositPaid' || STATUS_MAP[r.status] === 'completed';
|
|
// const hasTimeWindow = r.ownerApprovalDate && p?.allowedPaymentPeriod;
|
|
// const deadline = hasTimeWindow
|
|
// ? new Date(r.ownerApprovalDate).getTime() + parseTimeSpan(p.allowedPaymentPeriod)
|
|
// : null;
|
|
// const isExpired = deadline ? Date.now() > deadline : false;
|
|
// const isPaying = payingId === r.id;
|
|
// const [showRating, setShowRating] = useState(false);
|
|
// const [ratings, setRatings] = useState({ clean: 0, services: 0, ownerBehavior: 0, experience: 0 });
|
|
// const [ratingComment, setRatingComment] = useState('');
|
|
// const [submittingRating, setSubmittingRating] = useState(false);
|
|
|
|
// return (
|
|
// <motion.div initial={{ opacity:0,y:20 }} animate={{ opacity:1,y:0 }}
|
|
// className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
|
|
// <div className="p-5">
|
|
// {img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover" /></div>}
|
|
// <div className="flex justify-between items-start mb-3">
|
|
// <div>
|
|
// <StatusBadge code={r.status} />
|
|
// {addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
|
|
// </div>
|
|
// <div className="text-left">
|
|
// <div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString() ?? '—'}</div>
|
|
// <div className="text-xs text-gray-500">السعر الإجمالي</div>
|
|
// </div>
|
|
// </div>
|
|
// {(beds||baths) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{beds>0&&<span>{beds} غرف</span>}{baths>0&&<span>{baths} حمامات</span>}</div>}
|
|
// <div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
|
// <div className="bg-gray-50 p-2 rounded-lg">
|
|
// <Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
|
|
// <div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
|
// </div>
|
|
// <div className="bg-gray-50 p-2 rounded-lg">
|
|
// <Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
|
|
// <div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
|
// </div>
|
|
// </div>
|
|
// {isOwnerConfirmed && hasTimeWindow && <div className="bg-blue-50 p-3 rounded-xl mb-3">
|
|
// <div className="flex items-center justify-between mb-1">
|
|
// <span className="text-sm text-blue-800 font-medium flex items-center gap-1"><Timer className="w-4 h-4"/> متبقي للدفع:</span>
|
|
// <CountdownTimer deadline={deadline} />
|
|
// </div>
|
|
// <div className="text-xs text-blue-600">مدة الدفع: {formatWindowDuration(p.allowedPaymentPeriod)}</div>
|
|
// </div>}
|
|
// <div className="flex gap-3 pt-3 border-t border-gray-100">
|
|
// <button onClick={() => onViewDetails(r)}
|
|
// className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
|
// <Eye className="w-4 h-4"/> التفاصيل
|
|
// </button>
|
|
// {isOwnerConfirmed && !isExpired && <button onClick={() => onPay(r)} disabled={isPaying}
|
|
// className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2 ${isPaying ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-amber-500 text-white hover:bg-amber-600'}`}>
|
|
// {isPaying ? <Loader2 className="w-4 h-4 animate-spin"/> : <CreditCard className="w-4 h-4"/>} {isPaying ? 'جاري الدفع...' : 'ادفع الآن'}
|
|
// </button>}
|
|
// </div>
|
|
// {canRate && !showRating && <button onClick={() => setShowRating(true)}
|
|
// className="w-full mt-3 bg-amber-50 text-amber-700 py-2 rounded-xl text-sm font-medium hover:bg-amber-100 transition-colors flex items-center justify-center gap-2">
|
|
// <Star className="w-4 h-4"/> قيّم هذا العقار
|
|
// </button>}
|
|
// {canRate && showRating && <div className="mt-3 bg-amber-50 p-3 rounded-xl">
|
|
// <div className="space-y-2 mb-3">
|
|
// {[
|
|
// { key: 'clean', label: 'النظافة' },
|
|
// { key: 'services', label: 'الخدمات' },
|
|
// { key: 'ownerBehavior', label: 'تعامل المالك' },
|
|
// { key: 'experience', label: 'التجربة العامة' },
|
|
// ].map(cat => <div key={cat.key} className="flex items-center justify-between">
|
|
// <span className="text-sm text-gray-700">{cat.label}</span>
|
|
// <div className="flex gap-0.5">
|
|
// {[1,2,3,4,5].map(n => (
|
|
// <button key={n} onClick={() => setRatings(p => ({...p, [cat.key]: n}))}
|
|
// className={`p-0.5 rounded-full transition-colors ${n <= ratings[cat.key] ? 'text-amber-500' : 'text-gray-300'}`}>
|
|
// <Star className={`w-4 h-4 ${n <= ratings[cat.key] ? 'fill-amber-500' : ''}`} />
|
|
// </button>
|
|
// ))}
|
|
// </div>
|
|
// </div>)}
|
|
// </div>
|
|
// <textarea value={ratingComment} onChange={e => setRatingComment(e.target.value)}
|
|
// placeholder="أكتب تعليقك (اختياري)"
|
|
// className="w-full p-2 text-sm border border-amber-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-amber-500 mb-2" rows={2} />
|
|
// <div className="flex gap-2">
|
|
// <button onClick={async () => {
|
|
// if (!ratings.clean || !ratings.services || !ratings.ownerBehavior || !ratings.experience) return toast.error('قيّم جميع الفئات');
|
|
// setSubmittingRating(true);
|
|
// try {
|
|
// await addPropertyRating({ reservationId: r.id, cleanRating: ratings.clean, servicesRating: ratings.services, ownerBehaviorRating: ratings.ownerBehavior, experienceRating: ratings.experience, comment: ratingComment || null });
|
|
// toast.success('تم إرسال التقييم');
|
|
// setShowRating(false);
|
|
// setRatings({ clean: 0, services: 0, ownerBehavior: 0, experience: 0 });
|
|
// setRatingComment('');
|
|
// } catch (e) { toast.error(e?.message || 'فشل إرسال التقييم'); }
|
|
// finally { setSubmittingRating(false); }
|
|
// }} disabled={submittingRating}
|
|
// className="flex-1 bg-amber-500 text-white py-1.5 rounded-lg text-sm font-medium hover:bg-amber-600 transition-colors disabled:bg-gray-300">
|
|
// {submittingRating ? 'جاري الإرسال...' : 'إرسال التقييم'}
|
|
// </button>
|
|
// <button onClick={() => { setShowRating(false); setRatings({ clean: 0, services: 0, ownerBehavior: 0, experience: 0 }); setRatingComment(''); }}
|
|
// className="px-4 py-1.5 bg-gray-200 text-gray-700 rounded-lg text-sm hover:bg-gray-300 transition-colors">إلغاء</button>
|
|
// </div>
|
|
// </div>}
|
|
// </div>
|
|
// </motion.div>
|
|
// );
|
|
// }
|
|
|
|
// function DetailsModal({ r, isOpen, onClose, onPay, payingId }) {
|
|
// if (!isOpen || !r) return null;
|
|
// const p = r._prop;
|
|
// const isOwnerConfirmed = STATUS_MAP[r.status] === 'ownerConfirmed';
|
|
// const hasTimeWindow = r.ownerApprovalDate && p?.allowedPaymentPeriod;
|
|
// const deadline = hasTimeWindow
|
|
// ? new Date(r.ownerApprovalDate).getTime() + parseTimeSpan(p.allowedPaymentPeriod)
|
|
// : null;
|
|
// const isExpired = deadline ? Date.now() > deadline : false;
|
|
// const isPaying = payingId === r.id;
|
|
|
|
// return (
|
|
// <motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
|
|
// className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
|
|
// <motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
|
|
// className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
|
|
// <div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
|
// <div className="flex justify-between items-center">
|
|
// <h2 className="text-xl font-bold">تفاصيل الحجز</h2>
|
|
// <button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
|
// </div>
|
|
// <p className="text-amber-100 text-sm mt-1">رقم الحجز: #{r.id}</p>
|
|
// </div>
|
|
// <div className="p-6 space-y-6">
|
|
// {p && <div className="bg-gray-50 p-4 rounded-xl">
|
|
// <h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
|
|
// <p><span className="text-gray-500">العنوان:</span> {propAddr(p, r)||'—'}</p>
|
|
// {(propBeds(p, r)||propBaths(p, r)) && <div className="flex gap-3 mt-2">
|
|
// {propBeds(p, r)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBeds(p, r)} غرف</span>}
|
|
// {propBaths(p, r)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBaths(p, r)} حمامات</span>}
|
|
// </div>}
|
|
// </div>}
|
|
// <div className="bg-gray-50 p-4 rounded-xl">
|
|
// <h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
|
|
// <div className="grid grid-cols-2 gap-4">
|
|
// <div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
|
|
// <div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
|
|
// <div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
|
|
// <div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
|
|
// </div>
|
|
// </div>
|
|
// <div className="bg-amber-50 p-4 rounded-xl">
|
|
// <h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/> المعلومات المالية</h3>
|
|
// <div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??'—'}</span></div>
|
|
// </div>
|
|
// {isOwnerConfirmed && hasTimeWindow && <div className="bg-blue-50 p-4 rounded-xl">
|
|
// <div className="flex items-center justify-between mb-2">
|
|
// <span className="text-blue-800 font-medium flex items-center gap-2"><Timer className="w-5 h-5"/> متبقي للدفع:</span>
|
|
// <CountdownTimer deadline={deadline} />
|
|
// </div>
|
|
// <div className="text-xs text-blue-600 mb-3">مدة الدفع: {formatWindowDuration(p.allowedPaymentPeriod)}</div>
|
|
// {!isExpired && <button onClick={() => { onPay(r); onClose(); }} disabled={isPaying}
|
|
// className={`w-full py-2 rounded-xl font-medium transition-colors flex items-center justify-center gap-2 ${isPaying ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-amber-500 text-white hover:bg-amber-600'}`}>
|
|
// {isPaying ? <Loader2 className="w-5 h-5 animate-spin"/> : <CreditCard className="w-5 h-5"/>} {isPaying ? 'جاري الدفع...' : 'ادفع الآن'}
|
|
// </button>}
|
|
// </div>}
|
|
// </div>
|
|
// </motion.div>
|
|
// </motion.div>
|
|
// );
|
|
// }
|
|
|
|
// export default function UserReservationsPage() {
|
|
// const router = useRouter();
|
|
// const [reservations, setReservations] = useState([]);
|
|
// const [filtered, setFiltered] = useState([]);
|
|
// const [loading, setLoading] = useState(true);
|
|
// const [selected, setSelected] = useState(null);
|
|
// const [filterStatus, setFilterStatus] = useState('all');
|
|
// const [searchTerm, setSearchTerm] = useState('');
|
|
// const [payingId, setPayingId] = useState(null);
|
|
|
|
// useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
|
|
|
|
// const loadReservations = useCallback(async () => {
|
|
// try {
|
|
// const [data, rentProps] = await Promise.all([
|
|
// getUserReservations(),
|
|
// getRentProperties().catch(() => []),
|
|
// ]);
|
|
// const list = Array.isArray(data) ? data : [];
|
|
// const propsList = Array.isArray(rentProps) ? rentProps : [];
|
|
// const propMap = {};
|
|
// propsList.forEach(rp => {
|
|
// const info = rp?.propertyInformation ?? {};
|
|
// if (rp?.allowedPaymentPeriod) info.allowedPaymentPeriod = rp.allowedPaymentPeriod;
|
|
// propMap[rp.propertyInformationId] = info;
|
|
// propMap[rp.propertyInformation?.id] = info;
|
|
// });
|
|
// const enriched = list.map(r => {
|
|
// if (r.propertyId && propMap[r.propertyId]) r._prop = propMap[r.propertyId];
|
|
// return r;
|
|
// });
|
|
// setReservations(enriched);
|
|
// setFiltered(enriched);
|
|
// } catch (err) {
|
|
// console.error(err);
|
|
// toast.error('فشل تحميل الحجوزات');
|
|
// setReservations([]);
|
|
// setFiltered([]);
|
|
// }
|
|
// setLoading(false);
|
|
// }, []);
|
|
|
|
// useEffect(() => {
|
|
// let r = reservations;
|
|
// if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
|
|
// if (searchTerm) { const q = searchTerm.toLowerCase(); r = r.filter(x => propAddr(x._prop, x).toLowerCase().includes(q) || String(x.id).includes(q)); }
|
|
// setFiltered(r);
|
|
// }, [reservations, filterStatus, searchTerm]);
|
|
|
|
// const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
|
|
// const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
|
|
|
|
// const handlePay = async (r) => {
|
|
// setPayingId(r.id);
|
|
// try {
|
|
// await payDeposit({
|
|
// reservationId: r.id,
|
|
// paymentTypeId: 1,
|
|
// transactionType: 1,
|
|
// comment: null,
|
|
// });
|
|
// toast.success('تم دفع السلفة بنجاح!');
|
|
// loadReservations();
|
|
// } catch (err) {
|
|
// toast.error(err?.message || 'فشل عملية الدفع');
|
|
// } finally {
|
|
// setPayingId(null);
|
|
// }
|
|
// };
|
|
|
|
// if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
|
|
|
|
// return (
|
|
// <div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
|
// <Toaster position="top-center" reverseOrder={false} />
|
|
// <DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} onPay={handlePay} payingId={payingId} />
|
|
// <div className="container mx-auto px-4">
|
|
// <motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
|
|
// <button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
|
|
// <h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
|
|
// <p className="text-gray-600">لديك {reservations.length} حجز</p>
|
|
// </motion.div>
|
|
// <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
// {Object.entries(counts).map(([s, c]) => (
|
|
// <motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
|
// className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
|
|
// onClick={() => setFilterStatus(s)}>
|
|
// <div className="text-2xl font-bold text-amber-600">{c}</div>
|
|
// <div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
|
|
// </motion.div>
|
|
// ))}
|
|
// </div>
|
|
// <div className="mb-6 relative">
|
|
// <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
|
|
// <input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
|
|
// className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
|
|
// </div>
|
|
// {filtered.length === 0 ? (
|
|
// <div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
|
// <Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
|
|
// <h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
|
|
// <p className="text-gray-600">لم تقم بأي حجز حتى الآن</p>
|
|
// </div>
|
|
// ) : (
|
|
// <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
// {filtered.map(r => <ReservationCard key={r.id} r={r} onViewDetails={setSelected} onPay={handlePay} payingId={payingId} />)}
|
|
// </div>
|
|
// )}
|
|
// </div>
|
|
// </div>
|
|
// );
|
|
// }
|
|
|
|
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import { motion } from "framer-motion";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Calendar,
|
|
Clock,
|
|
CheckCircle,
|
|
XCircle,
|
|
Eye,
|
|
Loader2,
|
|
Search,
|
|
MapPin,
|
|
DollarSign,
|
|
Home,
|
|
ArrowLeft,
|
|
CreditCard,
|
|
Timer,
|
|
Star,
|
|
Flag,
|
|
Banknote,
|
|
Building,
|
|
ArrowLeftRight,
|
|
Check,
|
|
X,
|
|
Info,
|
|
Landmark,
|
|
Phone,
|
|
Wallet,
|
|
ImageIcon,
|
|
} from "lucide-react";
|
|
import toast, { Toaster } from "react-hot-toast";
|
|
import AuthService from "../services/AuthService";
|
|
import {
|
|
getPaymentTypes,
|
|
getRentProperties,
|
|
getUserReservations,
|
|
payDeposit,
|
|
} from "../utils/api";
|
|
import { addPropertyRating } from "../utils/ratings";
|
|
|
|
const API_BASE =
|
|
process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
|
|
|
|
const STATUS_MAP = [
|
|
"pending",
|
|
"ownerConfirmed",
|
|
"depositPaid",
|
|
"depositConfirmed",
|
|
"completed",
|
|
"cancelled",
|
|
];
|
|
|
|
const STATUS_UI = {
|
|
pending: { color: "bg-yellow-100 text-yellow-800", icon: Clock },
|
|
ownerConfirmed: { color: "bg-blue-100 text-blue-800", icon: CheckCircle },
|
|
depositPaid: { color: "bg-indigo-100 text-indigo-800", icon: DollarSign },
|
|
depositConfirmed: { color: "bg-green-100 text-green-800", icon: CheckCircle },
|
|
completed: { color: "bg-green-100 text-green-800", icon: CheckCircle },
|
|
cancelled: { color: "bg-gray-100 text-gray-800", icon: XCircle },
|
|
};
|
|
|
|
function statusColor(code) {
|
|
return STATUS_UI[STATUS_MAP[code]]?.color ?? "bg-gray-100 text-gray-700";
|
|
}
|
|
function statusIcon(code) {
|
|
return STATUS_UI[STATUS_MAP[code]]?.icon ?? Clock;
|
|
}
|
|
|
|
function formatCurrency(v, sign = "") {
|
|
return `${sign} ${Number(v ?? 0).toLocaleString()}`;
|
|
}
|
|
|
|
function formatDate(date) {
|
|
if (!date) return "";
|
|
const d = new Date(date);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
return d.toLocaleDateString("en-GB");
|
|
}
|
|
|
|
function StatusBadge({ code }) {
|
|
const { t } = useTranslation();
|
|
const Icon = statusIcon(code);
|
|
const key = STATUS_MAP[code] || "pending";
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${statusColor(code)}`}
|
|
>
|
|
<Icon className="w-3 h-3" /> {t(`bookingStatus.${key}`)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const propAddr = (p, r) => p?.address ?? r?.propertyAddress ?? "";
|
|
const propImages = (p, r) => {
|
|
if (p?.images && Array.isArray(p.images)) return p.images;
|
|
if (r?.property?.images && Array.isArray(r.property.images))
|
|
return r.property.images;
|
|
return [];
|
|
};
|
|
const propBeds = (p, r) =>
|
|
p?.numberOfBedRooms ?? r?.property?.numberOfBedRooms ?? 0;
|
|
const propBaths = (p, r) =>
|
|
p?.numberOfBathRooms ?? r?.property?.numberOfBathRooms ?? 0;
|
|
|
|
const getAuthToken = () => {
|
|
if (typeof window === "undefined") return "";
|
|
return (
|
|
AuthService.getToken?.() ||
|
|
AuthService.getAccessToken?.() ||
|
|
localStorage.getItem("token") ||
|
|
localStorage.getItem("accessToken") ||
|
|
localStorage.getItem("authToken") ||
|
|
""
|
|
);
|
|
};
|
|
|
|
const readStoredUser = () => {
|
|
if (typeof window === "undefined") return null;
|
|
const keys = ["user", "currentUser", "authUser", "profile"];
|
|
for (const key of keys) {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) continue;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const extractNumericUserId = (value) => {
|
|
if (!value) return null;
|
|
if (typeof value === "number") return Number.isInteger(value) ? value : null;
|
|
if (typeof value === "string") {
|
|
const n = Number(value);
|
|
return Number.isInteger(n) ? n : null;
|
|
}
|
|
if (typeof value === "object") {
|
|
const candidates = [
|
|
value.id,
|
|
value.userId,
|
|
value.userID,
|
|
value.user?.id,
|
|
value.user?.userId,
|
|
value.profile?.id,
|
|
value.profile?.userId,
|
|
value.data?.id,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const id = extractNumericUserId(candidate);
|
|
if (id !== null) return id;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
async function reportReservation(reservationId, message) {
|
|
const user = AuthService.getUser?.() ?? readStoredUser();
|
|
const reporter = extractNumericUserId(user);
|
|
const rid = Number(reservationId);
|
|
|
|
if (!Number.isInteger(rid)) {
|
|
throw new Error("Invalid reservation ID");
|
|
}
|
|
if (!Number.isInteger(reporter)) {
|
|
throw new Error("Could not identify current user");
|
|
}
|
|
|
|
const token = getAuthToken();
|
|
const res = await fetch(`${API_BASE}/ReservationReports/ReportReservation`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
reservationId: rid,
|
|
message: message ?? null,
|
|
reporter,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let errorMessage = "Failed to submit report";
|
|
try {
|
|
const data = await res.json();
|
|
errorMessage = data?.message || data?.title || errorMessage;
|
|
} catch (_) {
|
|
try {
|
|
const text = await res.text();
|
|
if (text) errorMessage = text;
|
|
} catch (_) {}
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
try {
|
|
return await res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseTimeSpan(str) {
|
|
if (!str) return 0;
|
|
const clean = str.replace(/-/g, "");
|
|
const dotIdx = clean.indexOf(".");
|
|
let days = 0,
|
|
timePart = clean;
|
|
if (dotIdx !== -1) {
|
|
days = parseInt(clean.substring(0, dotIdx), 10) || 0;
|
|
timePart = clean.substring(dotIdx + 1);
|
|
}
|
|
const parts = timePart.split(":");
|
|
if (parts.length < 2) return days * 86400000;
|
|
const hh = parseInt(parts[0], 10) || 0;
|
|
const mm = parseInt(parts[1], 10) || 0;
|
|
const ss = parts.length > 2 ? parseInt(parts[2], 10) || 0 : 0;
|
|
return (days * 86400 + hh * 3600 + mm * 60 + ss) * 1000;
|
|
}
|
|
|
|
function formatWindowDuration(str) {
|
|
if (!str) return "";
|
|
const clean = str.replace(/-/g, "");
|
|
const dotIdx = clean.indexOf(".");
|
|
let totalHours = 0,
|
|
timePart = clean;
|
|
if (dotIdx !== -1) {
|
|
const days = parseInt(clean.substring(0, dotIdx), 10) || 0;
|
|
totalHours += days * 24;
|
|
timePart = clean.substring(dotIdx + 1);
|
|
}
|
|
const parts = timePart.split(":");
|
|
if (parts.length >= 2) {
|
|
totalHours += parseInt(parts[0], 10) || 0;
|
|
}
|
|
if (totalHours > 0) return `${String(totalHours).padStart(2, "0")}:00:00`;
|
|
return timePart.substring(0, 8);
|
|
}
|
|
|
|
function CountdownTimer({ deadline }) {
|
|
const { t } = useTranslation();
|
|
const [remaining, setRemaining] = useState(
|
|
deadline ? Math.max(0, deadline - Date.now()) : 0,
|
|
);
|
|
useEffect(() => {
|
|
if (!deadline) return;
|
|
const tick = () => setRemaining(Math.max(0, deadline - Date.now()));
|
|
tick();
|
|
const id = setInterval(tick, 1000);
|
|
return () => clearInterval(id);
|
|
}, [deadline]);
|
|
if (remaining <= 0)
|
|
return (
|
|
<span className="text-red-500 text-sm font-medium">
|
|
{t("timeExpired")}
|
|
</span>
|
|
);
|
|
const h = Math.floor(remaining / 3600000);
|
|
const m = Math.floor((remaining % 3600000) / 60000);
|
|
const s = Math.floor((remaining % 60000) / 1000);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
return (
|
|
<span className="text-amber-600 text-sm font-mono font-bold" dir="ltr">
|
|
{pad(h)}:{pad(m)}:{pad(s)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function PaymentDialog({
|
|
isOpen,
|
|
reservation,
|
|
onClose,
|
|
selectedPayment,
|
|
onSelectPayment,
|
|
onConfirmPay,
|
|
payingId,
|
|
paymentMethods,
|
|
loadingPaymentMethods,
|
|
receiptImage,
|
|
onSetReceiptImage,
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [showCashDialog, setShowCashDialog] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) setShowCashDialog(false);
|
|
}, [isOpen, reservation?.id]);
|
|
|
|
if (!isOpen || !reservation) return null;
|
|
|
|
const amount = reservation.depositAmount || reservation.totalPrice || 0;
|
|
const currencySign = reservation.currencySign || t("sypSymbol");
|
|
const selectedMethod = paymentMethods?.find(
|
|
(m) => (m?.id ?? m?.name) === selectedPayment
|
|
);
|
|
const isHaram = selectedMethod?.name?.toLowerCase?.() === "haram";
|
|
const canPay = payingId !== reservation.id && (!isHaram || !!receiptImage);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="w-full max-w-2xl rounded-3xl bg-white shadow-2xl border border-gray-200 overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6 border-b border-gray-100">
|
|
<div className="flex items-start gap-4 mb-4">
|
|
<div className="w-14 h-14 bg-amber-100 rounded-2xl flex items-center justify-center shrink-0">
|
|
<Home className="w-7 h-7 text-amber-600" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-xl font-bold text-gray-900 mb-1">
|
|
{reservation.propertyName}
|
|
</h3>
|
|
{(reservation.propertyAddress || reservation.propertyCity) && (
|
|
<p className="text-sm text-gray-500 flex items-center gap-1">
|
|
<MapPin className="w-3.5 h-3.5" />
|
|
{[reservation.propertyCity, reservation.propertyAddress]
|
|
.filter(Boolean)
|
|
.join(" - ")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
|
<span className="flex items-center gap-1.5">
|
|
<Calendar className="w-4 h-4 text-gray-400" />
|
|
{formatDate(reservation.startDate)} -{" "}
|
|
{formatDate(reservation.endDate)}
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<Clock className="w-4 h-4 text-gray-400" />#
|
|
{reservation.reservationId || reservation.id}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 border-b border-gray-100">
|
|
<div className="text-center">
|
|
<p className="text-sm text-gray-500 mb-1">{t("depositAmount")}</p>
|
|
<p className="text-4xl font-bold text-amber-600">
|
|
{formatCurrency(amount, currencySign)}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
{t("depositPaidToPlatform")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/* this disabled becuase if all of them not active or active there is no manual*/}
|
|
{/* <div className="px-6 py-4 bg-amber-50 border-b border-amber-100">
|
|
<div className="flex items-start gap-3">
|
|
<Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
|
<p className="text-sm text-amber-800 leading-relaxed">
|
|
{t('cashOnlyNotice')}
|
|
</p>
|
|
</div>
|
|
</div> */}
|
|
|
|
<div className="px-6 py-5 border-b border-gray-100">
|
|
<p className="text-sm font-bold text-gray-700 mb-3">
|
|
{t("paymentMethod")}
|
|
</p>
|
|
{loadingPaymentMethods ? (
|
|
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
|
{t("loadingPaymentMethods", {
|
|
defaultValue: "جاري تحميل طرق الدفع...",
|
|
})}
|
|
</div>
|
|
) : !Array.isArray(paymentMethods) ||
|
|
paymentMethods.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
|
{t("noPaymentMethodsAvailable", {
|
|
defaultValue: "لا توجد طرق دفع متاحة حالياً.",
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{paymentMethods.map((method, index) => {
|
|
const isActive =
|
|
method?.isActive === true || method?.active === true;
|
|
const optionId =
|
|
method?.id ?? method?.name ?? `payment-method-${index}`;
|
|
const label =
|
|
method?.name ||
|
|
t("paymentMethod", { defaultValue: "طريقة الدفع" });
|
|
const isSelected = selectedPayment === optionId;
|
|
|
|
return (
|
|
<button
|
|
key={optionId}
|
|
type="button"
|
|
disabled={!isActive}
|
|
onClick={() => isActive && onSelectPayment(optionId)}
|
|
className={`relative flex items-start gap-3 p-4 rounded-2xl border-2 text-right transition-all ${
|
|
!isActive
|
|
? "border-gray-100 bg-gray-50 opacity-50 cursor-not-allowed"
|
|
: isSelected
|
|
? "border-amber-500 bg-amber-50 shadow-sm"
|
|
: "border-gray-200 bg-white hover:border-amber-300 cursor-pointer"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${
|
|
isSelected
|
|
? "bg-amber-500 text-white"
|
|
: "bg-gray-100 text-gray-500"
|
|
}`}
|
|
>
|
|
{isActive ? (
|
|
<CreditCard className="w-5 h-5" />
|
|
) : (
|
|
<X className="w-5 h-5" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className={`text-sm font-bold ${isSelected ? "text-amber-900" : "text-gray-800"}`}
|
|
>
|
|
{label}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-0.5 leading-relaxed">
|
|
{isActive
|
|
? t("paymentMethodAvailable", {
|
|
defaultValue: "متاحة للاستعمال",
|
|
})
|
|
: t("paymentMethodUnavailable", {
|
|
defaultValue: "غير متاحة حالياً",
|
|
})}
|
|
</p>
|
|
<span
|
|
className={`inline-block mt-1 text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
|
isActive
|
|
? "bg-amber-100 text-amber-700"
|
|
: "bg-gray-200 text-gray-500"
|
|
}`}
|
|
>
|
|
{isActive
|
|
? t("active", { defaultValue: "نشطة ✅" })
|
|
: t("inactive", { defaultValue: "غير نشطة ❌" })}
|
|
</span>
|
|
</div>
|
|
{isSelected && (
|
|
<div className="absolute top-2 left-2 w-5 h-5 bg-amber-500 rounded-full flex items-center justify-center">
|
|
<Check className="w-3 h-3 text-white" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isHaram && (
|
|
<div className="px-6 py-5 border-b border-gray-100">
|
|
<p className="text-sm font-bold text-gray-700 mb-3 flex items-center gap-2">
|
|
<ImageIcon className="w-4 h-4" />
|
|
{t("uploadReceipt", { defaultValue: "إيصال الدفع" })}
|
|
</p>
|
|
{receiptImage ? (
|
|
<div className="relative rounded-2xl overflow-hidden border-2 border-amber-200 bg-amber-50">
|
|
<img
|
|
src={URL.createObjectURL(receiptImage)}
|
|
alt="receipt"
|
|
className="w-full h-48 object-contain"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSetReceiptImage(null)}
|
|
className="absolute top-2 left-2 w-8 h-8 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors shadow-md"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
<div className="p-3 text-center">
|
|
<p className="text-xs text-gray-500 truncate">{receiptImage.name}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-2xl bg-gray-50 hover:bg-gray-100 hover:border-amber-300 transition-all cursor-pointer">
|
|
<ImageIcon className="w-10 h-10 text-gray-400 mb-2" />
|
|
<p className="text-sm text-gray-500">
|
|
{t("tapToUploadReceipt", { defaultValue: "اضغط لرفع صورة الإيصال" })}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
JPG, PNG {t("maxSize", { defaultValue: "حد أقصى 5 ميجا" })}
|
|
</p>
|
|
<input
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/jpg"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
toast.error(t("fileTooLarge", { defaultValue: "الصورة كبيرة جداً، الحد الأقصى 5 ميجا" }));
|
|
return;
|
|
}
|
|
onSetReceiptImage(file);
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-6 py-5 border-b border-gray-100">
|
|
<motion.button
|
|
type="button"
|
|
onClick={() => onConfirmPay(reservation)}
|
|
disabled={!canPay}
|
|
whileHover={{ scale: 1.01 }}
|
|
whileTap={{ scale: 0.99 }}
|
|
className="w-full bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white font-bold py-4 px-6 rounded-2xl text-lg transition-all shadow-lg hover:shadow-xl flex items-center justify-center gap-3"
|
|
>
|
|
{payingId === reservation.id ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
{t("processingPayment")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Banknote className="w-5 h-5" />
|
|
{t("payDeposit", {
|
|
amount: formatCurrency(amount, currencySign),
|
|
})}
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Pay Cash button - commented out */}
|
|
{/* <div className="px-6 py-4 border-b border-gray-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCashDialog(true)}
|
|
className="w-full bg-white border-2 border-amber-200 hover:border-amber-400 text-amber-700 font-bold py-3 px-6 rounded-2xl text-base transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<Building className="w-5 h-5" />
|
|
{t("payCash")}
|
|
</button>
|
|
</div> */}
|
|
|
|
{/* Cash payment location - commented out */}
|
|
{/* <div className="px-6 py-5 bg-gray-50">
|
|
<div className="flex items-start gap-3">
|
|
<Landmark className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm font-bold text-gray-800 mb-1">
|
|
{t("cashPaymentLocation")}
|
|
</p>
|
|
<p className="text-sm text-gray-600 leading-relaxed">
|
|
{t("platformOfficeAddress")}
|
|
</p>
|
|
<p className="text-sm text-gray-600 flex items-center gap-1 mt-1">
|
|
<Phone className="w-3.5 h-3.5" />
|
|
<span dir="ltr">+963567823411</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div> */}
|
|
|
|
<div className="p-4 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-5 py-2.5 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors font-medium"
|
|
>
|
|
{t("close")}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{showCashDialog && (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4 py-8">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="w-full max-w-md rounded-3xl bg-white p-8 shadow-2xl border border-gray-200 text-center"
|
|
>
|
|
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-5">
|
|
<Clock className="w-8 h-8 text-amber-600" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-gray-900 mb-3">
|
|
{t("cashPaymentPending")}
|
|
</h3>
|
|
<p className="text-gray-600 leading-relaxed mb-6">
|
|
{t("cashPaymentInstructions")}
|
|
</p>
|
|
<div className="bg-gray-50 rounded-2xl p-4 mb-6 text-right">
|
|
<p className="text-sm font-bold text-gray-800 mb-1">
|
|
{t("platformLocation")}
|
|
</p>
|
|
<p className="text-sm text-gray-600">
|
|
{t("platformOfficeAddress")}
|
|
</p>
|
|
<p
|
|
className="text-sm text-gray-600 flex items-center gap-1 mt-1"
|
|
dir="ltr"
|
|
>
|
|
<Phone className="w-3.5 h-3.5" />
|
|
+963567823411
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-3 sm:flex-row">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCashDialog(false)}
|
|
className="w-full px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors font-medium"
|
|
>
|
|
{t("close")}
|
|
</button>
|
|
<Link
|
|
href="/reservations"
|
|
className="w-full px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
|
>
|
|
{t("myBookings")}
|
|
</Link>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ReportDialog({ isOpen, reservation, onClose, onSubmit, submitting }) {
|
|
const { t } = useTranslation();
|
|
const [message, setMessage] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (isOpen) setMessage("");
|
|
}, [isOpen, reservation?.id]);
|
|
|
|
if (!isOpen || !reservation) return null;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.95, y: 16 }}
|
|
animate={{ scale: 1, y: 0 }}
|
|
exit={{ scale: 0.95, y: 16 }}
|
|
className="w-full max-w-lg rounded-2xl bg-white shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="bg-gradient-to-r from-red-500 to-red-600 p-6 text-white">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-bold">{t("reportReservation")}</h2>
|
|
<p className="text-red-100 text-sm mt-1">
|
|
{t("reservationNumber", { id: reservation.id })}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-full p-1 hover:bg-white/20"
|
|
>
|
|
<XCircle className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<p className="text-gray-700 mb-4 leading-7">
|
|
{t("reportDescription")}
|
|
</p>
|
|
|
|
<textarea
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
placeholder={t("reportPlaceholder")}
|
|
rows={5}
|
|
className="w-full resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
|
|
/>
|
|
|
|
<div className="mt-5 flex gap-3">
|
|
<button
|
|
onClick={() => onSubmit(message)}
|
|
disabled={submitting}
|
|
className={`flex-1 rounded-xl py-2.5 text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
|
submitting
|
|
? "cursor-not-allowed bg-gray-300 text-gray-500"
|
|
: "bg-red-600 text-white hover:bg-red-700"
|
|
}`}
|
|
>
|
|
{submitting ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Flag className="h-4 w-4" />
|
|
)}
|
|
{submitting ? t("sending") : t("sendReport")}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={submitting}
|
|
className="rounded-xl bg-gray-200 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-300 transition-colors disabled:cursor-not-allowed"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function ReservationCard({
|
|
r,
|
|
onViewDetails,
|
|
onPay,
|
|
onReport,
|
|
payingId,
|
|
reportingId,
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const p = r._prop;
|
|
const imgs = propImages(p, r);
|
|
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
|
const addr = propAddr(p, r);
|
|
const beds = propBeds(p, r);
|
|
const baths = propBaths(p, r);
|
|
const isOwnerConfirmed = STATUS_MAP[r.status] === "ownerConfirmed";
|
|
const canRate =
|
|
STATUS_MAP[r.status] === "depositPaid" ||
|
|
STATUS_MAP[r.status] === "completed";
|
|
const hasTimeWindow = r.ownerApprovalDate && p?.allowedPaymentPeriod;
|
|
const deadline = hasTimeWindow
|
|
? new Date(r.ownerApprovalDate).getTime() +
|
|
parseTimeSpan(p.allowedPaymentPeriod)
|
|
: null;
|
|
const isExpired = deadline ? Date.now() > deadline : false;
|
|
const isPaying = payingId === r.id;
|
|
const isReporting = reportingId === r.id;
|
|
const [showRating, setShowRating] = useState(false);
|
|
const [ratings, setRatings] = useState({
|
|
clean: 0,
|
|
services: 0,
|
|
ownerBehavior: 0,
|
|
experience: 0,
|
|
});
|
|
const [ratingComment, setRatingComment] = useState("");
|
|
const [submittingRating, setSubmittingRating] = useState(false);
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden"
|
|
>
|
|
<div className="p-5">
|
|
{img && (
|
|
<div className="mb-4 w-full h-40 rounded-xl overflow-hidden">
|
|
<img src={img} alt="" className="w-full h-full object-cover" />
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-start mb-3">
|
|
<div>
|
|
<StatusBadge code={r.status} />
|
|
{addr && (
|
|
<div className="flex items-center gap-1 text-gray-500 text-sm mt-1">
|
|
<MapPin className="w-4 h-4" />
|
|
{addr}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-left">
|
|
<div className="text-lg font-bold text-amber-600">
|
|
{r.totalPrice?.toLocaleString() ?? "—"}
|
|
</div>
|
|
<div className="text-xs text-gray-500">{t("totalPrice")}</div>
|
|
</div>
|
|
</div>
|
|
{(beds || baths) && (
|
|
<div className="flex gap-3 mb-3 text-sm text-gray-600">
|
|
{beds > 0 && (
|
|
<span>
|
|
{beds} {t("rooms")}
|
|
</span>
|
|
)}
|
|
{baths > 0 && (
|
|
<span>
|
|
{baths} {t("bathrooms")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
|
<div className="bg-gray-50 p-2 rounded-lg">
|
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
|
<div className="text-xs text-gray-500">{t("from")}</div>
|
|
<div className="text-sm font-medium">
|
|
{new Date(r.startDate).toLocaleDateString("ar")}
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-50 p-2 rounded-lg">
|
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
|
<div className="text-xs text-gray-500">{t("to")}</div>
|
|
<div className="text-sm font-medium">
|
|
{new Date(r.endDate).toLocaleDateString("ar")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isOwnerConfirmed && hasTimeWindow && (
|
|
<div className="bg-blue-50 p-3 rounded-xl mb-3">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm text-blue-800 font-medium flex items-center gap-1">
|
|
<Timer className="w-4 h-4" /> {t("remainingForPayment")}
|
|
</span>
|
|
<CountdownTimer deadline={deadline} />
|
|
</div>
|
|
<div className="text-xs text-blue-600">
|
|
{t("paymentDuration", {
|
|
duration: formatWindowDuration(p.allowedPaymentPeriod),
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-3 pt-3 border-t border-gray-100">
|
|
<button
|
|
onClick={() => onViewDetails(r)}
|
|
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Eye className="w-4 h-4" /> {t("details")}
|
|
</button>
|
|
{isOwnerConfirmed && !isExpired && (
|
|
<button
|
|
onClick={() => onPay(r)}
|
|
disabled={isPaying}
|
|
className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2 ${isPaying ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-amber-500 text-white hover:bg-amber-600"}`}
|
|
>
|
|
{isPaying ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<CreditCard className="w-4 h-4" />
|
|
)}{" "}
|
|
{isPaying ? t("processingPayment") : t("payNow")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => onReport(r)}
|
|
disabled={isReporting}
|
|
className={`w-full mt-3 py-2 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2 ${isReporting ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-red-50 text-red-700 hover:bg-red-100"}`}
|
|
>
|
|
{isReporting ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Flag className="w-4 h-4" />
|
|
)}{" "}
|
|
{isReporting ? t("reportingAction") : t("report")}
|
|
</button>
|
|
{canRate && !showRating && (
|
|
<button
|
|
onClick={() => setShowRating(true)}
|
|
className="w-full mt-3 bg-amber-50 text-amber-700 py-2 rounded-xl text-sm font-medium hover:bg-amber-100 transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Star className="w-4 h-4" /> {t("rateThisProperty")}
|
|
</button>
|
|
)}
|
|
{canRate && showRating && (
|
|
<div className="mt-3 bg-amber-50 p-3 rounded-xl">
|
|
<div className="space-y-2 mb-3">
|
|
{[
|
|
{ key: "clean", label: "ratingCategory.clean" },
|
|
{ key: "services", label: "ratingCategory.services" },
|
|
{ key: "ownerBehavior", label: "ratingCategory.ownerBehavior" },
|
|
{ key: "experience", label: "ratingCategory.experience" },
|
|
].map((cat) => (
|
|
<div
|
|
key={cat.key}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span className="text-sm text-gray-700">{t(cat.label)}</span>
|
|
<div className="flex gap-0.5">
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<button
|
|
key={n}
|
|
onClick={() =>
|
|
setRatings((p) => ({ ...p, [cat.key]: n }))
|
|
}
|
|
className={`p-0.5 rounded-full transition-colors ${n <= ratings[cat.key] ? "text-amber-500" : "text-gray-300"}`}
|
|
>
|
|
<Star
|
|
className={`w-4 h-4 ${n <= ratings[cat.key] ? "fill-amber-500" : ""}`}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<textarea
|
|
value={ratingComment}
|
|
onChange={(e) => setRatingComment(e.target.value)}
|
|
placeholder={t("writeCommentOptional")}
|
|
className="w-full p-2 text-sm border border-amber-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-amber-500 mb-2"
|
|
rows={2}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
if (
|
|
!ratings.clean ||
|
|
!ratings.services ||
|
|
!ratings.ownerBehavior ||
|
|
!ratings.experience
|
|
)
|
|
return toast.error(t("rateAllCategories"));
|
|
setSubmittingRating(true);
|
|
try {
|
|
await addPropertyRating({
|
|
reservationId: r.id,
|
|
cleanRating: ratings.clean,
|
|
servicesRating: ratings.services,
|
|
ownerBehaviorRating: ratings.ownerBehavior,
|
|
experienceRating: ratings.experience,
|
|
comment: ratingComment || null,
|
|
});
|
|
toast.success(t("ratingSubmittedToast"));
|
|
setShowRating(false);
|
|
setRatings({
|
|
clean: 0,
|
|
services: 0,
|
|
ownerBehavior: 0,
|
|
experience: 0,
|
|
});
|
|
setRatingComment("");
|
|
} catch (e) {
|
|
toast.error(e?.message || t("ratingFailed"));
|
|
} finally {
|
|
setSubmittingRating(false);
|
|
}
|
|
}}
|
|
disabled={submittingRating}
|
|
className="flex-1 bg-amber-500 text-white py-1.5 rounded-lg text-sm font-medium hover:bg-amber-600 transition-colors disabled:bg-gray-300"
|
|
>
|
|
{submittingRating ? t("sending") : t("submitRating")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowRating(false);
|
|
setRatings({
|
|
clean: 0,
|
|
services: 0,
|
|
ownerBehavior: 0,
|
|
experience: 0,
|
|
});
|
|
setRatingComment("");
|
|
}}
|
|
className="px-4 py-1.5 bg-gray-200 text-gray-700 rounded-lg text-sm hover:bg-gray-300 transition-colors"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function DetailsModal({
|
|
r,
|
|
isOpen,
|
|
onClose,
|
|
onPay,
|
|
onReport,
|
|
payingId,
|
|
reportingId,
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (!isOpen || !r) return null;
|
|
const p = r._prop;
|
|
const isOwnerConfirmed = STATUS_MAP[r.status] === "ownerConfirmed";
|
|
const hasTimeWindow = r.ownerApprovalDate && p?.allowedPaymentPeriod;
|
|
const deadline = hasTimeWindow
|
|
? new Date(r.ownerApprovalDate).getTime() +
|
|
parseTimeSpan(p.allowedPaymentPeriod)
|
|
: null;
|
|
const isExpired = deadline ? Date.now() > deadline : false;
|
|
const isPaying = payingId === r.id;
|
|
const isReporting = reportingId === r.id;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, y: 20 }}
|
|
animate={{ scale: 1, y: 0 }}
|
|
exit={{ scale: 0.9, y: 20 }}
|
|
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
|
<div className="flex justify-between items-center">
|
|
<h2 className="text-xl font-bold">{t("bookingDetails")}</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 hover:bg-white/20 rounded-full"
|
|
>
|
|
<XCircle className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
<p className="text-amber-100 text-sm mt-1">
|
|
{t("reservationNumber", { id: r.id })}
|
|
</p>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
{p && (
|
|
<div className="bg-gray-50 p-4 rounded-xl">
|
|
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
|
<Home className="w-5 h-5 text-amber-500" /> {t("propertyInfo")}
|
|
</h3>
|
|
<p>
|
|
<span className="text-gray-500">{t("addressLabel")}</span>{" "}
|
|
{propAddr(p, r) || "—"}
|
|
</p>
|
|
{(propBeds(p, r) || propBaths(p, r)) && (
|
|
<div className="flex gap-3 mt-2">
|
|
{propBeds(p, r) > 0 && (
|
|
<span className="text-sm bg-white px-2 py-1 rounded-lg">
|
|
{propBeds(p, r)} {t("rooms")}
|
|
</span>
|
|
)}
|
|
{propBaths(p, r) > 0 && (
|
|
<span className="text-sm bg-white px-2 py-1 rounded-lg">
|
|
{propBaths(p, r)} {t("bathrooms")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="bg-gray-50 p-4 rounded-xl">
|
|
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
|
<Calendar className="w-5 h-5 text-amber-500" />{" "}
|
|
{t("bookingDetails")}
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-gray-500">{t("startDateLabel")}</p>
|
|
<p className="font-medium">
|
|
{new Date(r.startDate).toLocaleDateString("ar")}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-500">{t("endDateLabel")}</p>
|
|
<p className="font-medium">
|
|
{new Date(r.endDate).toLocaleDateString("ar")}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-500">{t("statusLabel")}</p>
|
|
<StatusBadge code={r.status} />
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-500">{t("createdAtLabel")}</p>
|
|
<p className="font-medium">
|
|
{new Date(r.createdAt).toLocaleDateString("ar")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-amber-50 p-4 rounded-xl">
|
|
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2">
|
|
<DollarSign className="w-5 h-5" /> {t("financialInfo")}
|
|
</h3>
|
|
<div className="flex justify-between font-bold">
|
|
<span className="text-gray-900">{t("total")}</span>
|
|
<span className="text-amber-600 text-lg">
|
|
{r.totalPrice?.toLocaleString() ?? "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{isOwnerConfirmed && hasTimeWindow && (
|
|
<div className="bg-blue-50 p-4 rounded-xl">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-blue-800 font-medium flex items-center gap-2">
|
|
<Timer className="w-5 h-5" /> {t("remainingForPayment")}
|
|
</span>
|
|
<CountdownTimer deadline={deadline} />
|
|
</div>
|
|
<div className="text-xs text-blue-600 mb-3">
|
|
{t("paymentDuration", {
|
|
duration: formatWindowDuration(p.allowedPaymentPeriod),
|
|
})}
|
|
</div>
|
|
{!isExpired && (
|
|
<button
|
|
onClick={() => {
|
|
onPay(r);
|
|
onClose();
|
|
}}
|
|
disabled={isPaying}
|
|
className={`w-full py-2 rounded-xl font-medium transition-colors flex items-center justify-center gap-2 ${isPaying ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-amber-500 text-white hover:bg-amber-600"}`}
|
|
>
|
|
{isPaying ? (
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
) : (
|
|
<CreditCard className="w-5 h-5" />
|
|
)}{" "}
|
|
{isPaying ? t("processingPayment") : t("payNow")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
onReport(r);
|
|
onClose();
|
|
}}
|
|
disabled={isReporting}
|
|
className={`w-full py-2 rounded-xl font-medium transition-colors flex items-center justify-center gap-2 ${isReporting ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-red-50 text-red-700 hover:bg-red-100"}`}
|
|
>
|
|
{isReporting ? (
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
) : (
|
|
<Flag className="w-5 h-5" />
|
|
)}{" "}
|
|
{isReporting ? t("reportingAction") : t("report")}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
export default function UserReservationsPage() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const [reservations, setReservations] = useState([]);
|
|
const [filtered, setFiltered] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selected, setSelected] = useState(null);
|
|
const [filterStatus, setFilterStatus] = useState("all");
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [payingId, setPayingId] = useState(null);
|
|
const [reportDialog, setReportDialog] = useState({
|
|
open: false,
|
|
reservation: null,
|
|
});
|
|
const [reportingId, setReportingId] = useState(null);
|
|
const [selectedPayment, setSelectedPayment] = useState("cash");
|
|
const [receiptImage, setReceiptImage] = useState(null);
|
|
const [paymentDialog, setPaymentDialog] = useState({
|
|
open: false,
|
|
reservation: null,
|
|
});
|
|
const [paymentMethods, setPaymentMethods] = useState([]);
|
|
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
|
|
|
|
const loadReservations = useCallback(async () => {
|
|
try {
|
|
const [data, rentProps] = await Promise.all([
|
|
getUserReservations(),
|
|
getRentProperties().catch(() => []),
|
|
]);
|
|
const list = Array.isArray(data) ? data : [];
|
|
const propsList = Array.isArray(rentProps) ? rentProps : [];
|
|
const propMap = {};
|
|
propsList.forEach((rp) => {
|
|
const info = rp?.propertyInformation ?? {};
|
|
if (rp?.allowedPaymentPeriod)
|
|
info.allowedPaymentPeriod = rp.allowedPaymentPeriod;
|
|
propMap[rp.propertyInformationId] = info;
|
|
propMap[rp.propertyInformation?.id] = info;
|
|
});
|
|
const enriched = list.map((r) => {
|
|
if (r.propertyId && propMap[r.propertyId])
|
|
r._prop = propMap[r.propertyId];
|
|
return r;
|
|
});
|
|
setReservations(enriched);
|
|
setFiltered(enriched);
|
|
} catch (err) {
|
|
console.error(err);
|
|
toast.error(t("failedToLoadBookings"));
|
|
setReservations([]);
|
|
setFiltered([]);
|
|
}
|
|
setLoading(false);
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
if (!AuthService.getUser()) {
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
loadReservations();
|
|
}, [router, loadReservations]);
|
|
|
|
const loadPaymentMethods = useCallback(async () => {
|
|
setLoadingPaymentMethods(true);
|
|
try {
|
|
const data = await getPaymentTypes();
|
|
setPaymentMethods(Array.isArray(data) ? data : []);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setPaymentMethods([]);
|
|
} finally {
|
|
setLoadingPaymentMethods(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadPaymentMethods();
|
|
}, [loadPaymentMethods]);
|
|
|
|
useEffect(() => {
|
|
let r = reservations;
|
|
if (filterStatus !== "all")
|
|
r = r.filter((x) => STATUS_MAP[x.status] === filterStatus);
|
|
if (searchTerm) {
|
|
const q = searchTerm.toLowerCase();
|
|
r = r.filter(
|
|
(x) =>
|
|
propAddr(x._prop, x).toLowerCase().includes(q) ||
|
|
String(x.id).includes(q),
|
|
);
|
|
}
|
|
setFiltered(r);
|
|
}, [reservations, filterStatus, searchTerm]);
|
|
|
|
const allStatuses = [
|
|
...new Set(reservations.map((r) => STATUS_MAP[r.status])),
|
|
];
|
|
const counts = {
|
|
all: reservations.length,
|
|
...Object.fromEntries(
|
|
allStatuses.map((s) => [
|
|
s,
|
|
reservations.filter((r) => STATUS_MAP[r.status] === s).length,
|
|
]),
|
|
),
|
|
};
|
|
|
|
const openPaymentDialog = (r) => {
|
|
setPaymentDialog({ open: true, reservation: r });
|
|
setReceiptImage(null);
|
|
};
|
|
|
|
const closePaymentDialog = () => {
|
|
setPaymentDialog({ open: false, reservation: null });
|
|
setReceiptImage(null);
|
|
};
|
|
|
|
const handleConfirmPay = async (r) => {
|
|
setPayingId(r.id);
|
|
try {
|
|
await payDeposit({
|
|
reservationId: r.id,
|
|
paymentTypeId: selectedPayment,
|
|
transactionType: "",
|
|
comment: null,
|
|
paymentImage: receiptImage || null,
|
|
});
|
|
toast.success(t("depositPaidSuccess"));
|
|
closePaymentDialog();
|
|
loadReservations();
|
|
} catch (err) {
|
|
toast.error(err?.message || t("paymentFailed"));
|
|
} finally {
|
|
setPayingId(null);
|
|
}
|
|
};
|
|
|
|
const openReportDialog = (r) => {
|
|
setReportDialog({ open: true, reservation: r });
|
|
};
|
|
|
|
const closeReportDialog = () => {
|
|
setReportDialog({ open: false, reservation: null });
|
|
};
|
|
|
|
const handleSubmitReport = async (message) => {
|
|
if (!reportDialog.reservation) return;
|
|
|
|
setReportingId(reportDialog.reservation.id);
|
|
try {
|
|
await reportReservation(
|
|
reportDialog.reservation.id,
|
|
message.trim() || null,
|
|
);
|
|
toast.success(t("reportSubmittedSuccess"));
|
|
closeReportDialog();
|
|
} catch (err) {
|
|
toast.error(err?.message || t("reportFailed"));
|
|
} finally {
|
|
setReportingId(null);
|
|
}
|
|
};
|
|
|
|
if (loading)
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<Loader2 className="w-12 h-12 text-amber-500 animate-spin" />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
|
<Toaster position="top-center" reverseOrder={false} />
|
|
<DetailsModal
|
|
r={selected}
|
|
isOpen={!!selected}
|
|
onClose={() => setSelected(null)}
|
|
onPay={openPaymentDialog}
|
|
onReport={openReportDialog}
|
|
payingId={payingId}
|
|
reportingId={reportingId}
|
|
/>
|
|
<ReportDialog
|
|
isOpen={reportDialog.open}
|
|
reservation={reportDialog.reservation}
|
|
onClose={closeReportDialog}
|
|
onSubmit={handleSubmitReport}
|
|
submitting={!!reportingId}
|
|
/>
|
|
<PaymentDialog
|
|
isOpen={paymentDialog.open}
|
|
reservation={paymentDialog.reservation}
|
|
onClose={closePaymentDialog}
|
|
selectedPayment={selectedPayment}
|
|
onSelectPayment={setSelectedPayment}
|
|
onConfirmPay={handleConfirmPay}
|
|
payingId={payingId}
|
|
paymentMethods={paymentMethods}
|
|
loadingPaymentMethods={loadingPaymentMethods}
|
|
receiptImage={receiptImage}
|
|
onSetReceiptImage={setReceiptImage}
|
|
/>
|
|
<div className="container mx-auto px-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="mb-8"
|
|
>
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"
|
|
>
|
|
<ArrowLeft className="w-5 h-5" /> {t("back")}
|
|
</button>
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
|
{t("myBookings")}
|
|
</h1>
|
|
<p className="text-gray-600">
|
|
{t("youHaveBookings", { count: reservations.length })}
|
|
</p>
|
|
</motion.div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
{Object.entries(counts).map(([s, c]) => (
|
|
<motion.div
|
|
key={s}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus === s ? "border-amber-500 bg-amber-50" : "border-gray-200"}`}
|
|
onClick={() => setFilterStatus(s)}
|
|
>
|
|
<div className="text-2xl font-bold text-amber-600">{c}</div>
|
|
<div className="text-sm text-gray-600">
|
|
{s === "all" ? t("all") : t(`bookingStatus.${s}`)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
<div className="mb-6 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder={t("searchPlaceholder")}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
{filtered.length === 0 ? (
|
|
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
|
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4" />
|
|
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
|
{t("noBookings")}
|
|
</h3>
|
|
<p className="text-gray-600">{t("noReservationsDesc")}</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{filtered.map((r) => (
|
|
<ReservationCard
|
|
key={r.id}
|
|
r={r}
|
|
onViewDetails={setSelected}
|
|
onPay={openPaymentDialog}
|
|
onReport={openReportDialog}
|
|
payingId={payingId}
|
|
reportingId={reportingId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|