Files
SweetHome/app/reservations/page.js

326 lines
17 KiB
JavaScript
Raw Normal View History

'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,
2026-05-26 20:54:25 +03:00
MapPin, DollarSign, Home, ArrowLeft, CreditCard, Timer,
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../services/AuthService';
2026-05-26 20:54:25 +03:00
import { getRentProperty, getUserReservations, payDeposit } from '../utils/api';
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>
);
}
async function enrich(reservation) {
if (!reservation.propertyId) return reservation;
try {
const prop = await getRentProperty(reservation.propertyId);
reservation._prop = prop?.propertyInformation ?? prop ?? null;
} catch { /* skip */ }
return reservation;
}
const propAddr = (p) => p?.address ?? '';
const propImages = (p) => Array.isArray(p?.images) ? p.images : [];
const propBeds = (p) => p?.numberOfBedRooms ?? 0;
const propBaths = (p) => p?.numberOfBathRooms ?? 0;
2026-05-26 20:54:25 +03:00
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;
}
2026-05-26 21:00:14 +03:00
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);
}
2026-05-26 20:54:25 +03:00
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);
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
const addr = propAddr(p);
const beds = propBeds(p);
const baths = propBaths(p);
2026-05-26 20:54:25 +03:00
const isOwnerConfirmed = STATUS_MAP[r.status] === 'ownerConfirmed';
const deadline = isOwnerConfirmed && r.ownerApprovalDate
? new Date(r.ownerApprovalDate).getTime() + parseTimeSpan(r.allowedPaymentPeriod)
: null;
2026-05-26 21:00:14 +03:00
const isExpired = deadline ? Date.now() > deadline : false;
2026-05-26 20:54:25 +03:00
const isPaying = payingId === r.id;
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>
2026-05-26 21:00:14 +03:00
{isOwnerConfirmed && <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>
{r.allowedPaymentPeriod && <div className="text-xs text-blue-600">مدة الدفع: {formatWindowDuration(r.allowedPaymentPeriod)}</div>}
2026-05-26 20:54:25 +03:00
</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>
2026-05-26 21:00:14 +03:00
{isOwnerConfirmed && !isExpired && <button onClick={() => onPay(r)} disabled={isPaying}
2026-05-26 20:54:25 +03:00
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>
</div>
</motion.div>
);
}
2026-05-26 20:54:25 +03:00
function DetailsModal({ r, isOpen, onClose, onPay, payingId }) {
if (!isOpen || !r) return null;
const p = r._prop;
2026-05-26 20:54:25 +03:00
const isOwnerConfirmed = STATUS_MAP[r.status] === 'ownerConfirmed';
const deadline = isOwnerConfirmed && r.ownerApprovalDate
? new Date(r.ownerApprovalDate).getTime() + parseTimeSpan(r.allowedPaymentPeriod)
: null;
2026-05-26 21:00:14 +03:00
const isExpired = deadline ? Date.now() > deadline : false;
2026-05-26 20:54:25 +03:00
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)||''}</p>
{(propBeds(p)||propBaths(p)) && <div className="flex gap-3 mt-2">
{propBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBeds(p)} غرف</span>}
{propBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBaths(p)} حمامات</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>
2026-05-26 21:00:14 +03:00
{isOwnerConfirmed && <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>
{r.allowedPaymentPeriod && <div className="text-xs text-blue-600 mb-3">مدة الدفع: {formatWindowDuration(r.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'}`}>
2026-05-26 20:54:25 +03:00
{isPaying ? <Loader2 className="w-5 h-5 animate-spin"/> : <CreditCard className="w-5 h-5"/>} {isPaying ? 'جاري الدفع...' : 'ادفع الآن'}
2026-05-26 21:00:14 +03:00
</button>}
2026-05-26 20:54:25 +03:00
</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('');
2026-05-26 20:54:25 +03:00
const [payingId, setPayingId] = useState(null);
useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
const loadReservations = useCallback(async () => {
try {
2026-05-26 20:54:25 +03:00
const data = await getUserReservations();
const list = Array.isArray(data) ? data : [];
const enriched = await Promise.all(list.map(enrich));
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).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])) };
2026-05-26 20:54:25 +03:00
const handlePay = async (r) => {
setPayingId(r.id);
try {
2026-05-26 21:00:14 +03:00
await payDeposit({
reservationId: r.id,
paymentTypeId: 1,
transactionType: 1,
comment: null,
});
2026-05-26 20:54:25 +03:00
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} />
2026-05-26 20:54:25 +03:00
<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">
2026-05-26 20:54:25 +03:00
{filtered.map(r => <ReservationCard key={r.id} r={r} onViewDetails={setSelected} onPay={handlePay} payingId={payingId} />)}
</div>
)}
</div>
</div>
);
}