Files
SweetHome/app/reservations/page.js
hamzaobed7 3472dc1175
All checks were successful
Build frontend / build (push) Successful in 1m46s
add payment for SaleProprite ,add commsion,fixed error handling fiexd final things
2026-08-18 01:24:18 +03:00

1164 lines
48 KiB
JavaScript

"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,
User,
} 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";
import Loading from "../loading";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
const GOVERNORATES = ["دمشق", "ريف دمشق", "حلب", "حمص", "حماة", "اللاذقية", "طرطوس", "إدلب", "درعا", "السويداء", "القنيطرة", "دير الزور", "الرقة", "الحسكة"];
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?.() || "";
};
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,
haramDetails,
onSetHaramDetails,
}) {
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) => String(m?.id ?? m?.name) === String(selectedPayment));
const isHaram = selectedMethod?.name?.toLowerCase?.() === "haram";
const hasSelectedMethod = !!selectedMethod;
const isHaramFormValid = !isHaram || (haramDetails.fullName.trim() !== "" && haramDetails.governorate !== "" && haramDetails.phone.trim() !== "");
const canPay = payingId !== reservation.id && hasSelectedMethod && (!isHaram || (!!receiptImage && isHaramFormValid));
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 max-h-[90vh] overflow-y-auto"
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>
<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 space-y-4">
<p className="text-sm font-bold text-gray-700 flex items-center gap-2">
<User className="w-4 h-4 text-amber-600" />
بيانات حوالة الهرم
</p>
<div>
{haramDetails.split("\n").map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</div>
<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)}
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>
<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 [haramDetails, setHaramDetails] = useState();
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 : []);
setHaramDetails(data[0].details)
} 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);
setHaramDetails({ fullName: "", governorate: "", phone: "" });
};
const closePaymentDialog = () => {
setPaymentDialog({ open: false, reservation: null });
setReceiptImage(null);
setHaramDetails({ fullName: "", governorate: "", phone: "" });
};
const handleConfirmPay = async (r) => {
setPayingId(r.id);
const selectedMethod = paymentMethods?.find((m) => String(m?.id ?? m?.name) === String(selectedPayment));
const isHaram = selectedMethod?.name?.toLowerCase?.() === "haram";
const detailsJson = isHaram ? JSON.stringify(haramDetails) : null;
try {
await payDeposit({
reservationId: r.id,
paymentTypeId: selectedPayment,
transactionType: 1,
comment: detailsJson,
details: detailsJson,
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 <Loading />;
}
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}
haramDetails={haramDetails}
onSetHaramDetails={setHaramDetails}
/>
<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>
);
}