Compare commits
132 Commits
7fe8903701
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97126c5776 | |||
| 1e167c447a | |||
| dd0a9c401d | |||
| 32f6c7af5a | |||
| 7e0d5eaf8d | |||
| beccd8b24f | |||
| 7e9a9d79f2 | |||
| 39f494aecb | |||
| 485baffdc2 | |||
| c46173d7c6 | |||
| 04fa34107b | |||
| 5a7d0ef265 | |||
| 0ba435fd7e | |||
| 9c2a748ae9 | |||
| db949aaeba | |||
| ae600ad41b | |||
| 16b1c7c6f6 | |||
| f761ab6f48 | |||
| 78138e6445 | |||
| 0891974440 | |||
| f925af0272 | |||
| 2346f518ce | |||
| 149058ddfc | |||
| e6249e845e | |||
| 3bdb99f2e5 | |||
| 2a1f00740f | |||
| 50836d3ec7 | |||
| d850f921b5 | |||
| 77dd052951 | |||
| 1207dbe20d | |||
| c9f52f64cb | |||
| 5fd22f0e01 | |||
| 2998a6bd75 | |||
| 571c85f14f | |||
| 9e1f8f517b | |||
| 700b446463 | |||
| be14250a08 | |||
| eec7a9a75d | |||
| 4ca7106b48 | |||
| 7685134a39 | |||
| 6ad2457e74 | |||
| 98c3f51df2 | |||
| 5d44fb56ec | |||
| ba389042c2 | |||
| c546e11ed3 | |||
| 8d7efe82a4 | |||
| 52758eae9d | |||
| a824fb0c7c | |||
| 9e87aa90e8 | |||
| 199e78d6b1 | |||
| df9711f539 | |||
| 2bea2d190c | |||
| 81674c4aa7 | |||
| cf7f51b514 | |||
| 0171c7a2bf | |||
| 9f6a730a94 | |||
| 2c04cd751f | |||
| db184bbace | |||
| 230805e02b | |||
| 3b9831a513 | |||
| 1f40c6a4fd | |||
| 4dd60ec14a | |||
| 68cb802d60 | |||
| d22248248d | |||
| d375ed9d89 | |||
| e6d754d014 | |||
| dee74d335f | |||
| 8f2679253b | |||
| 9bf67ffa38 | |||
| d56b4d2a11 | |||
| 4d43cdaba2 | |||
| 891756e092 | |||
| 722d69cc92 | |||
| 9d671f1985 | |||
| 505dcd4bb0 | |||
| 8f700d0957 | |||
| 39193337b3 | |||
| 4299968764 | |||
| 1a96e457ca | |||
| d3242a4147 | |||
| ff589e4b0a | |||
| 0c3b454015 | |||
| 6245965c1c | |||
| 829491cc30 | |||
| 059c7194d8 | |||
| f22bc45a4f | |||
| 86b8fc591b | |||
| ca1d83967e | |||
| 00dab824c3 | |||
| 5d7b3e3b0f | |||
| 412ccbf8b8 | |||
| 253bb875ab | |||
| 16038a80dd | |||
| 6df7548611 | |||
| d94b32a670 | |||
| da0c36727f | |||
| b6e9f01938 | |||
| 48523067fc | |||
| f6f0f5a5ea | |||
| e0f80f3dee | |||
| b8117093af | |||
| de7636f852 | |||
| 5a4b018c07 | |||
| c14c28141f | |||
| c99689a995 | |||
| f7fa3c723d | |||
| 0621f51676 | |||
| d698305d79 | |||
| bb15a7934e | |||
| 2424da2d45 | |||
| c2235cf575 | |||
| 6394f1d71a | |||
| 9cddee841b | |||
| 3c21c1873e | |||
| eff0b41b78 | |||
| 2fb55db360 | |||
| b613bde682 | |||
| 211ac42ad9 | |||
| fd3dcf4cc3 | |||
| bdcb98a047 | |||
| cfb9c0058b | |||
| 082f20da40 | |||
| 157188d2e6 | |||
| ac1241583b | |||
| 552bbdd269 | |||
| 485e4c2630 | |||
| 45e46afe21 | |||
| e961288b04 | |||
| f6c6119c18 | |||
| d86cb9e9a1 | |||
| 6aab85e99f | |||
| 2ea48df8a8 |
@ -5,6 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
|
||||
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
|
||||
import FloatingSidebar from '@/app/components/FloatingSidebar';
|
||||
import {
|
||||
Globe,
|
||||
LogIn,
|
||||
@ -36,7 +39,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AuthService from "./services/AuthService";
|
||||
import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||
import "./i18n/config";
|
||||
import NotificationHandler from "./components/NotificationHandler";
|
||||
|
||||
export default function ClientLayout({ children }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@ -51,17 +57,10 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const savedLanguage = localStorage.getItem("language") || "en";
|
||||
const savedLanguage = localStorage.getItem("language") || "ar";
|
||||
setCurrentLanguage(savedLanguage);
|
||||
i18n.changeLanguage(savedLanguage);
|
||||
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
console.log("User data loaded:", userData);
|
||||
setUser(userData);
|
||||
}
|
||||
|
||||
if (savedLanguage === "ar") {
|
||||
document.documentElement.dir = "rtl";
|
||||
document.documentElement.lang = "ar";
|
||||
@ -71,6 +70,23 @@ export default function ClientLayout({ children }) {
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
// Re-read user from JWT on every route change (handles post-login)
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
phone: authUser.phone,
|
||||
role: AuthService.isAdmin() ? UserRole.ADMIN
|
||||
: AuthService.isOwner() ? UserRole.OWNER
|
||||
: UserRole.CUSTOMER,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
@ -104,7 +120,7 @@ export default function ClientLayout({ children }) {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("user");
|
||||
AuthService.deleteToken();
|
||||
setUser(null);
|
||||
setShowUserMenu(false);
|
||||
window.location.href = "/";
|
||||
@ -119,11 +135,10 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
const isProfilePage = pathname === "/profile";
|
||||
|
||||
const isOwner = user?.role === "owner";
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
console.log("User role:", user?.role);
|
||||
console.log("Is Admin:", isAdmin);
|
||||
const isOwner = user?.role === UserRole.OWNER;
|
||||
const isAdmin = user?.role === UserRole.ADMIN;
|
||||
const isCustomer = user?.role === UserRole.CUSTOMER;
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
const getUserInitial = () => {
|
||||
if (user?.name) {
|
||||
@ -175,8 +190,47 @@ export default function ClientLayout({ children }) {
|
||||
<div
|
||||
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
|
||||
>
|
||||
<NavLink href="/">{t("home")}</NavLink>
|
||||
<NavLink href="/properties">{t("ourProducts")}</NavLink>
|
||||
{/* Download App Dropdown */}
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-3 py-2 text-gray-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z"/>
|
||||
<path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z"/>
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">تحميل التطبيق</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 translate-y-2 group-hover:translate-y-0">
|
||||
<div className="p-2">
|
||||
<a href="/files/SweetHome.apk" download
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="#16a34a" viewBox="0 0 16 16">
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">Android</p>
|
||||
<p className="text-xs text-green-600">تحميل APK</p>
|
||||
</div>
|
||||
</a>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg opacity-50 cursor-not-allowed">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#6b7280" viewBox="0 0 16 16">
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-400 text-sm">iOS</p>
|
||||
<p className="text-xs text-gray-400">قريباً</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavLink href="/">الرئيسية</NavLink>
|
||||
<NavLink href="/properties">عقاراتنا</NavLink>
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink href="/admin">
|
||||
@ -195,18 +249,18 @@ export default function ClientLayout({ children }) {
|
||||
عقاراتي
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/bookings">
|
||||
<NavLink href="/owner/reservations">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
الحجوزات
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/calendar">
|
||||
{/* <NavLink href="/owner/calendar">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
التقويم
|
||||
</span>
|
||||
</NavLink>
|
||||
</NavLink> */}
|
||||
<NavLink href="/owner/profits">
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
@ -234,7 +288,7 @@ export default function ClientLayout({ children }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
{/* <motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 360 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() =>
|
||||
@ -243,7 +297,7 @@ export default function ClientLayout({ children }) {
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-all duration-200 ml-4"
|
||||
>
|
||||
<Globe className="w-5 h-5 text-gray-700" />
|
||||
</motion.button>
|
||||
</motion.button> */}
|
||||
|
||||
{user && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
@ -277,11 +331,7 @@ export default function ClientLayout({ children }) {
|
||||
{user?.email || ""}
|
||||
</p>
|
||||
<p className="text-xs text-amber-100 mt-1">
|
||||
{isOwner
|
||||
? "مالك عقار"
|
||||
: isAdmin
|
||||
? "مدير النظام"
|
||||
: "مستأجر"}
|
||||
{UserRoleLabels[user?.role] || 'زائر'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -349,7 +399,7 @@ export default function ClientLayout({ children }) {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/owner/bookings"
|
||||
href="/owner/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
@ -468,12 +518,12 @@ export default function ClientLayout({ children }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isOwner && !isAdmin && user && (
|
||||
{isCustomer && (
|
||||
<>
|
||||
<div className="border-t border-gray-100 my-2"></div>
|
||||
|
||||
<Link
|
||||
href="/tenant/bookings"
|
||||
href="/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
@ -562,6 +612,24 @@ export default function ClientLayout({ children }) {
|
||||
{t("ourProducts")}
|
||||
</MobileNavLink>
|
||||
|
||||
{/* Download App - Mobile */}
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">تحميل التطبيق</p>
|
||||
<a href="/files/SweetHome.apk" download onClick={closeMobileMenu}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-green-600 hover:bg-green-50 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Android - تحميل APK</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md text-gray-400 cursor-not-allowed opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
|
||||
</svg>
|
||||
<span className="text-sm">iOS - قريباً</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
|
||||
{isAdmin && (
|
||||
<MobileNavLink href="/admin" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
@ -583,7 +651,7 @@ export default function ClientLayout({ children }) {
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/owner/bookings"
|
||||
href="/owner/reservations"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@ -641,7 +709,12 @@ export default function ClientLayout({ children }) {
|
||||
<main
|
||||
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{children}
|
||||
<NotificationsProvider>
|
||||
<FavoritesProvider>
|
||||
{children}
|
||||
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
|
||||
</FavoritesProvider>
|
||||
</NotificationsProvider>
|
||||
</main>
|
||||
|
||||
{!isAuthPage && !isProfilePage && (
|
||||
@ -712,7 +785,7 @@ export default function ClientLayout({ children }) {
|
||||
<ul className="space-y-3 text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="w-5 h-5" />
|
||||
<span>{t("phone")}</span>
|
||||
<span dir="ltr" className="text-right">{t("phone")}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
@ -729,6 +802,7 @@ export default function ClientLayout({ children }) {
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
<NotificationHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
113
app/admin/add-admin/page.js
Normal file
113
app/admin/add-admin/page.js
Normal file
@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AddAdminPage() {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [formState, setFormState] = useState({ fullName: '', email: '', password: '' });
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleChange = (field) => (event) => {
|
||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
setSaved(true);
|
||||
console.log('Add admin payload', formState);
|
||||
};
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mt-3">إضافة مدير جديد</h1>
|
||||
<p className="text-slate-500 mt-2">انشئ حساب مسؤول جديد مع صلاحيات الإدارة.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[1.5fr_0.8fr]">
|
||||
<section className="rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<h2 className="text-xl font-semibold mb-6">بيانات المدير</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">الاسم الكامل</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.fullName}
|
||||
onChange={handleChange('fullName')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="مثال: محمد الأحمد"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">البريد الإلكتروني</span>
|
||||
<input
|
||||
type="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange('email')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">كلمة المرور</span>
|
||||
<input
|
||||
type="password"
|
||||
value={formState.password}
|
||||
onChange={handleChange('password')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="inline-flex items-center justify-center rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||
حفظ المدير الجديد
|
||||
</button>
|
||||
</form>
|
||||
{saved && (
|
||||
<div className="mt-6 rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||
تم حفظ بيانات المدير بنجاح
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
app/admin/error.js
Normal file
27
app/admin/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/admin/loading.js
Normal file
14
app/admin/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,17 @@
|
||||
// app/admin/page.js (محدث)
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Home,
|
||||
Calendar,
|
||||
Users,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Bell
|
||||
Bell,
|
||||
Frown
|
||||
} from 'lucide-react';
|
||||
import DashboardStats from '../components/admin/DashboardStats';
|
||||
import PropertiesTable from '../components/admin/PropertiesTable';
|
||||
@ -20,6 +20,7 @@ import UsersList from '../components/admin/UsersList';
|
||||
import LedgerBook from '../components/admin/LedgerBook';
|
||||
import AddPropertyForm from '../components/admin/AddPropertyForm';
|
||||
import { PropertyProvider } from '../contexts/PropertyContext';
|
||||
import AuthService from '../services/AuthService';
|
||||
import '../i18n/config';
|
||||
|
||||
export default function AdminPage() {
|
||||
@ -27,6 +28,54 @@ export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [showAddProperty, setShowAddProperty] = useState(false);
|
||||
const [notifications, setNotifications] = useState(3);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
// ─── 404 for non-admins ───
|
||||
if (checked && !isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<svg viewBox="0 0 200 180" className="w-72 h-52 mx-auto">
|
||||
<circle cx="100" cy="70" r="60" fill="#fef3c7" />
|
||||
<circle cx="80" cy="60" r="8" fill="#92400e" />
|
||||
<circle cx="120" cy="60" r="8" fill="#92400e" />
|
||||
<path d="M80 85 Q100 75 120 85" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
<text x="100" y="140" textAnchor="middle" fontSize="16" fontWeight="bold" fill="#6b7280">عذراً!</text>
|
||||
<text x="100" y="160" textAnchor="middle" fontSize="12" fill="#9ca3af">الصفحة غير موجودة</text>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">404 - الصفحة غير موجودة</h2>
|
||||
<p className="text-gray-500 mb-8">عذراً، لا يمكنك الوصول إلى هذه الصفحة</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home },
|
||||
@ -34,7 +83,7 @@ export default function AdminPage() {
|
||||
{ id: 'bookings', label: 'طلبات الحجز', icon: Calendar, badge: notifications },
|
||||
{ id: 'users', label: 'المستخدمين', icon: Users },
|
||||
{ id: 'ledger', label: 'دفتر الحسابات', icon: DollarSign },
|
||||
{ id: 'reports', label: 'التقارير', icon: TrendingUp }
|
||||
// { id: 'reports', label: 'التقارير', icon: TrendingUp }
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
85
app/admin/privacy/page.js
Normal file
85
app/admin/privacy/page.js
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
const initialPolicy = `1. نحترم خصوصيتك ونلتزم بحماية بياناتك الشخصية.
|
||||
2. يتم استخدام المعلومات لتحسين تجربة المستخدم وتأمين الخدمة.
|
||||
3. لا نشارك البيانات مع أطراف خارجية بدون موافقتك.
|
||||
4. يمكنك طلب حذف بياناتك من النظام في أي وقت.`;
|
||||
|
||||
export default function PrivacyPolicyAdminPage() {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [policyText, setPolicyText] = useState(initialPolicy);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = (event) => {
|
||||
event.preventDefault();
|
||||
setSaved(true);
|
||||
console.log('Privacy policy updated:', policyText);
|
||||
};
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||
<p className="text-gray-600 mb-6">هذه الصفحة لتحرير سياسة الخصوصية ولا يمكن الوصول إليها إلا للمدير.</p>
|
||||
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||
<p className="text-slate-500 mt-2">قم بتحديث نص سياسة الخصوصية</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-6 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">نص سياسة الخصوصية</label>
|
||||
<textarea
|
||||
value={policyText}
|
||||
onChange={(e) => setPolicyText(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4 text-slate-700 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<button type="submit" className="rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||
حفظ السياسة
|
||||
</button>
|
||||
</div>
|
||||
{saved && (
|
||||
<div className="rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||
تمت حفظ سياسة الخصوصية بنجاح
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
app/auth/choose-role/error.js
Normal file
27
app/auth/choose-role/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/auth/choose-role/loading.js
Normal file
14
app/auth/choose-role/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
app/components/FloatingSidebar.js
Normal file
190
app/components/FloatingSidebar.js
Normal file
@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { Heart, Bell, CreditCard, Shield, UserPlus } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
export default function FloatingSidebar({ isRTL, isAdmin }) {
|
||||
const { favorites } = useFavorites();
|
||||
const { unreadCount } = useNotifications();
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
let timeoutId = null;
|
||||
|
||||
const showTooltip = (id) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
setTooltip(id);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(timeoutId);
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const side = isRTL ? 'left' : 'right';
|
||||
const positionStyle = {
|
||||
[side]: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
initial: { opacity: 0, x: isRTL ? -20 : 20 },
|
||||
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: 'easeOut' } },
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
rest: { scale: 1, backgroundColor: 'rgba(255,255,255,0)' },
|
||||
hover: { scale: 1.05, backgroundColor: 'rgba(245,158,11,0.1)', transition: { duration: 0.2 } },
|
||||
tap: { scale: 0.95 },
|
||||
};
|
||||
|
||||
const renderTooltip = (id, label) => {
|
||||
if (tooltip !== id) return null;
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||
>
|
||||
<span className="relative">
|
||||
{label}
|
||||
<span
|
||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed z-50"
|
||||
style={positionStyle}
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/60 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/95">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('addAdmin')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/admin/add-admin"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl bg-amber-50 border border-amber-200 text-amber-600 hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-6 h-6" />
|
||||
</Link>
|
||||
{renderTooltip('addAdmin', 'إضافة أدمن')}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('editPrivacy')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/admin/privacy"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Shield className="w-6 h-6" />
|
||||
</Link>
|
||||
{renderTooltip('editPrivacy', 'تعديل سياسة الخصوصية')}
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('favorites')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/favorites"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
{favorites.length > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
{favorites.length}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('favorites', 'المفضلة')}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('notifications')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
{unreadCount}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('notifications', 'الإشعارات')}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('payments')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/payments"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
</Link>
|
||||
{renderTooltip('payments', 'المدفوعات')}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
165
app/components/NotificationHandler.js
Normal file
165
app/components/NotificationHandler.js
Normal file
@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { initializeApp, getApps } from "firebase/app";
|
||||
import { getMessaging, getToken, onMessage } from "firebase/messaging";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
export default function NotificationHandler() {
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
function checkAuth() {
|
||||
if (initialized.current) return;
|
||||
|
||||
if (!AuthService.getToken()) return;
|
||||
initialized.current = true;
|
||||
|
||||
if ("Notification" in window) {
|
||||
if (Notification.permission === "default") {
|
||||
setShowPrompt(true);
|
||||
} else if (Notification.permission === "granted") {
|
||||
setupFCM();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Also check when auth token changes (login via client-side navigation)
|
||||
const interval = setInterval(() => {
|
||||
if (!initialized.current && AuthService.getToken()) {
|
||||
checkAuth();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Check on route change (visibility)
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") checkAuth();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function setupFCM() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
|
||||
const messaging = getMessaging(app);
|
||||
|
||||
const fcmToken = await getToken(messaging, {
|
||||
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
|
||||
if (fcmToken) {
|
||||
console.log("[FCM] Token:", fcmToken.substring(0, 20) + "...");
|
||||
|
||||
const authToken = AuthService.getToken();
|
||||
if (authToken) {
|
||||
const apiBase = "https://45.93.137.91.nip.io/api";
|
||||
await fetch(`${apiBase}/User/SetFCMToken`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token: fcmToken, deviceType: 2 }),
|
||||
});
|
||||
console.log("[FCM] Token sent to backend");
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(messaging, (payload) => {
|
||||
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
|
||||
const body = payload.notification?.body || payload.data?.body || "";
|
||||
setNotification({ title, body });
|
||||
setTimeout(() => setNotification(null), 5000);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[FCM] Setup error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable() {
|
||||
setShowPrompt(false);
|
||||
|
||||
// This MUST be synchronous from a user gesture
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log("[FCM] Permission result:", permission);
|
||||
|
||||
if (permission === "granted") {
|
||||
await setupFCM();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPrompt && (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">🔔</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-gray-900 text-sm">تفعيل الإشعارات</p>
|
||||
<p className="text-gray-600 text-sm mt-0.5">اسمح بالإشعارات للبقاء على اطلاع بحجوزاتك وعروضنا.</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تفعيل
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPrompt(false)}
|
||||
className="px-4 py-1.5 text-gray-500 text-sm hover:text-gray-700 transition-colors"
|
||||
>
|
||||
لاحقاً
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification && (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999] animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">🏠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-gray-900 text-sm">{notification.title}</p>
|
||||
<p className="text-gray-600 text-sm mt-0.5">{notification.body}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className="text-gray-400 hover:text-gray-600 flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
||||
import { COMMISSION_TYPE, CITIES } from '@/app/utils/constants';
|
||||
import { CommissionType, CitiesList } from '@/app/enums';
|
||||
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
||||
|
||||
export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
@ -25,7 +25,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
|
||||
dailyPrice: 0,
|
||||
commissionRate: 5,
|
||||
commissionType: COMMISSION_TYPE.FROM_OWNER,
|
||||
commissionType: CommissionType.FROM_OWNER,
|
||||
|
||||
securityDeposit: 0,
|
||||
|
||||
@ -38,11 +38,21 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
const [selectedFeatures, setSelectedFeatures] = useState([]);
|
||||
|
||||
const featuresList = [
|
||||
'swimmingPool', 'privateGarden', 'parking', 'superLuxFinish',
|
||||
'equippedKitchen', 'centralHeating', 'balcony', 'securitySystem',
|
||||
'largeGarden', 'receptionHall', 'maidRoom', 'garage',
|
||||
'seaView', 'centralAC', 'fruitGarden', 'storage'
|
||||
];
|
||||
'مسبح',
|
||||
'حديقة خاصة',
|
||||
'موقف سيارات',
|
||||
'مطبخ مجهز',
|
||||
'تدفئة مركزية',
|
||||
'بلكونة',
|
||||
'نظام أمني',
|
||||
'حديقة كبيرة',
|
||||
'صالة استقبال',
|
||||
'غرفة خادمة',
|
||||
'كراج',
|
||||
'إطلالة بحرية',
|
||||
'تكييف مركزي',
|
||||
'مخزن'
|
||||
];
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
@ -76,11 +86,11 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
const commission = (dailyPrice * commissionRate) / 100;
|
||||
|
||||
switch(commissionType) {
|
||||
case COMMISSION_TYPE.FROM_TENANT:
|
||||
case CommissionType.FROM_TENANT:
|
||||
return dailyPrice + commission;
|
||||
case COMMISSION_TYPE.FROM_OWNER:
|
||||
case CommissionType.FROM_OWNER:
|
||||
return dailyPrice;
|
||||
case COMMISSION_TYPE.FROM_BOTH:
|
||||
case CommissionType.FROM_BOTH:
|
||||
return dailyPrice + (commission / 2);
|
||||
default:
|
||||
return dailyPrice;
|
||||
@ -121,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
required
|
||||
>
|
||||
<option value="">اختر المدينة</option>
|
||||
{Object.values(CITIES).map(city => (
|
||||
{CitiesList.map(city => (
|
||||
<option key={city} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
@ -222,8 +232,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
<input
|
||||
type="radio"
|
||||
name="commissionType"
|
||||
value={COMMISSION_TYPE.FROM_OWNER}
|
||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_OWNER}
|
||||
value={CommissionType.FROM_OWNER}
|
||||
checked={formData.commissionType === CommissionType.FROM_OWNER}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
/>
|
||||
<span>من المالك</span>
|
||||
@ -232,8 +242,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
<input
|
||||
type="radio"
|
||||
name="commissionType"
|
||||
value={COMMISSION_TYPE.FROM_TENANT}
|
||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_TENANT}
|
||||
value={CommissionType.FROM_TENANT}
|
||||
checked={formData.commissionType === CommissionType.FROM_TENANT}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
/>
|
||||
<span>من المستأجر</span>
|
||||
@ -242,8 +252,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
<input
|
||||
type="radio"
|
||||
name="commissionType"
|
||||
value={COMMISSION_TYPE.FROM_BOTH}
|
||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_BOTH}
|
||||
value={CommissionType.FROM_BOTH}
|
||||
checked={formData.commissionType === CommissionType.FROM_BOTH}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
/>
|
||||
<span>من الاثنين</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,9 +13,15 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
Shield
|
||||
Shield,
|
||||
FileText,
|
||||
Printer,
|
||||
X,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { formatCurrency } from '@/app/utils/calculations';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export default function LedgerBook({ userType = 'admin' }) {
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
@ -28,6 +34,7 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
securityDeposits: 0,
|
||||
commissionEarned: 0
|
||||
});
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions();
|
||||
@ -144,30 +151,239 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const exportToExcel = () => {
|
||||
const csvContent = [
|
||||
['التاريخ', 'الوصف', 'من', 'إلى', 'المبلغ', 'العمولة', 'الحالة'],
|
||||
...filteredTransactions.map(t => [
|
||||
t.date,
|
||||
t.description,
|
||||
t.fromUser,
|
||||
t.toUser,
|
||||
t.amount,
|
||||
t.commission,
|
||||
t.status
|
||||
])
|
||||
].map(row => row.join(',')).join('\n');
|
||||
const exportToExcel = async () => {
|
||||
if (filteredTransactions.length === 0) {
|
||||
toast.error('لا توجد معاملات للتصدير');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ledger_${new Date().toISOString()}.csv`;
|
||||
a.click();
|
||||
setIsExporting(true);
|
||||
toast.loading('جاري تصدير البيانات...', { id: 'export' });
|
||||
|
||||
try {
|
||||
const exportData = filteredTransactions.map(t => ({
|
||||
'رقم العملية': t.id,
|
||||
'التاريخ': t.date,
|
||||
'نوع العملية': t.type === 'rent_payment' ? 'دفعة إيجار' :
|
||||
t.type === 'security_deposit' ? 'سلفة ضمان' :
|
||||
t.type === 'commission' ? 'عمولة' : 'أخرى',
|
||||
'الوصف': t.description,
|
||||
'من': t.fromUser,
|
||||
'إلى': t.toUser,
|
||||
'المبلغ (ل.س)': t.amount,
|
||||
'العمولة (ل.س)': t.commission || 0,
|
||||
'الحالة': t.status === 'completed' ? 'مكتمل' :
|
||||
t.status === 'pending' ? 'معلق' :
|
||||
t.status === 'pending_refund' ? 'بإنتظار الاسترداد' : 'مؤكد',
|
||||
}));
|
||||
|
||||
const summaryRow = {
|
||||
'رقم العملية': '',
|
||||
'التاريخ': '',
|
||||
'نوع العملية': '',
|
||||
'الوصف': '',
|
||||
'من': '',
|
||||
'إلى': '',
|
||||
'المبلغ (ل.س)': summary.totalRevenue,
|
||||
'العمولة (ل.س)': summary.commissionEarned,
|
||||
'الحالة': ''
|
||||
};
|
||||
|
||||
exportData.push(summaryRow);
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
|
||||
const columnWidths = [
|
||||
{ wch: 12 }, // رقم العملية
|
||||
{ wch: 12 }, // التاريخ
|
||||
{ wch: 12 }, // نوع العملية
|
||||
{ wch: 30 }, // الوصف
|
||||
{ wch: 20 }, // من
|
||||
{ wch: 20 }, // إلى
|
||||
{ wch: 15 }, // المبلغ
|
||||
{ wch: 15 }, // العمولة
|
||||
{ wch: 12 }, // الحالة
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref']);
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const address = XLSX.utils.encode_col(C) + '1';
|
||||
if (!worksheet[address]) continue;
|
||||
worksheet[address].s = {
|
||||
font: { bold: true, sz: 12 },
|
||||
fill: { fgColor: { rgb: "F59E0B" } },
|
||||
alignment: { horizontal: "center", vertical: "center" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'دفتر الحسابات');
|
||||
|
||||
const fileName = `دفتر_الحسابات_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
|
||||
toast.success(`تم تصدير ${filteredTransactions.length} معاملة بنجاح!`, { id: 'export' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting to Excel:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير البيانات', { id: 'export' });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const printReport = () => {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>تقرير دفتر الحسابات</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Cairo', Arial, sans-serif;
|
||||
padding: 20px;
|
||||
direction: rtl;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #f59e0b;
|
||||
}
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.summary-card {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #f59e0b;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
th {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="title">تقرير دفتر الحسابات</div>
|
||||
<div class="subtitle">الفترة: ${dateRange.start || 'بداية السجلات'} - ${dateRange.end || 'حتى الآن'}</div>
|
||||
<div class="subtitle">تاريخ التقرير: ${new Date().toLocaleDateString('ar-SA')}</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div>إجمالي الإيرادات</div>
|
||||
<div class="summary-value">${formatCurrency(summary.totalRevenue)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>أرباح المنصة</div>
|
||||
<div class="summary-value">${formatCurrency(summary.commissionEarned)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>سلف الضمان</div>
|
||||
<div class="summary-value">${formatCurrency(summary.securityDeposits)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>المدفوعات المعلقة</div>
|
||||
<div class="summary-value">${formatCurrency(summary.pendingPayments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>التاريخ</th>
|
||||
<th>الوصف</th>
|
||||
<th>من</th>
|
||||
<th>إلى</th>
|
||||
<th>المبلغ</th>
|
||||
<th>العمولة</th>
|
||||
<th>الحالة</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${filteredTransactions.map(t => `
|
||||
<tr>
|
||||
<td>${t.date}</td>
|
||||
<td>${t.description}</td>
|
||||
<td>${t.fromUser}</td>
|
||||
<td>${t.toUser}</td>
|
||||
<td>${formatCurrency(t.amount)}</td>
|
||||
<td>${t.commission ? formatCurrency(t.commission) : '-'}</td>
|
||||
<td>${t.status === 'completed' ? 'مكتمل' : t.status === 'pending' ? 'معلق' : 'بإنتظار الرد'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<p>تقرير صادر عن نظام SweetHome لإدارة العقارات</p>
|
||||
<p>جميع الحقوق محفوظة © ${new Date().getFullYear()}</p>
|
||||
</div>
|
||||
|
||||
<div class="no-print" style="text-align: center; margin-top: 20px;">
|
||||
<button onclick="window.print()" style="padding: 10px 20px; background: #f59e0b; color: white; border: none; border-radius: 8px; cursor: pointer;">
|
||||
طباعة التقرير
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -224,13 +440,13 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
<div className="bg-white rounded-xl p-5 shadow-sm border">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-1/2 transform -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-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -239,25 +455,63 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-500 self-center">إلى</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg flex items-center gap-2 hover:bg-green-700"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
تصدير
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
disabled={isExporting || filteredTransactions.length === 0}
|
||||
className="px-5 py-3 bg-green-600 text-white rounded-xl flex items-center gap-2 hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
جاري التصدير...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير Excel
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={printReport}
|
||||
disabled={filteredTransactions.length === 0}
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-xl flex items-center gap-2 hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
طباعة
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(dateRange.start || dateRange.end || searchTerm) && (
|
||||
<div className="mt-4 pt-4 border-t flex justify-between items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="font-medium">{filteredTransactions.length}</span> معاملة من إجمالي <span className="font-medium">{transactions.length}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDateRange({ start: '', end: '' });
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className="text-sm text-red-500 hover:text-red-600 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
إلغاء الفلترة
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
@ -307,14 +561,14 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
<span className="text-sm">{transaction.toUser}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-bold">
|
||||
<td className="px-6 py-4 text-sm font-bold text-green-600">
|
||||
{formatCurrency(transaction.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-amber-600">
|
||||
{transaction.commission ? formatCurrency(transaction.commission) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
transaction.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
transaction.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
@ -344,6 +598,7 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
أرصدة المستأجرين
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-500 text-sm">لا توجد أرصدة حالياً</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@ -12,14 +12,404 @@ import {
|
||||
Square,
|
||||
DollarSign,
|
||||
Percent,
|
||||
MoreVertical
|
||||
MoreVertical,
|
||||
X,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
User,
|
||||
Home,
|
||||
Building,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
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-md p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">تأكيد الحذف</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
هل أنت متأكد من حذف العقار: <span className="font-bold text-gray-700">"{propertyTitle}"</span>؟
|
||||
</p>
|
||||
<p className="text-xs text-red-500 mt-1">هذا الإجراء لا يمكن التراجع عنه</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
نعم، احذف
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyViewModal = ({ property, isOpen, onClose }) => {
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
||||
};
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{property.title}</h2>
|
||||
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Home className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.type === 'villa' ? 'فيلا' : property.type === 'apartment' ? 'شقة' : 'بيت'}</div>
|
||||
<div className="text-xs text-gray-500">نوع العقار</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">السعر اليومي</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Percent className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.commission}%</div>
|
||||
<div className="text-xs text-gray-500">نسبة العمولة</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Calendar className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bookings || 0}</div>
|
||||
<div className="text-xs text-gray-500">عدد الحجوزات</div>
|
||||
</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">
|
||||
<MapPin className="w-5 h-5 text-amber-500" />
|
||||
الموقع
|
||||
</h3>
|
||||
<p className="text-gray-700">{property.location}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">المواصفات</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.bedrooms}</div>
|
||||
<div className="text-xs text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.bathrooms}</div>
|
||||
<div className="text-xs text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.area}</div>
|
||||
<div className="text-xs text-gray-500">م²</div>
|
||||
</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">
|
||||
<Percent className="w-5 h-5" />
|
||||
معلومات العمولة
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">نسبة العمولة</label>
|
||||
<div className="font-bold text-amber-600">{property.commission}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">مصدر العمولة</label>
|
||||
<div className="font-bold text-amber-600">{property.commissionType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">قيمة العمولة</label>
|
||||
<div className="font-bold text-amber-600">
|
||||
{formatCurrency((property.price * property.commission) / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">حالة العقار</label>
|
||||
<div className={`inline-block px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
property.status === 'available'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyEditModal = ({ property, isOpen, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({ ...property });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
onSave(formData);
|
||||
setIsSaving(false);
|
||||
onClose();
|
||||
toast.success('تم تحديث العقار بنجاح');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
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">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-amber-100 text-sm mt-1">يمكنك تعديل معلومات العقار</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
اسم العقار
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع العقار
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({...formData, type: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="villa">فيلا</option>
|
||||
<option value="apartment">شقة</option>
|
||||
<option value="house">بيت</option>
|
||||
<option value="studio">استوديو</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
الموقع
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({...formData, location: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
السعر اليومي (ل.س)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({...formData, price: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نسبة العمولة (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.commission}
|
||||
onChange={(e) => setFormData({...formData, commission: parseFloat(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
مصدر العمولة
|
||||
</label>
|
||||
<select
|
||||
value={formData.commissionType}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="من المالك">من المالك</option>
|
||||
<option value="من المستأجر">من المستأجر</option>
|
||||
<option value="من الاثنين">من الاثنين</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الغرف
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.bedrooms}
|
||||
onChange={(e) => setFormData({...formData, bedrooms: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الحمامات
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.bathrooms}
|
||||
onChange={(e) => setFormData({...formData, bathrooms: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
المساحة (م²)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.area}
|
||||
onChange={(e) => setFormData({...formData, area: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حالة العقار
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="available">متاح</option>
|
||||
<option value="booked">محجوز</option>
|
||||
<option value="maintenance">صيانة</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex-1 bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MoreActionsMenu = ({ property, isOpen, onClose, onViewBookings, onViewReports }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="absolute left-0 mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50"
|
||||
>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function PropertiesTable() {
|
||||
const [properties, setProperties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'luxuryVillaDamascus',
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
type: 'villa',
|
||||
location: 'دمشق, المزة',
|
||||
price: 500000,
|
||||
@ -33,7 +423,7 @@ export default function PropertiesTable() {
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'modernApartmentAleppo',
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
type: 'apartment',
|
||||
location: 'حلب, الشهباء',
|
||||
price: 250000,
|
||||
@ -47,6 +437,11 @@ export default function PropertiesTable() {
|
||||
}
|
||||
]);
|
||||
|
||||
const [viewModal, setViewModal] = useState({ isOpen: false, property: null });
|
||||
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, property: null });
|
||||
const [moreMenu, setMoreMenu] = useState({ isOpen: false, property: null, anchorEl: null });
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
||||
};
|
||||
@ -71,8 +466,50 @@ export default function PropertiesTable() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleView = (property) => {
|
||||
setViewModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const handleEdit = (property) => {
|
||||
setEditModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const handleDelete = (property) => {
|
||||
setDeleteModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteModal.property) {
|
||||
setProperties(prev => prev.filter(p => p.id !== deleteModal.property.id));
|
||||
setDeleteModal({ isOpen: false, property: null });
|
||||
toast.success('تم حذف العقار بنجاح');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = (updatedProperty) => {
|
||||
setProperties(prev => prev.map(p =>
|
||||
p.id === updatedProperty.id ? updatedProperty : p
|
||||
));
|
||||
toast.success('تم تحديث العقار بنجاح');
|
||||
};
|
||||
|
||||
const handleMoreClick = (event, property) => {
|
||||
event.stopPropagation();
|
||||
setMoreMenu({ isOpen: true, property, anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
const handleViewBookings = (property) => {
|
||||
toast.success(`جاري عرض حجوزات ${property.title}`);
|
||||
};
|
||||
|
||||
const handleViewReports = (property) => {
|
||||
toast.success(`جاري عرض تقرير أرباح ${property.title}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
@ -97,7 +534,11 @@ export default function PropertiesTable() {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{property.title}</div>
|
||||
<div className="text-xs text-gray-500">{property.type}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{property.type === 'villa' ? 'فيلا' :
|
||||
property.type === 'apartment' ? 'شقة' :
|
||||
property.type === 'house' ? 'بيت' : 'استوديو'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
@ -125,20 +566,38 @@ export default function PropertiesTable() {
|
||||
<td className="px-4 py-3">
|
||||
{getStatusBadge(property.status)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3 relative">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button className="p-1 hover:bg-blue-100 rounded text-blue-600">
|
||||
<button
|
||||
onClick={() => handleView(property)}
|
||||
className="p-1 hover:bg-blue-100 rounded text-blue-600 transition-colors"
|
||||
title="عرض التفاصيل"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-amber-100 rounded text-amber-600">
|
||||
<button
|
||||
onClick={() => handleEdit(property)}
|
||||
className="p-1 hover:bg-amber-100 rounded text-amber-600 transition-colors"
|
||||
title="تعديل العقار"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-red-100 rounded text-red-600">
|
||||
<button
|
||||
onClick={() => handleDelete(property)}
|
||||
className="p-1 hover:bg-red-100 rounded text-red-600 transition-colors"
|
||||
title="حذف العقار"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-gray-100 rounded">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
{moreMenu.isOpen && moreMenu.property?.id === property.id && (
|
||||
<MoreActionsMenu
|
||||
property={property}
|
||||
isOpen={moreMenu.isOpen}
|
||||
onClose={() => setMoreMenu({ isOpen: false, property: null, anchorEl: null })}
|
||||
onViewBookings={handleViewBookings}
|
||||
onViewReports={handleViewReports}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
@ -152,6 +611,26 @@ export default function PropertiesTable() {
|
||||
<p className="text-gray-500">لا توجد عقارات مضافة بعد</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PropertyViewModal
|
||||
property={viewModal.property}
|
||||
isOpen={viewModal.isOpen}
|
||||
onClose={() => setViewModal({ isOpen: false, property: null })}
|
||||
/>
|
||||
|
||||
<PropertyEditModal
|
||||
property={editModal.property}
|
||||
isOpen={editModal.isOpen}
|
||||
onClose={() => setEditModal({ isOpen: false, property: null })}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, property: null })}
|
||||
onConfirm={confirmDelete}
|
||||
propertyTitle={deleteModal.property?.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
@ -11,8 +11,445 @@ import {
|
||||
DollarSign,
|
||||
Search,
|
||||
Filter,
|
||||
Eye
|
||||
Eye,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
Users,
|
||||
Award,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const FilterDialog = ({ isOpen, onClose, filters, onApplyFilters, onResetFilters }) => {
|
||||
const [localFilters, setLocalFilters] = useState({ ...filters });
|
||||
|
||||
const identityTypes = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'syrian', label: 'هوية سورية' },
|
||||
{ id: 'passport', label: 'جواز سفر' }
|
||||
];
|
||||
|
||||
const bookingRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: '0-5', label: '0 - 5 حجوزات' },
|
||||
{ id: '5-10', label: '5 - 10 حجوزات' },
|
||||
{ id: '10-20', label: '10 - 20 حجوزات' },
|
||||
{ id: '20+', label: 'أكثر من 20 حجز' }
|
||||
];
|
||||
|
||||
const spendingRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: '0-500000', label: 'أقل من 500,000 ل.س' },
|
||||
{ id: '500000-1000000', label: '500,000 - 1,000,000 ل.س' },
|
||||
{ id: '1000000-5000000', label: '1,000,000 - 5,000,000 ل.س' },
|
||||
{ id: '5000000+', label: 'أكثر من 5,000,000 ل.س' }
|
||||
];
|
||||
|
||||
const dateRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'today', label: 'اليوم' },
|
||||
{ id: 'week', label: 'آخر 7 أيام' },
|
||||
{ id: 'month', label: 'آخر 30 يوم' },
|
||||
{ id: 'year', label: 'آخر 12 شهر' }
|
||||
];
|
||||
|
||||
const applyFilters = () => {
|
||||
onApplyFilters(localFilters);
|
||||
onClose();
|
||||
toast.success('تم تطبيق الفلاتر بنجاح');
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
const resetData = {
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
};
|
||||
setLocalFilters(resetData);
|
||||
onResetFilters();
|
||||
onClose();
|
||||
toast.success('تم إعادة تعيين الفلاتر');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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-blue-600 to-blue-700 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
تصفية متقدمة
|
||||
</h2>
|
||||
<p className="text-blue-100 text-sm mt-1">حدد معايير التصفية المطلوبة</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع الهوية
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{identityTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setLocalFilters({...localFilters, identityType: type.id})}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
localFilters.identityType === type.id
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الحجوزات
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="من"
|
||||
value={localFilters.minBookings}
|
||||
onChange={(e) => setLocalFilters({...localFilters, minBookings: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="إلى"
|
||||
value={localFilters.maxBookings}
|
||||
onChange={(e) => setLocalFilters({...localFilters, maxBookings: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bookingRanges.slice(1).map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => {
|
||||
const [min, max] = range.id.split('-');
|
||||
setLocalFilters({
|
||||
...localFilters,
|
||||
minBookings: min,
|
||||
maxBookings: max === '5' ? '5' : max === '10' ? '10' : max === '20' ? '20' : '1000'
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
إجمالي الإنفاق (ل.س)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="من"
|
||||
value={localFilters.minSpending}
|
||||
onChange={(e) => setLocalFilters({...localFilters, minSpending: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="إلى"
|
||||
value={localFilters.maxSpending}
|
||||
onChange={(e) => setLocalFilters({...localFilters, maxSpending: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{spendingRanges.slice(1).map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => {
|
||||
const [min, max] = range.id.split('-');
|
||||
setLocalFilters({
|
||||
...localFilters,
|
||||
minSpending: min,
|
||||
maxSpending: max === '500000' ? '500000' : max === '1000000' ? '1000000' : max === '5000000' ? '5000000' : '999999999'
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
فترة التسجيل
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{dateRanges.map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => setLocalFilters({...localFilters, dateRange: range.id})}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
localFilters.dateRange === range.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localFilters.activeOnly}
|
||||
onChange={(e) => setLocalFilters({...localFilters, activeOnly: e.target.checked, inactiveOnly: false})}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">مستخدمون لديهم حجوزات نشطة فقط</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localFilters.inactiveOnly}
|
||||
onChange={(e) => setLocalFilters({...localFilters, inactiveOnly: e.target.checked, activeOnly: false})}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">مستخدمون بدون حجوزات نشطة</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إعادة تعيين
|
||||
</button>
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
تطبيق الفلاتر
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserDetailsModal = ({ user, isOpen, onClose }) => {
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const userBookings = [
|
||||
{
|
||||
id: 'BK001',
|
||||
property: 'فيلا فاخرة في المزة',
|
||||
startDate: '2024-03-10',
|
||||
endDate: '2024-03-15',
|
||||
amount: 2500000,
|
||||
status: 'completed'
|
||||
},
|
||||
{
|
||||
id: 'BK002',
|
||||
property: 'شقة حديثة في الشهباء',
|
||||
startDate: '2024-02-20',
|
||||
endDate: '2024-02-25',
|
||||
amount: 1250000,
|
||||
status: 'completed'
|
||||
},
|
||||
{
|
||||
id: 'BK003',
|
||||
property: 'بيت عائلي في بابا عمرو',
|
||||
startDate: '2024-04-01',
|
||||
endDate: '2024-04-10',
|
||||
amount: 3500000,
|
||||
status: 'confirmed'
|
||||
}
|
||||
];
|
||||
|
||||
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-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
تفاصيل المستخدم
|
||||
</h2>
|
||||
<p className="text-blue-100 text-sm mt-1">{user.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-blue-500" />
|
||||
معلومات شخصية
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">الاسم الكامل:</span>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">البريد الإلكتروني:</span>
|
||||
<span className="font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">رقم الهاتف:</span>
|
||||
<span className="font-medium">{user.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">تاريخ التسجيل:</span>
|
||||
<span className="font-medium">{user.joinDate}</span>
|
||||
</div>
|
||||
</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">
|
||||
<Shield className="w-4 h-4 text-blue-500" />
|
||||
معلومات الهوية
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">نوع الهوية:</span>
|
||||
<span className="font-medium">
|
||||
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">رقم الهوية:</span>
|
||||
<span className="font-medium">{user.identityNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي الحجوزات</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{user.activeBookings}</div>
|
||||
<div className="text-sm text-gray-600">حجوزات نشطة</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-amber-600">{formatCurrency(user.totalSpent)}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي المنصرف</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-blue-500" />
|
||||
سجل الحجوزات
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{userBookings.map((booking) => (
|
||||
<div key={booking.id} className="bg-gray-50 p-4 rounded-xl flex flex-col md:flex-row justify-between items-start md:items-center gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.property}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mt-1">
|
||||
<CalendarDays className="w-3 h-3" />
|
||||
{booking.startDate} - {booking.endDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.amount)}</div>
|
||||
<div className="text-xs text-gray-500">المبلغ الإجمالي</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
booking.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{booking.status === 'completed' ? 'مكتمل' : 'مؤكد'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{userBookings.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
||||
<p>لا توجد حجوزات سابقة</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function UsersList() {
|
||||
const [users, setUsers] = useState([
|
||||
@ -44,30 +481,194 @@ export default function UsersList() {
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showFilterDialog, setShowFilterDialog] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
});
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.name.includes(searchTerm) ||
|
||||
user.email.includes(searchTerm) ||
|
||||
user.phone.includes(searchTerm)
|
||||
);
|
||||
const applyFilters = (newFilters) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
});
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user => {
|
||||
if (searchTerm && !user.name.includes(searchTerm) && !user.email.includes(searchTerm) && !user.phone.includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.identityType !== 'all' && user.identityType !== filters.identityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.minBookings && user.totalBookings < parseInt(filters.minBookings)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxBookings && user.totalBookings > parseInt(filters.maxBookings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.minSpending && user.totalSpent < parseInt(filters.minSpending)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxSpending && user.totalSpent > parseInt(filters.maxSpending)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.activeOnly && user.activeBookings === 0) {
|
||||
return false;
|
||||
}
|
||||
if (filters.inactiveOnly && user.activeBookings > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.dateRange !== 'all') {
|
||||
const joinDate = new Date(user.joinDate);
|
||||
const today = new Date();
|
||||
const diffDays = Math.floor((today - joinDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
switch(filters.dateRange) {
|
||||
case 'today':
|
||||
if (joinDate.toDateString() !== today.toDateString()) return false;
|
||||
break;
|
||||
case 'week':
|
||||
if (diffDays > 7) return false;
|
||||
break;
|
||||
case 'month':
|
||||
if (diffDays > 30) return false;
|
||||
break;
|
||||
case 'year':
|
||||
if (diffDays > 365) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const filterStats = {
|
||||
total: filteredUsers.length,
|
||||
filtered: filteredUsers.length !== users.length
|
||||
};
|
||||
|
||||
const getActiveFiltersCount = () => {
|
||||
let count = 0;
|
||||
if (filters.identityType !== 'all') count++;
|
||||
if (filters.minBookings || filters.maxBookings) count++;
|
||||
if (filters.minSpending || filters.maxSpending) count++;
|
||||
if (filters.dateRange !== 'all') count++;
|
||||
if (filters.activeOnly || filters.inactiveOnly) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث عن مستخدم..."
|
||||
placeholder="بحث عن مستخدم بالاسم أو البريد أو الهاتف..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pr-10 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full pr-12 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button className="px-4 py-2 border rounded-lg flex items-center gap-2 hover:bg-gray-50">
|
||||
<Filter className="w-4 h-4" />
|
||||
تصفية
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilterDialog(true)}
|
||||
className={`px-5 py-3 rounded-xl font-medium flex items-center gap-2 transition-all ${
|
||||
getActiveFiltersCount() > 0
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
تصفية متقدمة
|
||||
{getActiveFiltersCount() > 0 && (
|
||||
<span className="ml-1 bg-white text-blue-600 rounded-full w-5 h-5 text-xs flex items-center justify-center">
|
||||
{getActiveFiltersCount()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{filterStats.filtered && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="px-5 py-3 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
إعادة تعيين
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getActiveFiltersCount() > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-blue-50 rounded-xl">
|
||||
<span className="text-sm text-blue-800 font-medium">الفلاتر النشطة:</span>
|
||||
{filters.identityType !== 'all' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
{filters.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</span>
|
||||
)}
|
||||
{(filters.minBookings || filters.maxBookings) && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
الحجوزات: {filters.minBookings || '0'} - {filters.maxBookings || '∞'}
|
||||
</span>
|
||||
)}
|
||||
{(filters.minSpending || filters.maxSpending) && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
الإنفاق: {parseInt(filters.minSpending || 0).toLocaleString()} - {parseInt(filters.maxSpending || '∞').toLocaleString()} ل.س
|
||||
</span>
|
||||
)}
|
||||
{filters.dateRange !== 'all' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
{filters.dateRange === 'today' ? 'اليوم' :
|
||||
filters.dateRange === 'week' ? 'آخر 7 أيام' :
|
||||
filters.dateRange === 'month' ? 'آخر 30 يوم' : 'آخر 12 شهر'}
|
||||
</span>
|
||||
)}
|
||||
{filters.activeOnly && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-lg text-xs">
|
||||
لديهم حجوزات نشطة
|
||||
</span>
|
||||
)}
|
||||
{filters.inactiveOnly && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
|
||||
بدون حجوزات نشطة
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
عرض <span className="font-bold text-gray-900">{filteredUsers.length}</span> مستخدم
|
||||
{filterStats.filtered && (
|
||||
<span className="text-gray-500 mr-1">(من {users.length})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@ -76,40 +677,46 @@ export default function UsersList() {
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-blue-600" />
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white text-xl font-bold shadow-lg">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold">{user.name}</h3>
|
||||
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-600">
|
||||
<h3 className="font-bold text-gray-900 text-lg">{user.name}</h3>
|
||||
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
<Mail className="w-4 h-4" />
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
<Phone className="w-4 h-4" />
|
||||
{user.phone}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="text-center min-w-[80px]">
|
||||
<div className="text-xl font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="text-xs text-gray-500">إجمالي الحجوزات</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-600">{user.activeBookings}</div>
|
||||
<div className="text-center min-w-[80px]">
|
||||
<div className={`text-xl font-bold ${user.activeBookings > 0 ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{user.activeBookings}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">حجوزات نشطة</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-600">
|
||||
<div className="text-center min-w-[100px]">
|
||||
<div className="text-xl font-bold text-amber-600">
|
||||
{user.totalSpent.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">إجمالي المنصرف</div>
|
||||
@ -118,7 +725,7 @@ export default function UsersList() {
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm flex items-center gap-1 hover:bg-blue-700"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
عرض التفاصيل
|
||||
@ -128,63 +735,39 @@ export default function UsersList() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white rounded-xl w-full max-w-2xl p-6"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">تفاصيل المستخدم</h2>
|
||||
<button
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">الاسم</label>
|
||||
<div className="font-medium">{selectedUser.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">البريد الإلكتروني</label>
|
||||
<div className="font-medium">{selectedUser.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">رقم الهاتف</label>
|
||||
<div className="font-medium">{selectedUser.phone}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">نوع الهوية</label>
|
||||
<div className="font-medium">
|
||||
{selectedUser.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">رقم الهوية</label>
|
||||
<div className="font-medium">{selectedUser.identityNumber}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">تاريخ التسجيل</label>
|
||||
<div className="font-medium">{selectedUser.joinDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-bold mb-3">سجل الحجوزات</h3>
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
لا توجد حجوزات سابقة
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
{filteredUsers.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد نتائج</h3>
|
||||
<p className="text-gray-500">لا يوجد مستخدمون يطابقون معايير البحث</p>
|
||||
{(searchTerm || getActiveFiltersCount() > 0) && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
إعادة تعيين الفلاتر
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<FilterDialog
|
||||
isOpen={showFilterDialog}
|
||||
onClose={() => setShowFilterDialog(false)}
|
||||
filters={filters}
|
||||
onApplyFilters={applyFilters}
|
||||
onResetFilters={resetFilters}
|
||||
/>
|
||||
|
||||
<UserDetailsModal
|
||||
user={selectedUser}
|
||||
isOpen={!!selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
|
||||
import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
|
||||
|
||||
export default function HeroSearch({ onSearch }) {
|
||||
export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('rent');
|
||||
const [activeTab, setActiveTab] = useState('buy');
|
||||
const [filters, setFilters] = useState({
|
||||
city: '',
|
||||
propertyType: '',
|
||||
priceRange: '',
|
||||
identityType: 'syrian'
|
||||
city: 'all',
|
||||
propertyType: 'all',
|
||||
priceRange: 'all',
|
||||
identityType: 'syrian',
|
||||
ownerSource: 'all',
|
||||
rentPeriod: 'all',
|
||||
availableToday: false
|
||||
});
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
|
||||
const cities = [
|
||||
{ id: 'all', label: 'جميع المدن' },
|
||||
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
|
||||
|
||||
const propertyTypes = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'apartment', label: 'شقة' },
|
||||
{ id: 'villa', label: 'فيلا' },
|
||||
{ id: 'house', label: 'بيت' },
|
||||
{ id: 'studio', label: 'استوديو' }
|
||||
{ id: 'apartment', label: 'شقق سكنية' },
|
||||
{ id: 'studio', label: 'استوديو' },
|
||||
{ id: 'commercial', label: 'عقار تجاري' },
|
||||
{ id: 'villa', label: 'فيلا / مزرعة' }
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
@ -46,17 +51,45 @@ export default function HeroSearch({ onSearch }) {
|
||||
{ id: 'passport', label: 'جواز سفر' }
|
||||
];
|
||||
|
||||
const ownerSources = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'owner', label: 'من المالك' },
|
||||
{ id: 'agency', label: 'من مكتب عقاري' }
|
||||
];
|
||||
|
||||
const rentPeriods = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'daily', label: 'إيجار يومي' },
|
||||
{ id: 'monthly', label: 'إيجار شهري' }
|
||||
];
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch({
|
||||
...filters,
|
||||
propertyType: filters.propertyType || 'all',
|
||||
mode: activeTab,
|
||||
city: filters.city || 'all',
|
||||
priceRange: filters.priceRange || 'all'
|
||||
propertyType: filters.propertyType || 'all',
|
||||
priceRange: filters.priceRange || 'all',
|
||||
ownerSource: filters.ownerSource || 'all',
|
||||
rentPeriod: filters.rentPeriod || 'all'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<>
|
||||
<motion.div
|
||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
|
||||
{['rent', 'buy', 'sell'].map((tab) => (
|
||||
<motion.button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||
activeTab === tab
|
||||
? 'bg-amber-500 text-white'
|
||||
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
|
||||
<select
|
||||
value={filters.ownerSource}
|
||||
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{ownerSources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
|
||||
<select
|
||||
value={filters.rentPeriod}
|
||||
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{rentPeriods.map((period) => (
|
||||
<option key={period.id} value={period.id}>
|
||||
{period.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
|
||||
<label className="mt-4 flex items-center gap-3 text-white text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.availableToday}
|
||||
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||
/>
|
||||
<span className="font-medium">عرض فقط العقارات المتاحة من اليوم</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
onClick={handleSearch}
|
||||
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showLoginDialog && !isAuthenticated && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<ShieldCheck className="w-7 h-7 text-amber-500" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
|
||||
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
216
app/components/ratings/RatingForm.js
Normal file
216
app/components/ratings/RatingForm.js
Normal file
@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, Edit2, X, Check, Clock } from 'lucide-react';
|
||||
import StarRating from './StarRating.js';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { rateProperty, rateCustomer, getUserPropertyRating, canRateProperty } from '../../utils/ratings.js';
|
||||
|
||||
const RatingForm = ({
|
||||
propertyId,
|
||||
userId,
|
||||
propertyOwner = false,
|
||||
initialRating = 0,
|
||||
initialComment = '',
|
||||
onSubmitSuccess
|
||||
}) => {
|
||||
const [rating, setRating] = useState(initialRating);
|
||||
const [comment, setComment] = useState(initialComment);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [userRating, setUserRating] = useState(null);
|
||||
|
||||
// Check if user has already rated
|
||||
useState(() => {
|
||||
async function fetchUserRating() {
|
||||
try {
|
||||
const rating = await getUserPropertyRating(propertyId, userId);
|
||||
if (rating) {
|
||||
setUserRating(rating);
|
||||
setRating(rating.rating);
|
||||
setComment(rating.comment || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RatingForm] Failed to fetch user rating:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (propertyId && userId) {
|
||||
fetchUserRating();
|
||||
}
|
||||
}, [propertyId, userId]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!rating) {
|
||||
toast.error('يرجى إعطاء تقييم من 1 إلى 5 نجوم');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const ratingData = {
|
||||
propertyId,
|
||||
customerId: userId,
|
||||
rating,
|
||||
comment: comment.trim() || null
|
||||
};
|
||||
|
||||
await rateProperty(ratingData);
|
||||
|
||||
toast.success('تم إرسال التقييم بنجاح!');
|
||||
|
||||
// Reset form
|
||||
setRating(0);
|
||||
setComment('');
|
||||
setShowForm(false);
|
||||
|
||||
if (onSubmitSuccess) {
|
||||
onSubmitSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RatingForm] Failed to submit rating:', error);
|
||||
toast.error('حدث خطأ أثناء إرسال التقييم. يرجى المحاولة مرة أخرى.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setShowForm(true);
|
||||
setRating(userRating?.rating || 0);
|
||||
setComment(userRating?.comment || '');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setRating(userRating?.rating || 0);
|
||||
setComment(userRating?.comment || '');
|
||||
};
|
||||
|
||||
if (!propertyId || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
{/* Display existing rating */}
|
||||
{userRating && !showForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gray-50 rounded-xl p-4 border border-gray-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-500" />
|
||||
<span className="font-medium text-gray-900">{userRating.rating}</span>
|
||||
<span className="text-sm text-gray-500">من 5</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm hover:bg-gray-200 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
تعديل
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{userRating.comment && (
|
||||
<div className="text-gray-600 text-sm mb-3 line-clamp-3">
|
||||
"{userRating.comment}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{userRating.createdAt ? new Date(userRating.createdAt).toLocaleDateString('ar-SA') : ''}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Rating form */}
|
||||
{showForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-xl p-6 border border-gray-200 shadow-sm"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تقييمك للعقار
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating
|
||||
rating={rating}
|
||||
onRatingChange={setRating}
|
||||
readOnly={false}
|
||||
size={28}
|
||||
color="#ffc107"
|
||||
/>
|
||||
<span className="text-lg font-bold text-gray-900">{rating || '1'}</span>
|
||||
<span className="text-sm text-gray-400">/5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تعليق (اختياري)
|
||||
</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="شارك تجربتك مع العقار..."
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !rating}
|
||||
className="flex-1 px-4 py-2 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Check className="w-5 h-5" />
|
||||
)}
|
||||
{loading ? 'إرسال' : 'إرسال التقييم'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Add rating button */}
|
||||
{!userRating && !showForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-amber-50 border-2 border-amber-200 rounded-xl p-4 text-center cursor-pointer hover:border-amber-300 transition-all"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
<Star className="w-8 h-8 text-amber-500 mx-auto mb-2" />
|
||||
<h3 className="font-bold text-amber-700 mb-2">قيّم هذا العقار</h3>
|
||||
<p className="text-sm text-amber-600">شارك تجربتك مع المستأجرين الآخرين</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingForm;
|
||||
149
app/components/ratings/RatingList.js
Normal file
149
app/components/ratings/RatingList.js
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star } from 'lucide-react';
|
||||
import { getPropertyRatings } from '../../utils/ratings.js';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const RatingList = ({ propertyId, userId }) => {
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReviews = async () => {
|
||||
if (!propertyId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getPropertyReviews(propertyId);
|
||||
setReviews(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[RatingList] Failed to fetch reviews:', err);
|
||||
setError('فشل تحميل التقييمات');
|
||||
setReviews([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReviews();
|
||||
}, [propertyId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<div className="w-10 h-10 border-2 border-gray-200 border-t-gray-500 rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500">جاري تحميل التقييمات...</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<p className="text-red-500">{error}</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reviews.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<p className="text-gray-500">لا توجد تقييمات حتى الآن. كن أول من يقيم هذا العقار!</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate average rating
|
||||
const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Header with average rating */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">تقييمات المستأجرين</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{reviews.length} تقييمات
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-5 h-5 ${index < Math.floor(averageRating) ? 'text-amber-500' : 'text-gray-300'}`}
|
||||
/>
|
||||
))}
|
||||
{averageRating % 1 !== 0 && (
|
||||
<Star className="w-5 h-5 text-amber-400" />
|
||||
)}
|
||||
<span className="font-bold text-gray-900 ml-2">{averageRating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews list */}
|
||||
<div className="space-y-4">
|
||||
{reviews.map((review, index) => (
|
||||
<div key={index} className="border-t border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Star className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{review.userName || 'مستأجر'}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm">
|
||||
{Array.from({ length: 5 }).map((_, starIndex) => (
|
||||
<Star
|
||||
key={starIndex}
|
||||
className={`w-4 h-4 ${starIndex < review.rating ? 'text-amber-500' : 'text-gray-300'}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-1 text-xs text-gray-500">({review.rating}/5)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{review.createdAt ? new Date(review.createdAt).toLocaleDateString('ar-SA') : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{review.comment && (
|
||||
<p className="text-gray-700 text-sm leading-relaxed">{review.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingList;
|
||||
90
app/components/ratings/StarRating.js
Normal file
90
app/components/ratings/StarRating.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const StarRating = ({
|
||||
rating,
|
||||
onRatingChange,
|
||||
maxStars = 5,
|
||||
size = 24,
|
||||
color = '#ffc107',
|
||||
readOnly = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const [hoverRating, setHoverRating] = useState(null);
|
||||
|
||||
const handleClick = (value) => {
|
||||
if (!readOnly && onRatingChange) {
|
||||
onRatingChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (value) => {
|
||||
if (!readOnly) {
|
||||
setHoverRating(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!readOnly) {
|
||||
setHoverRating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStarIcon = (index) => {
|
||||
const currentRating = hoverRating !== null ? hoverRating : rating;
|
||||
|
||||
if (currentRating > index) {
|
||||
const hasHalfStar = currentRating % 1 > 0.5 && index + 0.5 <= currentRating;
|
||||
if (hasHalfStar) {
|
||||
// For half star, we'll use a combination approach or just show full star
|
||||
// Since we don't have StarOutline, we'll approximate with full stars
|
||||
return <Star className={`w-${size} h-${size} text-${color}`} />;
|
||||
}
|
||||
return <Star className={`w-${size} h-${size} text-${color}`} />;
|
||||
}
|
||||
return <Star className={`w-${size} h-${size} text-gray-400`} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex gap-1 ${className}`} onMouseLeave={handleMouseLeave}>
|
||||
{[...Array(maxStars)].map((_, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
whileHover={{ scale: readOnly ? 1 : 1.1 }}
|
||||
onClick={() => handleClick(index + 1)}
|
||||
onMouseEnter={() => handleMouseEnter(index + 1)}
|
||||
>
|
||||
{getStarIcon(index)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StarRating;
|
||||
|
||||
// Helper functions
|
||||
export function getStarCount(rating, maxStars = 5) {
|
||||
return Math.round(rating * maxStars) / maxStars;
|
||||
}
|
||||
|
||||
export function formatRating(rating) {
|
||||
if (rating === 0) return 'لا يوجد تقييم';
|
||||
return `${rating.toFixed(1)}`; // Show 1 decimal place
|
||||
}
|
||||
|
||||
export function getRatingColor(rating) {
|
||||
if (rating >= 4.5) return 'text-green-600';
|
||||
if (rating >= 3.5) return 'text-yellow-600';
|
||||
if (rating >= 2.5) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
export function getRatingText(rating) {
|
||||
if (rating >= 4.5) return 'ممتاز';
|
||||
if (rating >= 3.5) return 'جيد جداً';
|
||||
if (rating >= 2.5) return 'جيد';
|
||||
if (rating >= 1.5) return 'مقبول';
|
||||
return 'ضعيف';
|
||||
}
|
||||
123
app/contexts/FavoritesContext.js
Normal file
123
app/contexts/FavoritesContext.js
Normal file
@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getUserFavoriteProperties, addFavoriteProperty, removeFavoriteProperty } from '../utils/api';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const FavoritesContext = createContext();
|
||||
|
||||
export const useFavorites = () => {
|
||||
const context = useContext(FavoritesContext);
|
||||
if (!context) {
|
||||
throw new Error('useFavorites must be used within FavoritesProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
function mapApiFavorite(item) {
|
||||
const info = item.propertyInformation || {};
|
||||
let details = {};
|
||||
try { details = JSON.parse(info.detailsJSON || '{}'); } catch {}
|
||||
|
||||
const price = item.monthlyRent || item.dailyRent || 0;
|
||||
const priceUnit = item.monthlyRent ? 'monthly' : 'daily';
|
||||
const buildingType = info.buildingType ?? 0;
|
||||
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
|
||||
const address = info.address || '';
|
||||
const addressParts = address.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const images = info.images || [];
|
||||
const resolvedImages = images.map(img => {
|
||||
if (!img) return '';
|
||||
if (img.startsWith('http')) return img;
|
||||
return `http://45.93.137.91${img.startsWith('/') ? '' : '/'}${img}`;
|
||||
});
|
||||
|
||||
return {
|
||||
id: info.id || item.propertyInformationId,
|
||||
faveId: item.id, // needed to remove from favorites
|
||||
title: `${typeLabel} في ${addressParts[0] || address}`,
|
||||
type,
|
||||
typeLabel,
|
||||
price,
|
||||
priceUnit,
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
location: {
|
||||
city: addressParts[addressParts.length - 1] || '',
|
||||
district: addressParts[0] || '',
|
||||
},
|
||||
images: resolvedImages,
|
||||
rating: item.rating || 0,
|
||||
deposit: item.deposit || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const FavoritesProvider = ({ children }) => {
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchFavorites = useCallback(async () => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
setFavorites([]);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getUserFavoriteProperties();
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setFavorites(items.map(mapApiFavorite));
|
||||
} catch (err) {
|
||||
console.error('[Favorites] Failed to fetch:', err);
|
||||
setFavorites([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFavorites();
|
||||
}, [fetchFavorites]);
|
||||
|
||||
const addFavorite = async (propId) => {
|
||||
if (!AuthService.isAuthenticated()) return false;
|
||||
try {
|
||||
await addFavoriteProperty(propId);
|
||||
// Refresh to get the full object with faveId
|
||||
await fetchFavorites();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Favorites] Add failed:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeFavorite = async (propId) => {
|
||||
if (!AuthService.isAuthenticated()) return false;
|
||||
const fav = favorites.find(f => f.id === propId);
|
||||
if (!fav) return false;
|
||||
// Optimistic update — remove immediately from UI
|
||||
const previous = [...favorites];
|
||||
setFavorites(prev => prev.filter(f => f.id !== propId));
|
||||
try {
|
||||
await removeFavoriteProperty(fav.faveId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Favorites] Remove failed:', err);
|
||||
// Rollback on failure
|
||||
setFavorites(previous);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isFavorite = (propId) => {
|
||||
return favorites.some(f => f.id === propId);
|
||||
};
|
||||
|
||||
return (
|
||||
<FavoritesContext.Provider value={{ favorites, isLoading, addFavorite, removeFavorite, isFavorite, refreshFavorites: fetchFavorites }}>
|
||||
{children}
|
||||
</FavoritesContext.Provider>
|
||||
);
|
||||
};
|
||||
75
app/contexts/NotificationsContext.js
Normal file
75
app/contexts/NotificationsContext.js
Normal file
@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getUserNotifications } from '../utils/api';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const NotificationsContext = createContext();
|
||||
|
||||
export const useNotifications = () => {
|
||||
const context = useContext(NotificationsContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotifications must be used within NotificationsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export function NotificationsProvider({ children }) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getUserNotifications();
|
||||
const notificationsArray = Array.isArray(data) ? data : [];
|
||||
setNotifications(notificationsArray);
|
||||
// Assuming all are unread for now, or add logic to check 'read' field if exists
|
||||
setUnreadCount(notificationsArray.length);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const markAsRead = useCallback((id) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(() => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationsContext.Provider>
|
||||
);
|
||||
}
|
||||
38
app/enums/BookingStatus.js
Normal file
38
app/enums/BookingStatus.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* BookingStatus Enum
|
||||
* Backend values are strings
|
||||
* Used in: Reservation workflow
|
||||
*/
|
||||
const BookingStatus = Object.freeze({
|
||||
PENDING: 'pending',
|
||||
OWNER_APPROVED: 'owner_approved',
|
||||
ADMIN_APPROVED: 'admin_approved',
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
REJECTED: 'rejected',
|
||||
CANCELLED: 'cancelled',
|
||||
});
|
||||
|
||||
// Map status → Arabic label
|
||||
const BookingStatusLabels = Object.freeze({
|
||||
[BookingStatus.PENDING]: 'بانتظار الموافقة',
|
||||
[BookingStatus.OWNER_APPROVED]: 'موافقة المالك',
|
||||
[BookingStatus.ADMIN_APPROVED]: 'موافقة الإدارة',
|
||||
[BookingStatus.ACTIVE]: 'إيجار نشط',
|
||||
[BookingStatus.COMPLETED]: 'منتهي',
|
||||
[BookingStatus.REJECTED]: 'مرفوض',
|
||||
[BookingStatus.CANCELLED]: 'ملغي',
|
||||
});
|
||||
|
||||
// Map status → color class (Tailwind bg)
|
||||
const BookingStatusColors = Object.freeze({
|
||||
[BookingStatus.PENDING]: 'yellow',
|
||||
[BookingStatus.OWNER_APPROVED]: 'blue',
|
||||
[BookingStatus.ADMIN_APPROVED]: 'green',
|
||||
[BookingStatus.ACTIVE]: 'purple',
|
||||
[BookingStatus.COMPLETED]: 'gray',
|
||||
[BookingStatus.REJECTED]: 'red',
|
||||
[BookingStatus.CANCELLED]: 'red',
|
||||
});
|
||||
|
||||
export { BookingStatus, BookingStatusLabels, BookingStatusColors };
|
||||
33
app/enums/BuildingType.js
Normal file
33
app/enums/BuildingType.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* BuildingType Enum
|
||||
* Backend values are numeric (0, 1, 2)
|
||||
* Used in: PropertyInformation.buildingType
|
||||
*/
|
||||
const BuildingType = Object.freeze({
|
||||
APARTMENT: 0,
|
||||
VILLA: 1,
|
||||
HOUSE: 2,
|
||||
});
|
||||
|
||||
// Map numeric value → Arabic label
|
||||
const BuildingTypeLabels = Object.freeze({
|
||||
[BuildingType.APARTMENT]: 'شقة',
|
||||
[BuildingType.VILLA]: 'فيلا',
|
||||
[BuildingType.HOUSE]: 'بيت',
|
||||
});
|
||||
|
||||
// Map numeric value → English key (for UI filters)
|
||||
const BuildingTypeKeys = Object.freeze({
|
||||
[BuildingType.APARTMENT]: 'apartment',
|
||||
[BuildingType.VILLA]: 'villa',
|
||||
[BuildingType.HOUSE]: 'house',
|
||||
});
|
||||
|
||||
// Reverse map: English key → numeric value
|
||||
const BuildingTypeByKey = Object.freeze({
|
||||
apartment: BuildingType.APARTMENT,
|
||||
villa: BuildingType.VILLA,
|
||||
house: BuildingType.HOUSE,
|
||||
});
|
||||
|
||||
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey };
|
||||
38
app/enums/City.js
Normal file
38
app/enums/City.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* City Enum
|
||||
* Syrian cities used in property locations
|
||||
* Used in: Property search filters, location display
|
||||
*/
|
||||
const City = Object.freeze({
|
||||
DAMASCUS: 'دمشق',
|
||||
ALEPPO: 'حلب',
|
||||
HOMS: 'حمص',
|
||||
LATAKIA: 'اللاذقية',
|
||||
DARAA: 'درعا',
|
||||
TARTOUS: 'طرطوس',
|
||||
SUWEIDA: 'السويداء',
|
||||
DEIR_EZZOR: 'دير الزور',
|
||||
RAQQA: 'الرقة',
|
||||
IDLIB: 'إدلب',
|
||||
HASAKAH: 'الحسكة',
|
||||
QAMISHLI: 'القامشلي',
|
||||
RURAL_DAMASCUS: 'ريف دمشق',
|
||||
});
|
||||
|
||||
// All cities as a flat array
|
||||
const CitiesList = Object.freeze(Object.values(City));
|
||||
|
||||
/**
|
||||
* Extract city name from a full address string
|
||||
* @param {string} address
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractCity(address) {
|
||||
if (!address) return '';
|
||||
for (const city of CitiesList) {
|
||||
if (address.includes(city)) return city;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export { City, CitiesList, extractCity };
|
||||
19
app/enums/CommissionType.js
Normal file
19
app/enums/CommissionType.js
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* CommissionType Enum
|
||||
* Defines who pays the platform commission
|
||||
* Used in: Property pricing, booking financials
|
||||
*/
|
||||
const CommissionType = Object.freeze({
|
||||
FROM_OWNER: 'from_owner',
|
||||
FROM_TENANT: 'from_tenant',
|
||||
FROM_BOTH: 'from_both',
|
||||
});
|
||||
|
||||
// Map type → Arabic label
|
||||
const CommissionTypeLabels = Object.freeze({
|
||||
[CommissionType.FROM_OWNER]: 'من المالك',
|
||||
[CommissionType.FROM_TENANT]: 'من المستأجر',
|
||||
[CommissionType.FROM_BOTH]: 'من الاثنين',
|
||||
});
|
||||
|
||||
export { CommissionType, CommissionTypeLabels };
|
||||
20
app/enums/Currency.js
Normal file
20
app/enums/Currency.js
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Currency Enum
|
||||
* Currency IDs used in the backend
|
||||
*/
|
||||
const Currency = Object.freeze({
|
||||
SYP: 1,
|
||||
USD: 2,
|
||||
});
|
||||
|
||||
const CurrencyLabels = Object.freeze({
|
||||
[Currency.SYP]: 'ليرة سورية',
|
||||
[Currency.USD]: 'دولار أمريكي',
|
||||
});
|
||||
|
||||
const CurrencySymbols = Object.freeze({
|
||||
[Currency.SYP]: 'SYP',
|
||||
[Currency.USD]: 'USD',
|
||||
});
|
||||
|
||||
export { Currency, CurrencyLabels, CurrencySymbols };
|
||||
17
app/enums/CustomerType.js
Normal file
17
app/enums/CustomerType.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* CustomerType Enum
|
||||
* Backend values for customer sub-types
|
||||
* Used in: Customer registration (Customer/Add)
|
||||
*/
|
||||
const CustomerType = Object.freeze({
|
||||
PERSONAL: 0,
|
||||
FAMILY: 1,
|
||||
});
|
||||
|
||||
// Map value → Arabic label
|
||||
const CustomerTypeLabels = Object.freeze({
|
||||
[CustomerType.PERSONAL]: 'شخصي',
|
||||
[CustomerType.FAMILY]: 'عائلي',
|
||||
});
|
||||
|
||||
export { CustomerType, CustomerTypeLabels };
|
||||
23
app/enums/IdentityType.js
Normal file
23
app/enums/IdentityType.js
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* IdentityType Enum
|
||||
* Tenant identity document type
|
||||
* Used in: Property booking, allowedIdentities filter
|
||||
*/
|
||||
const IdentityType = Object.freeze({
|
||||
SYRIAN: 'syrian',
|
||||
PASSPORT: 'passport',
|
||||
});
|
||||
|
||||
// Map type → Arabic label
|
||||
const IdentityTypeLabels = Object.freeze({
|
||||
[IdentityType.SYRIAN]: 'هوية سورية',
|
||||
[IdentityType.PASSPORT]: 'جواز سفر',
|
||||
});
|
||||
|
||||
// Map type → flag emoji
|
||||
const IdentityTypeFlags = Object.freeze({
|
||||
[IdentityType.SYRIAN]: '🇸🇾',
|
||||
[IdentityType.PASSPORT]: '🛂',
|
||||
});
|
||||
|
||||
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags };
|
||||
11
app/enums/LoginMethod.js
Normal file
11
app/enums/LoginMethod.js
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* LoginMethod Enum
|
||||
* Authentication method type
|
||||
* Used in: Login page, OTP verification
|
||||
*/
|
||||
const LoginMethod = Object.freeze({
|
||||
EMAIL: 'email',
|
||||
PHONE: 'phone',
|
||||
});
|
||||
|
||||
export { LoginMethod };
|
||||
17
app/enums/OwnerType.js
Normal file
17
app/enums/OwnerType.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* OwnerType Enum
|
||||
* Backend values for owner sub-types
|
||||
* Used in: Owner registration (Owner/Add)
|
||||
*/
|
||||
const OwnerType = Object.freeze({
|
||||
PERSON: 0,
|
||||
REAL_ESTATE_AGENCY: 1,
|
||||
});
|
||||
|
||||
// Map value → Arabic label
|
||||
const OwnerTypeLabels = Object.freeze({
|
||||
[OwnerType.PERSON]: 'شخص',
|
||||
[OwnerType.REAL_ESTATE_AGENCY]: 'وكالة عقارية',
|
||||
});
|
||||
|
||||
export { OwnerType, OwnerTypeLabels };
|
||||
41
app/enums/PropertyService.js
Normal file
41
app/enums/PropertyService.js
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PropertyService Enum
|
||||
* Services available at the property
|
||||
* Used in detailsJSON.services array
|
||||
*/
|
||||
const PropertyService = Object.freeze({
|
||||
ELECTRICITY: 'Electricity',
|
||||
INTERNET: 'Internet',
|
||||
HEATING: 'Heating',
|
||||
WATER: 'Water',
|
||||
POOL: 'Pool',
|
||||
PRIVATE_GARDEN: 'PrivateGarden',
|
||||
PARKING: 'Parking',
|
||||
SECURITY_247: 'Security247',
|
||||
CENTRAL_HEATING: 'CentralHeating',
|
||||
CENTRAL_AIR_CONDITIONING: 'CentralAirConditioning',
|
||||
EQUIPPED_KITCHEN: 'EquippedKitchen',
|
||||
MAIDS_ROOM: 'MaidsRoom',
|
||||
ELEVATOR: 'Elevator',
|
||||
});
|
||||
|
||||
const PropertyServiceLabels = Object.freeze({
|
||||
[PropertyService.ELECTRICITY]: 'كهرباء',
|
||||
[PropertyService.INTERNET]: 'إنترنت',
|
||||
[PropertyService.HEATING]: 'تدفئة',
|
||||
[PropertyService.WATER]: 'ماء',
|
||||
[PropertyService.POOL]: 'مسبح',
|
||||
[PropertyService.PRIVATE_GARDEN]: 'حديقة خاصة',
|
||||
[PropertyService.PARKING]: 'موقف سيارات',
|
||||
[PropertyService.SECURITY_247]: 'حراسة 24 ساعة',
|
||||
[PropertyService.CENTRAL_HEATING]: 'تدفئة مركزية',
|
||||
[PropertyService.CENTRAL_AIR_CONDITIONING]: 'تكييف مركزي',
|
||||
[PropertyService.EQUIPPED_KITCHEN]: 'مطبخ مجهز',
|
||||
[PropertyService.MAIDS_ROOM]: 'غرفة خادمة',
|
||||
[PropertyService.ELEVATOR]: 'مصعد',
|
||||
});
|
||||
|
||||
// All services as array
|
||||
const PropertyServicesList = Object.freeze(Object.values(PropertyService));
|
||||
|
||||
export { PropertyService, PropertyServiceLabels, PropertyServicesList };
|
||||
33
app/enums/PropertyStatus.js
Normal file
33
app/enums/PropertyStatus.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* PropertyStatus Enum
|
||||
* Backend values are numeric (0, 1, 2)
|
||||
* Used in: PropertyInformation.status
|
||||
*/
|
||||
const PropertyStatus = Object.freeze({
|
||||
AVAILABLE: 0,
|
||||
BOOKED: 1,
|
||||
MAINTENANCE: 2,
|
||||
});
|
||||
|
||||
// Map numeric value → Arabic label
|
||||
const PropertyStatusLabels = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'متاح',
|
||||
[PropertyStatus.BOOKED]: 'محجوز',
|
||||
[PropertyStatus.MAINTENANCE]: 'صيانة',
|
||||
});
|
||||
|
||||
// Map numeric value → English key (for UI filters)
|
||||
const PropertyStatusKeys = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'available',
|
||||
[PropertyStatus.BOOKED]: 'booked',
|
||||
[PropertyStatus.MAINTENANCE]: 'maintenance',
|
||||
});
|
||||
|
||||
// Reverse map: English key → numeric value
|
||||
const PropertyStatusByKey = Object.freeze({
|
||||
available: PropertyStatus.AVAILABLE,
|
||||
booked: PropertyStatus.BOOKED,
|
||||
maintenance: PropertyStatus.MAINTENANCE,
|
||||
});
|
||||
|
||||
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey };
|
||||
21
app/enums/PropertyTerm.js
Normal file
21
app/enums/PropertyTerm.js
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* PropertyTerm Enum
|
||||
* Usage terms/conditions for the property
|
||||
* Used in detailsJSON.terms array
|
||||
*/
|
||||
const PropertyTerm = Object.freeze({
|
||||
NO_SMOKING: 'NoSmoking',
|
||||
NO_ANIMALS: 'NoAnimals',
|
||||
NO_PARTIES: 'NoParties',
|
||||
});
|
||||
|
||||
const PropertyTermLabels = Object.freeze({
|
||||
[PropertyTerm.NO_SMOKING]: 'ممنوع التدخين',
|
||||
[PropertyTerm.NO_ANIMALS]: 'ممنوع الحيوانات',
|
||||
[PropertyTerm.NO_PARTIES]: 'ممنوع الحفلات',
|
||||
});
|
||||
|
||||
// All terms as array
|
||||
const PropertyTermsList = Object.freeze(Object.values(PropertyTerm));
|
||||
|
||||
export { PropertyTerm, PropertyTermLabels, PropertyTermsList };
|
||||
16
app/enums/RentPropertyCondition.js
Normal file
16
app/enums/RentPropertyCondition.js
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RentPropertyCondition Enum
|
||||
* Furniture condition of the property
|
||||
* Sent as `propertyType` field in API
|
||||
*/
|
||||
const RentPropertyCondition = Object.freeze({
|
||||
WITH_FURNITURE: 0,
|
||||
WITHOUT_FURNITURE: 1,
|
||||
});
|
||||
|
||||
const RentPropertyConditionLabels = Object.freeze({
|
||||
[RentPropertyCondition.WITH_FURNITURE]: 'مفروش',
|
||||
[RentPropertyCondition.WITHOUT_FURNITURE]: 'غير مفروش',
|
||||
});
|
||||
|
||||
export { RentPropertyCondition, RentPropertyConditionLabels };
|
||||
17
app/enums/RentPropertyType.js
Normal file
17
app/enums/RentPropertyType.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* RentPropertyType Enum
|
||||
* Sent as `type` field in RentPropertyDto
|
||||
*/
|
||||
const RentPropertyType = Object.freeze({
|
||||
FURNISHED: 0,
|
||||
UNFURNISHED: 1,
|
||||
SEMI_FURNISHED: 2,
|
||||
});
|
||||
|
||||
const RentPropertyTypeLabels = Object.freeze({
|
||||
[RentPropertyType.FURNISHED]: 'مفروش بالكامل',
|
||||
[RentPropertyType.UNFURNISHED]: 'غير مفروش',
|
||||
[RentPropertyType.SEMI_FURNISHED]: 'مفروش جزئياً',
|
||||
});
|
||||
|
||||
export { RentPropertyType, RentPropertyTypeLabels };
|
||||
16
app/enums/RentType.js
Normal file
16
app/enums/RentType.js
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RentType Enum
|
||||
* Rental period type
|
||||
* Sent as `rentType` field in API
|
||||
*/
|
||||
const RentType = Object.freeze({
|
||||
MONTHLY: 0,
|
||||
DAILY: 1,
|
||||
});
|
||||
|
||||
const RentTypeLabels = Object.freeze({
|
||||
[RentType.MONTHLY]: 'شهري',
|
||||
[RentType.DAILY]: 'يومي',
|
||||
});
|
||||
|
||||
export { RentType, RentTypeLabels };
|
||||
27
app/enums/UserRole.js
Normal file
27
app/enums/UserRole.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* UserRole Enum
|
||||
* User account roles in the system
|
||||
* Derived from JWT token claims
|
||||
*/
|
||||
const UserRole = Object.freeze({
|
||||
GUEST: 'guest',
|
||||
CUSTOMER: 'customer',
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
});
|
||||
|
||||
const UserRoleLabels = Object.freeze({
|
||||
[UserRole.GUEST]: 'زائر',
|
||||
[UserRole.CUSTOMER]: 'مستأجر',
|
||||
[UserRole.OWNER]: 'مالك عقار',
|
||||
[UserRole.ADMIN]: 'مدير النظام',
|
||||
});
|
||||
|
||||
const UserRoleColors = Object.freeze({
|
||||
[UserRole.GUEST]: 'gray',
|
||||
[UserRole.CUSTOMER]: 'blue',
|
||||
[UserRole.OWNER]: 'amber',
|
||||
[UserRole.ADMIN]: 'red',
|
||||
});
|
||||
|
||||
export { UserRole, UserRoleLabels, UserRoleColors };
|
||||
21
app/enums/index.js
Normal file
21
app/enums/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Enums Index
|
||||
* Central export for all enum modules
|
||||
*/
|
||||
|
||||
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey } from './BuildingType';
|
||||
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey } from './PropertyStatus';
|
||||
export { BookingStatus, BookingStatusLabels, BookingStatusColors } from './BookingStatus';
|
||||
export { CommissionType, CommissionTypeLabels } from './CommissionType';
|
||||
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags } from './IdentityType';
|
||||
export { UserRole, UserRoleLabels, UserRoleColors } from './UserRole';
|
||||
export { City, CitiesList, extractCity } from './City';
|
||||
export { LoginMethod } from './LoginMethod';
|
||||
export { OwnerType, OwnerTypeLabels } from './OwnerType';
|
||||
export { CustomerType, CustomerTypeLabels } from './CustomerType';
|
||||
export { RentPropertyCondition, RentPropertyConditionLabels } from './RentPropertyCondition';
|
||||
export { RentPropertyType, RentPropertyTypeLabels } from './RentPropertyType';
|
||||
export { RentType, RentTypeLabels } from './RentType';
|
||||
export { PropertyService, PropertyServiceLabels, PropertyServicesList } from './PropertyService';
|
||||
export { PropertyTerm, PropertyTermLabels, PropertyTermsList } from './PropertyTerm';
|
||||
export { Currency, CurrencyLabels, CurrencySymbols } from './Currency';
|
||||
39
app/error.js
Normal file
39
app/error.js
Normal file
@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function GlobalError({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="w-24 h-24 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ غير متوقع</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر عن هذا الإزعاج، يرجى المحاولة مرة أخرى</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
إعادة المحاولة
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
app/favorites/page.js
Normal file
144
app/favorites/page.js
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Heart, MapPin, Bed, Bath, Square, X, ImageIcon } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const router = useRouter();
|
||||
const { favorites, isLoading: favoritesLoading, removeFavorite } = useFavorites();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setIsAdmin(AuthService.isAdmin());
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
if (favoritesLoading && favorites.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">المفضلة</h1>
|
||||
<p className="text-gray-600">العقارات التي قمت بحفظها</p>
|
||||
</div>
|
||||
|
||||
{favorites.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد عقارات في المفضلة</h3>
|
||||
<p className="text-gray-500 mb-6">يمكنك إضافة العقارات التي تعجبك بالنقر على أيقونة القلب</p>
|
||||
<Link
|
||||
href="/properties"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
استعرض العقارات
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{favorites.map((property) => (
|
||||
<motion.div
|
||||
key={property.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-gray-200"
|
||||
>
|
||||
<div className="relative h-48 bg-gray-100">
|
||||
{property.images && property.images[0] ? (
|
||||
<Image
|
||||
src={property.images[0]}
|
||||
alt={property.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFavorite(property.id)}
|
||||
className="absolute top-2 right-2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center hover:bg-red-50 transition-colors shadow-sm"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium">
|
||||
{property.type === 'apartment' ? 'شقة' : property.type === 'villa' ? 'فيلا' : 'بيت'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-xs mb-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="line-clamp-1">
|
||||
{property.location.city}، {property.location.district}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-600 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bed className="w-4 h-4" />
|
||||
<span>{property.bedrooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{property.bathrooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>{property.area}م²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/property/${property.id}`}
|
||||
className="block w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
عرض التفاصيل
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/forgot-password/error.js
Normal file
27
app/forgot-password/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/forgot-password/loading.js
Normal file
14
app/forgot-password/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,78 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Madani Arabic Font ─── */
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Thin.woff2') format('woff2');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Extra Light.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani-Arabic-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Semi Bold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani-Arabic-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Extra Bold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ede6e6;
|
||||
--foreground: #156874;
|
||||
@ -19,10 +92,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
|
||||
BIN
app/icon.png
Normal file
BIN
app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@ -13,19 +13,47 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "SweetHome",
|
||||
title: "Sweet Home",
|
||||
description: "Discover premium furniture and home decor",
|
||||
icons: {
|
||||
icon: [{ url: "/logo.png", type: "image/png" }],
|
||||
shortcut: [{ url: "/logo.png", type: "image/png" }],
|
||||
apple: [{ url: "/logo.png", type: "image/png" }],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<ClientLayout>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/fonts/Madani-Arabic-Regular.woff2"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/fonts/Madani-Arabic-Bold.woff2"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/fonts/Madani Arabic Medium.woff2"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
|
||||
>
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
app/loading.js
Normal file
18
app/loading.js
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/login/error.js
Normal file
27
app/login/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/login/loading.js
Normal file
14
app/login/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
@ -16,106 +15,277 @@ import {
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Home,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
Shield,
|
||||
Phone,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
loginWithEmail,
|
||||
loginWithPhone,
|
||||
sendEmailOTP,
|
||||
sendPhoneOTP,
|
||||
verifyEmail,
|
||||
verifyPhone,
|
||||
isEmail,
|
||||
isPhoneNumber,
|
||||
getOwnerByUserId,
|
||||
getCustomerByUserId,
|
||||
} from "../utils/api";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Step: 'login' | 'otp'
|
||||
const [step, setStep] = useState("login");
|
||||
const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
credential: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const ADMIN_EMAIL = 'admin@gmail.com';
|
||||
const ADMIN_PASSWORD = '123';
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
|
||||
if (!formData.credential) {
|
||||
newErrors.credential =
|
||||
loginMethod === "email"
|
||||
? "البريد الإلكتروني مطلوب"
|
||||
: "رقم الهاتف مطلوب";
|
||||
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||
newErrors.credential = "رقم الهاتف غير صالح";
|
||||
}
|
||||
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'كلمة المرور مطلوبة';
|
||||
newErrors.password = "كلمة المرور مطلوبة";
|
||||
}
|
||||
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
toast.error('يرجى تصحيح الأخطاء في النموذج', {
|
||||
style: { background: '#fee2e2', color: '#991b1b' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
if (formData.email.toLowerCase() === ADMIN_EMAIL && formData.password === ADMIN_PASSWORD) {
|
||||
setIsLoading(false);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||
console.log(
|
||||
"[Login] Attempting login via",
|
||||
loginMethod,
|
||||
":",
|
||||
formData.credential,
|
||||
);
|
||||
|
||||
const result = await loginFn(formData.credential, formData.password);
|
||||
|
||||
console.log("[Login] Response status:", result.status);
|
||||
|
||||
if (result.status === 200) {
|
||||
const token =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
AuthService.addToken(token);
|
||||
console.log("[Login] Token stored");
|
||||
|
||||
// Fetch user profile to get full name
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser?.id) {
|
||||
try {
|
||||
const isOwner = AuthService.isOwner();
|
||||
const fetchFn = isOwner ? getOwnerByUserId : getCustomerByUserId;
|
||||
const profile = await fetchFn(authUser.id);
|
||||
if (profile) {
|
||||
AuthService.cacheUser({
|
||||
name:
|
||||
profile.fullName ||
|
||||
profile.name ||
|
||||
`${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email: profile.email || authUser.email,
|
||||
phone: profile.phone || profile.phoneNumber || authUser.phone,
|
||||
});
|
||||
console.log("[Login] User profile cached");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Login] Failed to fetch profile:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userRole = AuthService.isAdmin()
|
||||
? "admin"
|
||||
: AuthService.isOwner()
|
||||
? "owner"
|
||||
: "customer";
|
||||
console.log("[Login] User role:", userRole);
|
||||
|
||||
setIsSuccess(true);
|
||||
|
||||
toast.success('تم تسجيل الدخول كأدمن!', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
duration: 3000
|
||||
toast.success("تم تسجيل الدخول بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
localStorage.setItem('user', JSON.stringify({
|
||||
name: 'مدير النظام',
|
||||
email: ADMIN_EMAIL,
|
||||
role: 'admin',
|
||||
avatar: 'أ'
|
||||
}));
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/admin');
|
||||
if (userRole === "admin") {
|
||||
router.push("/admin");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
toast.error('بيانات الدخول غير صحيحة. حاول مع admin@gmail.com / 123', {
|
||||
style: { background: '#fee2e2', color: '#991b1b' },
|
||||
duration: 4000
|
||||
} else if (result.status === 206) {
|
||||
console.log("[Login] 206 — OTP required");
|
||||
const tempToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (tempToken) {
|
||||
AuthService.addToken(tempToken);
|
||||
console.log("[Login] Temp token stored for OTP");
|
||||
}
|
||||
toast("يرجى إدخال رمز التحقق", {
|
||||
icon: "🔐",
|
||||
style: { background: "#fef3c7", color: "#92400e" },
|
||||
});
|
||||
|
||||
// Send OTP
|
||||
try {
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
console.log("[Login] OTP sent successfully");
|
||||
} catch (otpErr) {
|
||||
console.warn("[Login] OTP send failed, proceeding anyway:", otpErr);
|
||||
}
|
||||
|
||||
setStep("otp");
|
||||
} else {
|
||||
// Other error
|
||||
console.error("[Login] Unexpected status:", result.status, result.data);
|
||||
toast.error(
|
||||
result.data?.message || result.data || "بيانات الدخول غير صحيحة",
|
||||
{
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error("[Login] Error:", err);
|
||||
toast.error(err.message || "حدث خطأ في الاتصال", {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const particles = Array.from({ length: 30 }, (_, i) => ({
|
||||
const handleVerifyOTP = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpError("يرجى إدخال رمز التحقق");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setOtpError("");
|
||||
|
||||
try {
|
||||
const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
|
||||
console.log("[OTP] Verifying code:", otpCode);
|
||||
|
||||
const result = await verifyFn(otpCode);
|
||||
console.log("[OTP] Verify response status:", result.status);
|
||||
|
||||
if (result.ok) {
|
||||
const finalToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (finalToken && typeof finalToken === "string") {
|
||||
AuthService.addToken(finalToken);
|
||||
console.log("[OTP] Final token stored");
|
||||
}
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success("تم التحقق بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("[OTP] Redirecting to home");
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else {
|
||||
console.error("[OTP] Verification failed:", result.data);
|
||||
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[OTP] Error:", err);
|
||||
setOtpError(err.message || "حدث خطأ في التحقق");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOTP = async () => {
|
||||
try {
|
||||
console.log("[OTP] Resending OTP via", loginMethod);
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
toast.success("تم إرسال رمز التحقق مجدداً", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[OTP] Resend failed:", err);
|
||||
toast.error("فشل إرسال الرمز");
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-detect login method from input
|
||||
const handleCredentialChange = (value) => {
|
||||
setFormData({ ...formData, credential: value });
|
||||
if (errors.credential) setErrors({ ...errors, credential: null });
|
||||
|
||||
// Auto-switch method
|
||||
// if (isEmail(value)) {
|
||||
// setLoginMethod('email');
|
||||
// } else if (isPhoneNumber(value)) {
|
||||
// setLoginMethod('phone');
|
||||
// }
|
||||
};
|
||||
|
||||
const particles = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 3 + 1,
|
||||
duration: Math.random() * 15 + 10,
|
||||
delay: Math.random() * 5
|
||||
delay: Math.random() * 5,
|
||||
}));
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2
|
||||
}
|
||||
}
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
@ -123,24 +293,25 @@ export default function LoginPage() {
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { type: 'spring', stiffness: 100 }
|
||||
}
|
||||
transition: { type: "spring", stiffness: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
|
||||
{/* Particles */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{particles.map((particle) => (
|
||||
{particles.map((p) => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
key={p.id}
|
||||
className="absolute rounded-full bg-amber-500/20"
|
||||
style={{
|
||||
left: `${particle.x}%`,
|
||||
top: `${particle.y}%`,
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -20, 0],
|
||||
@ -148,31 +319,24 @@ export default function LoginPage() {
|
||||
opacity: [0.2, 0.4, 0.2],
|
||||
}}
|
||||
transition={{
|
||||
duration: particle.duration,
|
||||
duration: p.duration,
|
||||
repeat: Infinity,
|
||||
delay: particle.delay,
|
||||
ease: "linear"
|
||||
delay: p.delay,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Glow orbs */}
|
||||
<motion.div
|
||||
className="absolute top-20 left-20 w-64 h-64 bg-amber-500/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
x: [0, 30, 0],
|
||||
y: [0, -20, 0],
|
||||
}}
|
||||
animate={{ scale: [1, 1.2, 1], x: [0, 30, 0], y: [0, -20, 0] }}
|
||||
transition={{ duration: 12, repeat: Infinity }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-20 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
x: [0, -30, 0],
|
||||
y: [0, 20, 0],
|
||||
}}
|
||||
animate={{ scale: [1, 1.3, 1], x: [0, -30, 0], y: [0, 20, 0] }}
|
||||
transition={{ duration: 15, repeat: Infinity }}
|
||||
/>
|
||||
|
||||
@ -182,17 +346,15 @@ export default function LoginPage() {
|
||||
animate="visible"
|
||||
className="relative w-full max-w-md z-10"
|
||||
>
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="absolute -top-16 left-0"
|
||||
>
|
||||
{/* Back link */}
|
||||
<motion.div variants={itemVariants} className="absolute -top-16 left-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ x: -5 }}
|
||||
transition={{ type: 'spring', stiffness: 400 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
@ -204,6 +366,7 @@ export default function LoginPage() {
|
||||
variants={itemVariants}
|
||||
className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
@ -217,7 +380,7 @@ export default function LoginPage() {
|
||||
transition={{ delay: 0.3, type: "spring" }}
|
||||
className="absolute -bottom-10 -left-10 w-40 h-40 bg-white/10 rounded-full"
|
||||
/>
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
@ -229,172 +392,322 @@ export default function LoginPage() {
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
||||
>
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
{step === "otp" ? (
|
||||
<KeyRound className="w-10 h-10 text-white" />
|
||||
) : (
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
)}
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
|
||||
<p className="text-amber-100">مرحباً بعودتك!</p>
|
||||
<p className="text-amber-100">
|
||||
{step === "otp" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<motion.form
|
||||
variants={itemVariants}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
<motion.div variants={itemVariants}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
البريد الإلكتروني
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Mail className={`w-5 h-5 transition-colors ${
|
||||
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, email: e.target.value});
|
||||
if (errors.email) setErrors({...errors, email: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل بريدك الإلكتروني"
|
||||
/>
|
||||
{formData.email && validateEmail(formData.email) && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
{errors.email && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.email}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 transition-colors ${
|
||||
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, password: e.target.value});
|
||||
if (errors.password) setErrors({...errors, password: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.password}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(e) => setFormData({...formData, rememberMe: e.target.checked})}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||
تذكرني
|
||||
</span>
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "login" ? (
|
||||
<motion.form
|
||||
key="login"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
onSubmit={handleLogin}
|
||||
className="space-y-6"
|
||||
>
|
||||
نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</motion.div>
|
||||
{/* Login method tabs */}
|
||||
<div className="flex gap-2 bg-white/5 p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginMethod("email");
|
||||
setFormData({ ...formData, credential: "" });
|
||||
}}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
loginMethod === "email"
|
||||
? "bg-amber-500 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
بريد إلكتروني
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginMethod("phone");
|
||||
setFormData({ ...formData, credential: "" });
|
||||
}}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
loginMethod === "phone"
|
||||
? "bg-amber-500 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
رقم الهاتف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
variants={itemVariants}
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-amber-600 to-amber-700"
|
||||
initial={{ x: '100%' }}
|
||||
whileHover={{ x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
جاري تسجيل الدخول...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
تم بنجاح!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
تسجيل الدخول
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
{/* Credential input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{loginMethod === "email"
|
||||
? "البريد الإلكتروني"
|
||||
: "رقم الهاتف"}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
{loginMethod === "email" ? (
|
||||
<Mail
|
||||
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
) : (
|
||||
<Phone
|
||||
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
// type={loginMethod === 'email' ? 'email' : 'tel'}
|
||||
value={formData.credential}
|
||||
onChange={(e) => handleCredentialChange(e.target.value)}
|
||||
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.credential
|
||||
? "border-red-500"
|
||||
: "border-gray-700"
|
||||
}`}
|
||||
placeholder={
|
||||
loginMethod === "email"
|
||||
? "example@email.com"
|
||||
: "+963XXXXXXXXX"
|
||||
}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
{errors.credential && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.credential}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock
|
||||
className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
password: e.target.value,
|
||||
});
|
||||
if (errors.password)
|
||||
setErrors({ ...errors, password: null });
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.password}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
rememberMe: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||
تذكرني
|
||||
</span>
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
جاري تسجيل الدخول...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
تم بنجاح!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
تسجيل الدخول
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
) : (
|
||||
/* OTP Verification Step */
|
||||
<motion.form
|
||||
key="otp"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onSubmit={handleVerifyOTP}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
|
||||
<p className="text-gray-300 text-sm">
|
||||
تم إرسال رمز التحقق إلى{" "}
|
||||
<span className="text-white font-medium" dir="ltr">
|
||||
{formData.credential}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
رمز التحقق
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => {
|
||||
setOtpCode(e.target.value);
|
||||
if (otpError) setOtpError("");
|
||||
}}
|
||||
className={`w-full px-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white text-center text-2xl tracking-[0.5em] placeholder-gray-500 transition-all ${
|
||||
otpError ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="______"
|
||||
maxLength={6}
|
||||
dir="ltr"
|
||||
/>
|
||||
{otpError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1 text-center"
|
||||
>
|
||||
{otpError}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
جاري التحقق...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
تم بنجاح!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="w-5 h-5" />
|
||||
تحقق
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("login");
|
||||
setOtpCode("");
|
||||
setOtpError("");
|
||||
console.log("[OTP] Going back to login");
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
← العودة
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resendOTP}
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
إعادة إرسال الرمز
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-400 mt-6"
|
||||
>
|
||||
ليس لديك حساب؟{' '}
|
||||
ليس لديك حساب؟{" "}
|
||||
<Link
|
||||
href="/auth/choose-role"
|
||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||
@ -409,16 +722,22 @@ export default function LoginPage() {
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-500 text-xs mt-4"
|
||||
>
|
||||
بتسجيل الدخول، أنت توافق على{' '}
|
||||
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||
بتسجيل الدخول، أنت توافق على{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
شروط الاستخدام
|
||||
</Link>
|
||||
{' '}و{' '}
|
||||
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||
</Link>{" "}
|
||||
و{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
سياسة الخصوصية
|
||||
</Link>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
36
app/not-found.js
Normal file
36
app/not-found.js
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<svg viewBox="0 0 200 160" className="w-64 h-48 mx-auto">
|
||||
<circle cx="100" cy="80" r="70" fill="#fef3c7" />
|
||||
<text x="100" y="95" textAnchor="middle" fontSize="60" fontWeight="bold" fill="#f59e0b">404</text>
|
||||
<circle cx="80" cy="110" r="8" fill="#92400e" />
|
||||
<circle cx="120" cy="110" r="8" fill="#92400e" />
|
||||
<path d="M85 130 Q100 120 115 130" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">الصفحة غير موجودة</h2>
|
||||
<p className="text-gray-500 mb-8">عذراً، الصفحة التي تبحث عنها غير متوفرة</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app/notifications/page.js
Normal file
102
app/notifications/page.js
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const { notifications, unreadCount, isLoading } = useNotifications();
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const markAsRead = (id) => {
|
||||
// This will be handled by context if needed
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
// This will be handled by context if needed
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">خطأ في التحميل</h3>
|
||||
<p className="text-gray-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الإشعارات</h1>
|
||||
<p className="text-gray-600">
|
||||
{unreadCount > 0 ? `لديك ${unreadCount} إشعار غير مقروء` : 'جميع الإشعارات مقروءة'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد إشعارات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا الإشعارات المتعلقة بحجوزاتك ومدفوعاتك</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
|
||||
>
|
||||
<div className="p-5 flex gap-4">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-full flex items-center justify-center shrink-0">
|
||||
<Bell className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{notification.title}</h3>
|
||||
{notification.message && (
|
||||
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
|
||||
)}
|
||||
{notification.date && (
|
||||
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/bookings/error.js
Normal file
27
app/owner/bookings/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/bookings/loading.js
Normal file
14
app/owner/bookings/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
740
app/owner/bookings/page.js
Normal file
740
app/owner/bookings/page.js
Normal file
@ -0,0 +1,740 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Calendar,
|
||||
Home,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
DollarSign,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MessageCircle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Filter,
|
||||
Search,
|
||||
Download,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Building
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import Image from 'next/image';
|
||||
|
||||
const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [hoverDate, setHoverDate] = useState(null);
|
||||
|
||||
const daysInMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth() + 1,
|
||||
0
|
||||
).getDate();
|
||||
|
||||
const firstDayOfMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
1
|
||||
).getDay();
|
||||
|
||||
const monthNames = [
|
||||
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
|
||||
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
|
||||
];
|
||||
|
||||
const isDateBooked = (date) => {
|
||||
if (!property?.bookings) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.some(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const isDateSelected = (date) => {
|
||||
if (!selectedDates) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return dateStr === selectedDates.start || dateStr === selectedDates.end;
|
||||
};
|
||||
|
||||
const isInRange = (date) => {
|
||||
if (!selectedDates?.start || !selectedDates?.end) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return dateStr > selectedDates.start && dateStr < selectedDates.end;
|
||||
};
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
if (isDateBooked(date)) return;
|
||||
onDateSelect?.(date);
|
||||
};
|
||||
|
||||
const renderDays = () => {
|
||||
const days = [];
|
||||
const totalDays = daysInMonth + firstDayOfMonth;
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
if (i < firstDayOfMonth) {
|
||||
days.push(<div key={`empty-${i}`} className="p-2" />);
|
||||
} else {
|
||||
const dayNumber = i - firstDayOfMonth + 1;
|
||||
const date = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
dayNumber
|
||||
);
|
||||
|
||||
const isBooked = isDateBooked(date);
|
||||
const isSelected = isDateSelected(date);
|
||||
const inRange = isInRange(date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={dayNumber}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={isBooked}
|
||||
onMouseEnter={() => setHoverDate(dayNumber)}
|
||||
onMouseLeave={() => setHoverDate(null)}
|
||||
className={`
|
||||
p-2 rounded-lg text-center text-sm transition-all relative
|
||||
${isBooked ? 'bg-red-100 text-red-500 cursor-not-allowed line-through' : ''}
|
||||
${isSelected ? 'bg-amber-500 text-white shadow-md' : ''}
|
||||
${inRange ? 'bg-amber-100' : ''}
|
||||
${!isBooked && !isSelected ? 'hover:bg-amber-50 hover:text-amber-600 cursor-pointer' : ''}
|
||||
${isToday && !isSelected && !isBooked ? 'border-2 border-amber-500' : ''}
|
||||
`}
|
||||
>
|
||||
{dayNumber}
|
||||
{isBooked && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
{/* رأس التقويم */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* أيام الأسبوع */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
|
||||
<div>أحد</div>
|
||||
<div>إثنين</div>
|
||||
<div>ثلاثاء</div>
|
||||
<div>أربعاء</div>
|
||||
<div>خميس</div>
|
||||
<div>جمعة</div>
|
||||
<div>سبت</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{renderDays()}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-6 pt-4 border-t border-gray-200 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-100 rounded" />
|
||||
<span className="text-gray-600">محجوز</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-amber-500 rounded" />
|
||||
<span className="text-gray-600">محدد</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-amber-100 rounded" />
|
||||
<span className="text-gray-600">ضمن الفترة</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-amber-500 rounded" />
|
||||
<span className="text-gray-600">اليوم</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BookingCard = ({ booking, onViewDetails, onContact }) => {
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
confirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
cancelled: { label: 'ملغي', color: 'bg-red-100 text-red-800', icon: XCircle },
|
||||
completed: { label: 'منتهي', color: 'bg-gray-100 text-gray-800', icon: CheckCircle }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${config.color}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-gray-900">{booking.propertyTitle}</h3>
|
||||
{getStatusBadge(booking.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{booking.location}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</div>
|
||||
<div className="text-xs text-gray-500">إجمالي المبلغ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.tenantName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Phone className="w-3 h-3" />
|
||||
{booking.tenantPhone}
|
||||
<Mail className="w-3 h-3 mr-1" />
|
||||
{booking.tenantEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 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">{booking.startDate}</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">{booking.endDate}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Clock 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">{booking.days} يوم</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => onViewDetails(booking)}
|
||||
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>
|
||||
{/* <button
|
||||
onClick={() => onContact(booking)}
|
||||
className="flex-1 bg-amber-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
تواصل
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const BookingDetailsModal = ({ booking, isOpen, onClose }) => {
|
||||
if (!isOpen || !booking) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
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">رقم الحجز: #{booking.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">معلومات العقار</h3>
|
||||
<div className="space-y-2">
|
||||
<p><span className="text-gray-500">العقار:</span> {booking.propertyTitle}</p>
|
||||
<p><span className="text-gray-500">الموقع:</span> {booking.location}</p>
|
||||
{booking.propertyDetails && (
|
||||
<div className="flex gap-3 mt-2">
|
||||
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.bedrooms} غرف</span>
|
||||
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.bathrooms} حمامات</span>
|
||||
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.area} م²</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">معلومات المستأجر</h3>
|
||||
<div className="space-y-2">
|
||||
<p><span className="text-gray-500">الاسم:</span> {booking.tenantName}</p>
|
||||
<p><span className="text-gray-500">البريد الإلكتروني:</span> {booking.tenantEmail}</p>
|
||||
<p><span className="text-gray-500">رقم الهاتف:</span> {booking.tenantPhone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">تفاصيل الحجز</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-500">تاريخ البداية</p>
|
||||
<p className="font-medium">{booking.startDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">تاريخ النهاية</p>
|
||||
<p className="font-medium">{booking.endDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">عدد الأيام</p>
|
||||
<p className="font-medium">{booking.days} يوم</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">حالة الحجز</p>
|
||||
<p className="font-medium">{booking.status === 'pending' ? 'قيد الانتظار' :
|
||||
booking.status === 'confirmed' ? 'مؤكد' :
|
||||
booking.status === 'cancelled' ? 'ملغي' : 'منتهي'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3">المعلومات المالية</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">السعر اليومي</span>
|
||||
<span className="font-medium">{formatCurrency(booking.dailyPrice)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">المدة ({booking.days} أيام)</span>
|
||||
<span className="font-medium">{formatCurrency(booking.dailyPrice * booking.days)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">سلفة الضمان</span>
|
||||
<span className="font-medium">{formatCurrency(booking.securityDeposit || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-amber-200 font-bold">
|
||||
<span className="text-gray-900">الإجمالي</span>
|
||||
<span className="text-amber-600 text-lg">{formatCurrency(booking.totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.notes && (
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-2">ملاحظات</h3>
|
||||
<p className="text-gray-600">{booking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OwnerBookingsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [bookings, setBookings] = useState([]);
|
||||
const [filteredBookings, setFilteredBookings] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadBookings();
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
|
||||
const loadBookings = () => {
|
||||
const storedBookings = localStorage.getItem('ownerBookings');
|
||||
if (storedBookings) {
|
||||
setBookings(JSON.parse(storedBookings));
|
||||
setFilteredBookings(JSON.parse(storedBookings));
|
||||
} else {
|
||||
const mockBookings = [
|
||||
{
|
||||
id: 'BK001',
|
||||
propertyId: 1,
|
||||
propertyTitle: 'فيلا فاخرة في المزة',
|
||||
location: 'دمشق، المزة',
|
||||
propertyDetails: { bedrooms: 5, bathrooms: 4, area: 450 },
|
||||
tenantName: 'أحمد محمد',
|
||||
tenantEmail: 'ahmed@example.com',
|
||||
tenantPhone: '0933111222',
|
||||
startDate: '2024-03-10',
|
||||
endDate: '2024-03-15',
|
||||
days: 5,
|
||||
dailyPrice: 500000,
|
||||
totalAmount: 2500000,
|
||||
securityDeposit: 500000,
|
||||
status: 'confirmed',
|
||||
createdAt: '2024-02-25',
|
||||
notes: 'طلب الحجز من خلال الموقع'
|
||||
},
|
||||
{
|
||||
id: 'BK002',
|
||||
propertyId: 2,
|
||||
propertyTitle: 'شقة حديثة في الشهباء',
|
||||
location: 'حلب، الشهباء',
|
||||
propertyDetails: { bedrooms: 3, bathrooms: 2, area: 180 },
|
||||
tenantName: 'سارة أحمد',
|
||||
tenantEmail: 'sara@example.com',
|
||||
tenantPhone: '0945123789',
|
||||
startDate: '2024-03-05',
|
||||
endDate: '2024-03-08',
|
||||
days: 3,
|
||||
dailyPrice: 250000,
|
||||
totalAmount: 750000,
|
||||
securityDeposit: 250000,
|
||||
status: 'pending',
|
||||
createdAt: '2024-02-24',
|
||||
notes: 'تحتاج إلى تأكيد'
|
||||
},
|
||||
{
|
||||
id: 'BK003',
|
||||
propertyId: 3,
|
||||
propertyTitle: 'بيت عائلي في بابا عمرو',
|
||||
location: 'حمص، بابا عمرو',
|
||||
propertyDetails: { bedrooms: 4, bathrooms: 3, area: 300 },
|
||||
tenantName: 'محمد الحلبي',
|
||||
tenantEmail: 'mohammed@example.com',
|
||||
tenantPhone: '0956123456',
|
||||
startDate: '2024-02-20',
|
||||
endDate: '2024-03-20',
|
||||
days: 30,
|
||||
dailyPrice: 350000,
|
||||
totalAmount: 10500000,
|
||||
securityDeposit: 500000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-02-15',
|
||||
notes: 'تم إنهاء الإيجار بنجاح'
|
||||
}
|
||||
];
|
||||
setBookings(mockBookings);
|
||||
setFilteredBookings(mockBookings);
|
||||
localStorage.setItem('ownerBookings', JSON.stringify(mockBookings));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleViewDetails = (booking) => {
|
||||
setSelectedBooking(booking);
|
||||
};
|
||||
|
||||
const handleContact = (booking) => {
|
||||
toast.success(`جاري فتح محادثة مع ${booking.tenantName}`, {
|
||||
icon: '💬',
|
||||
style: { background: '#dcfce7', color: '#166534' }
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = (bookingId, newStatus) => {
|
||||
const updatedBookings = bookings.map(b =>
|
||||
b.id === bookingId ? { ...b, status: newStatus } : b
|
||||
);
|
||||
setBookings(updatedBookings);
|
||||
setFilteredBookings(updatedBookings);
|
||||
localStorage.setItem('ownerBookings', JSON.stringify(updatedBookings));
|
||||
toast.success(`تم تحديث حالة الحجز بنجاح`);
|
||||
};
|
||||
|
||||
const statusCounts = {
|
||||
all: bookings.length,
|
||||
pending: bookings.filter(b => b.status === 'pending').length,
|
||||
confirmed: bookings.filter(b => b.status === 'confirmed').length,
|
||||
completed: bookings.filter(b => b.status === 'completed').length,
|
||||
cancelled: bookings.filter(b => b.status === 'cancelled').length
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل الحجوزات...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<BookingDetailsModal
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.name}، لديك {bookings.length} حجز</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCalendar(!showCalendar)}
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
{showCalendar ? 'إخفاء التقويم' : 'عرض التقويم'}
|
||||
</button>
|
||||
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
</button> */}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200 cursor-pointer hover:shadow-md transition-all"
|
||||
onClick={() => setFilterStatus('all')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900">{statusCounts.all}</div>
|
||||
<div className="text-sm text-gray-600">جميع الحجوزات</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'pending' ? 'border-yellow-500 bg-yellow-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('pending')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-yellow-600">{statusCounts.pending}</div>
|
||||
<div className="text-sm text-gray-600">قيد الانتظار</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'confirmed' ? 'border-green-500 bg-green-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('confirmed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-green-600">{statusCounts.confirmed}</div>
|
||||
<div className="text-sm text-gray-600">مؤكدة</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'completed' ? 'border-gray-500 bg-gray-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('completed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-600">{statusCounts.completed}</div>
|
||||
<div className="text-sm text-gray-600">منتهية</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'cancelled' ? 'border-red-500 bg-red-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('cancelled')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-red-600">{statusCounts.cancelled}</div>
|
||||
<div className="text-sm text-gray-600">ملغية</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -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>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="من تاريخ"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="إلى تاريخ"
|
||||
/>
|
||||
{(dateRange.start || dateRange.end) && (
|
||||
<button
|
||||
onClick={() => setDateRange({ start: '', end: '' })}
|
||||
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
مسح
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCalendar && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<OwnerBookingCalendar
|
||||
property={{ bookings }}
|
||||
onDateSelect={(date) => console.log('Date selected:', date)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{filteredBookings.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<div className="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-12 h-12 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{filterStatus !== 'all' ? 'لا توجد حجوزات في هذه الفئة' : 'لم يتم استلام أي حجوزات بعد'}
|
||||
</p>
|
||||
{filterStatus !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600"
|
||||
>
|
||||
عرض جميع الحجوزات
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredBookings.map((booking) => (
|
||||
<BookingCard
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
onViewDetails={handleViewDetails}
|
||||
onContact={handleContact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/calendar/error.js
Normal file
27
app/owner/calendar/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/calendar/loading.js
Normal file
14
app/owner/calendar/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
738
app/owner/calendar/page.js
Normal file
738
app/owner/calendar/page.js
Normal file
@ -0,0 +1,738 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Home,
|
||||
Building,
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
DollarSign,
|
||||
Eye,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Filter,
|
||||
Download,
|
||||
Printer,
|
||||
ChevronDown,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List,
|
||||
AlertCircle,
|
||||
XCircle as XCircleIcon,
|
||||
Calendar as CalendarIcon
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
|
||||
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [viewType, setViewType] = useState('grid');
|
||||
|
||||
const monthNames = [
|
||||
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
|
||||
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
|
||||
];
|
||||
|
||||
const daysInMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth() + 1,
|
||||
0
|
||||
).getDate();
|
||||
|
||||
const firstDayOfMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
1
|
||||
).getDay();
|
||||
|
||||
const isDateBookedForProperty = (date, property) => {
|
||||
if (!property?.bookings) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.some(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const getDayStatus = (date) => {
|
||||
if (selectedPropertyId === 'all') {
|
||||
const totalProperties = properties.length;
|
||||
const bookedCount = properties.filter(p => isDateBookedForProperty(date, p)).length;
|
||||
|
||||
if (bookedCount === 0) return { status: 'all_available', label: 'جميع العقارات متاحة', color: 'bg-green-100 text-green-800' };
|
||||
if (bookedCount === totalProperties) return { status: 'all_booked', label: 'جميع العقارات محجوزة', color: 'bg-red-100 text-red-800' };
|
||||
return { status: 'partial', label: `${bookedCount}/${totalProperties} محجوز`, color: 'bg-yellow-100 text-yellow-800' };
|
||||
} else {
|
||||
const property = properties.find(p => p.id === selectedPropertyId);
|
||||
if (!property) return { status: 'no_property', label: 'غير متاح', color: 'bg-gray-100 text-gray-500' };
|
||||
|
||||
const isBooked = isDateBookedForProperty(date, property);
|
||||
return {
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
label: isBooked ? 'محجوز' : 'متاح',
|
||||
color: isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
setSelectedDate(date);
|
||||
onDateClick?.(date);
|
||||
};
|
||||
|
||||
const changeMonth = (direction) => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + direction, 1));
|
||||
};
|
||||
|
||||
const renderDays = () => {
|
||||
const days = [];
|
||||
const totalDays = daysInMonth + firstDayOfMonth;
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
if (i < firstDayOfMonth) {
|
||||
days.push(<div key={`empty-${i}`} className="p-2 md:p-3" />);
|
||||
} else {
|
||||
const dayNumber = i - firstDayOfMonth + 1;
|
||||
const date = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
dayNumber
|
||||
);
|
||||
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
const status = getDayStatus(date);
|
||||
const isSelected = selectedDate?.toDateString() === date.toDateString();
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={dayNumber}
|
||||
onClick={() => handleDateClick(date)}
|
||||
className={`
|
||||
p-2 md:p-3 rounded-xl text-center transition-all relative group
|
||||
${status.color}
|
||||
${isToday ? 'ring-2 ring-amber-500 ring-offset-2' : ''}
|
||||
${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}
|
||||
hover:scale-105 hover:shadow-md
|
||||
`}
|
||||
>
|
||||
<div className="text-sm md:text-base font-medium">{dayNumber}</div>
|
||||
<div className="text-xs mt-1 hidden md:block">{status.label}</div>
|
||||
{status.status === 'partial' && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 md:p-6 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900">
|
||||
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date())}
|
||||
className="px-4 py-2 bg-amber-500 text-white rounded-xl text-sm hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
اليوم
|
||||
</button>
|
||||
<div className="flex border border-gray-200 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewType('grid')}
|
||||
className={`p-2 transition-colors ${viewType === 'grid' ? 'bg-amber-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewType('list')}
|
||||
className={`p-2 transition-colors ${viewType === 'list' ? 'bg-amber-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 p-4 bg-gray-50 border-b border-gray-200">
|
||||
{['أحد', 'إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت'].map((day, index) => (
|
||||
<div key={index} className="text-center text-sm font-medium text-gray-600 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-7 gap-1 md:gap-2">
|
||||
{renderDays()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-100 rounded" />
|
||||
<span className="text-gray-600">متاح</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-100 rounded" />
|
||||
<span className="text-gray-600">محجوز</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-yellow-100 rounded" />
|
||||
<span className="text-gray-600">محجوز جزئياً</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 ring-2 ring-amber-500 rounded" />
|
||||
<span className="text-gray-600">اليوم</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 ring-2 ring-blue-500 rounded" />
|
||||
<span className="text-gray-600">محدد</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) => {
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const isDateBooked = (property, date) => {
|
||||
if (!property?.bookings || !date) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.some(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const getBookingForDate = (property, date) => {
|
||||
if (!property?.bookings || !date) return null;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.find(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<CalendarDays className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-2">اختر تاريخاً</h3>
|
||||
<p className="text-gray-500">اضغط على أي يوم في التقويم لعرض حالة العقارات في ذلك التاريخ</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedDate = selectedDate.toLocaleDateString('ar-SA', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 md:p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-amber-100">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
حالة العقارات في تاريخ: {formattedDate}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{properties.map((property) => {
|
||||
const isBooked = isDateBooked(property, selectedDate);
|
||||
const booking = getBookingForDate(property, selectedDate);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={property.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`p-4 md:p-6 hover:bg-gray-50 transition-colors cursor-pointer ${isBooked ? 'bg-red-50/30' : 'bg-green-50/30'}`}
|
||||
onClick={() => onPropertyClick(property)}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-bold text-gray-900 text-lg">{property.title}</h4>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{isBooked ? 'محجوز' : 'متاح'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm mb-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{property.location}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bed className="w-4 h-4" />
|
||||
<span>{property.bedrooms} غرف</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{property.bathrooms} حمامات</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>{property.area} م²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/يوم</div>
|
||||
{isBooked && booking && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div>مستأجر: {booking.tenantName || 'غير معروف'}</div>
|
||||
<div>من: {booking.startDate} إلى {booking.endDate}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{property.title}</h2>
|
||||
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<XCircleIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{property.images && property.images.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3">صور العقار</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{property.images.slice(0, 4).map((image, index) => (
|
||||
<div key={index} className="relative h-32 rounded-lg overflow-hidden bg-gray-100">
|
||||
<img src={image} alt={`${property.title} ${index + 1}`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bedrooms}</div>
|
||||
<div className="text-xs text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bathrooms}</div>
|
||||
<div className="text-xs text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.area}</div>
|
||||
<div className="text-xs text-gray-500">م²</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/يوم</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{property.features && property.features.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3">المميزات</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{property.features.map((feature, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.bookings && property.bookings.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-500" />
|
||||
الحجوزات القادمة
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{property.bookings.slice(0, 3).map((booking, index) => (
|
||||
<div key={index} className="bg-gray-50 p-3 rounded-lg flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.startDate} - {booking.endDate}</p>
|
||||
<p className="text-xs text-gray-500">مستأجر: {booking.tenantName || 'غير معروف'}</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 flex gap-3">
|
||||
<Link
|
||||
href={`/owner/properties/edit?id=${property.id}`}
|
||||
className="flex-1 bg-amber-500 text-white py-3 rounded-xl text-center font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تعديل العقار
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.location.href = `/owner/bookings?property=${property.id}`}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl text-center font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
عرض الحجوزات
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OwnerCalendarPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [filteredProperties, setFilteredProperties] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState('all');
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedProperty, setSelectedProperty] = useState(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadCalendar();
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
|
||||
|
||||
const loadProperties = () => {
|
||||
const storedProperties = localStorage.getItem('ownerProperties');
|
||||
if (storedProperties) {
|
||||
const props = JSON.parse(storedProperties);
|
||||
setProperties(props);
|
||||
setFilteredProperties(props);
|
||||
} else {
|
||||
const mockProperties = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
location: 'دمشق، المزة',
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
price: 500000,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7'],
|
||||
images: ['/villa1.jpg'],
|
||||
status: 'available',
|
||||
bookings: [
|
||||
{ startDate: '2024-03-10', endDate: '2024-03-15', totalAmount: 2500000, tenantName: 'أحمد محمد' },
|
||||
{ startDate: '2024-03-20', endDate: '2024-03-25', totalAmount: 2500000, tenantName: 'سارة أحمد' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
location: 'حلب، الشهباء',
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
price: 250000,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
images: ['/apartment1.jpg'],
|
||||
status: 'available',
|
||||
bookings: [
|
||||
{ startDate: '2024-03-05', endDate: '2024-03-08', totalAmount: 750000, tenantName: 'محمد علي' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
location: 'حمص، بابا عمرو',
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
price: 350000,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
bookings: []
|
||||
}
|
||||
];
|
||||
setProperties(mockProperties);
|
||||
setFilteredProperties(mockProperties);
|
||||
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const calendarStats = {
|
||||
totalProperties: properties.length,
|
||||
availableToday: properties.filter(p => {
|
||||
const today = new Date();
|
||||
const isBooked = p.bookings?.some(b => {
|
||||
const start = new Date(b.startDate);
|
||||
const end = new Date(b.endDate);
|
||||
return today >= start && today <= end;
|
||||
});
|
||||
return !isBooked;
|
||||
}).length,
|
||||
bookedToday: properties.filter(p => {
|
||||
const today = new Date();
|
||||
return p.bookings?.some(b => {
|
||||
const start = new Date(b.startDate);
|
||||
const end = new Date(b.endDate);
|
||||
return today >= start && today <= end;
|
||||
});
|
||||
}).length,
|
||||
upcomingBookings: properties.reduce((sum, p) => sum + (p.bookings?.length || 0), 0)
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل التقويم...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<PropertyDetailsModal
|
||||
property={selectedProperty}
|
||||
isOpen={!!selectedProperty}
|
||||
onClose={() => setSelectedProperty(null)}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">تقويم العقارات</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.name}، تتبع حالة عقاراتك عبر التقويم</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
فلترة العقارات
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
<Printer className="w-5 h-5" />
|
||||
طباعة التقويم
|
||||
</button> */}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<Building className="w-6 h-6 text-amber-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-gray-900">{calendarStats.totalProperties}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي العقارات</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-green-600">{calendarStats.availableToday}</div>
|
||||
<div className="text-sm text-gray-600">متاح اليوم</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<XCircle className="w-6 h-6 text-red-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-red-600">{calendarStats.bookedToday}</div>
|
||||
<div className="text-sm text-gray-600">محجوز اليوم</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<CalendarDays className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-blue-600">{calendarStats.upcomingBookings}</div>
|
||||
<div className="text-sm text-gray-600">حجوزات قادمة</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<label className="text-sm font-medium text-gray-700">اختر عقاراً:</label>
|
||||
<select
|
||||
value={selectedPropertyId}
|
||||
onChange={(e) => setSelectedPropertyId(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="all">جميع العقارات</option>
|
||||
{properties.map((property) => (
|
||||
<option key={property.id} value={property.id}>{property.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSelectedPropertyId('all')}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إعادة تعيين
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mb-8">
|
||||
<MonthlyCalendar
|
||||
properties={filteredProperties}
|
||||
selectedPropertyId={selectedPropertyId}
|
||||
onDateClick={setSelectedDate}
|
||||
onPropertySelect={setSelectedProperty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PropertyCalendarList
|
||||
properties={filteredProperties}
|
||||
selectedDate={selectedDate}
|
||||
onPropertyClick={setSelectedProperty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-6 text-center text-sm text-gray-500"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 inline ml-1" />
|
||||
اضغط على أي عقار لعرض التفاصيل الكاملة
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/profits/error.js
Normal file
27
app/owner/profits/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/profits/loading.js
Normal file
14
app/owner/profits/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
592
app/owner/profits/page.js
Normal file
592
app/owner/profits/page.js
Normal file
@ -0,0 +1,592 @@
|
||||
// 'use client';
|
||||
|
||||
// import { useState, useEffect } from 'react';
|
||||
// import { motion } from 'framer-motion';
|
||||
// import { useRouter } from 'next/navigation';
|
||||
// import {
|
||||
// DollarSign,
|
||||
// TrendingUp,
|
||||
// Wallet,
|
||||
// Star,
|
||||
// Eye,
|
||||
// Download,
|
||||
// CalendarDays
|
||||
// } from 'lucide-react';
|
||||
// import toast, { Toaster } from 'react-hot-toast';
|
||||
// import AuthService from '@/app/services/AuthService';
|
||||
|
||||
// const StatCard = ({ title, value, icon: Icon, color }) => {
|
||||
// return (
|
||||
// <motion.div
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
|
||||
// >
|
||||
// <div className="flex items-center justify-between mb-4">
|
||||
// <div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
|
||||
// <Icon className="w-6 h-6 text-white" />
|
||||
// </div>
|
||||
// </div>
|
||||
// <h3 className="text-sm text-gray-500 mb-1">{title}</h3>
|
||||
// <div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
// </motion.div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const PropertyProfitCard = ({ property, onViewDetails }) => {
|
||||
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
|
||||
|
||||
// return (
|
||||
// <motion.div
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
// >
|
||||
// <div className="p-5">
|
||||
// <div className="flex justify-between items-start mb-4">
|
||||
// <div>
|
||||
// <h3 className="font-bold text-lg text-gray-900">{property.title}</h3>
|
||||
// {property.isNotSeized && (
|
||||
// <span className="inline-block mt-1 px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
|
||||
// غير محجوز
|
||||
// </span>
|
||||
// )}
|
||||
// </div>
|
||||
// <span className="text-xs text-gray-500">{property.location}</span>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-3 gap-4 mb-4">
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">الإيرادات</div>
|
||||
// <div className="text-lg font-bold text-amber-600">{formatCurrency(property.revenue)}</div>
|
||||
// </div>
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">العمولة</div>
|
||||
// <div className="text-lg font-bold text-blue-600">{formatCurrency(property.commission)}</div>
|
||||
// </div>
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">المتبقي</div>
|
||||
// <div className="text-lg font-bold text-green-600">{formatCurrency(property.remaining)}</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="flex justify-between items-center pt-3 border-t border-gray-100">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <Star className="w-4 h-4 text-amber-500" />
|
||||
// <span className="text-sm font-medium text-gray-700">التقييم العام:</span>
|
||||
// <span className="text-sm font-medium text-gray-900">{property.valuation}</span>
|
||||
// </div>
|
||||
// <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
// <CalendarDays className="w-4 h-4" />
|
||||
// <span>مؤجر {property.rentedCount} مرة</span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <button
|
||||
// onClick={() => onViewDetails(property)}
|
||||
// className="w-full mt-4 py-2 bg-gray-100 text-gray-700 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>
|
||||
// </div>
|
||||
// </motion.div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const PropertyCalendar = ({ year, month }) => {
|
||||
// const [currentMonth, setCurrentMonth] = useState(new Date(year, month - 1));
|
||||
// const monthNames = ['يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'];
|
||||
// const weekDays = ['إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت', 'أحد'];
|
||||
|
||||
// const getDaysInMonth = (date) => {
|
||||
// return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
// };
|
||||
|
||||
// const getFirstDayOfMonth = (date) => {
|
||||
// const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
// return day === 0 ? 6 : day - 1;
|
||||
// };
|
||||
|
||||
// const daysInMonth = getDaysInMonth(currentMonth);
|
||||
// const firstDayIndex = getFirstDayOfMonth(currentMonth);
|
||||
|
||||
// const cells = [];
|
||||
// for (let i = 0; i < firstDayIndex; i++) {
|
||||
// cells.push(<div key={`empty-${i}`} className="p-2 md:p-3 text-center" />);
|
||||
// }
|
||||
// for (let d = 1; d <= daysInMonth; d++) {
|
||||
// cells.push(
|
||||
// <div
|
||||
// key={d}
|
||||
// className="p-2 md:p-3 text-center rounded-xl hover:bg-gray-100 transition-colors"
|
||||
// >
|
||||
// {d}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
// <div className="flex justify-between items-center mb-6">
|
||||
// <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
// <CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
// {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
// </h3>
|
||||
// <div className="flex gap-2">
|
||||
// <button
|
||||
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
|
||||
// className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
// >
|
||||
// ←
|
||||
// </button>
|
||||
// <button
|
||||
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
|
||||
// className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
// >
|
||||
// →
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
|
||||
// {weekDays.map(day => (
|
||||
// <div key={day}>{day}</div>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-7 gap-1">
|
||||
// {cells}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default function OwnerProfitsPage() {
|
||||
// const router = useRouter();
|
||||
// const [user, setUser] = useState(null);
|
||||
// const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// const [summary] = useState({
|
||||
// totalRevenue: 4290,
|
||||
// totalCommission: 644,
|
||||
// remainingBalance: 3647,
|
||||
// });
|
||||
|
||||
// const [properties] = useState([
|
||||
// {
|
||||
// id: 1,
|
||||
// title: 'Damascus Olive Residence',
|
||||
// location: 'دمشق، المزة',
|
||||
// isNotSeized: true,
|
||||
// revenue: 3240,
|
||||
// commission: 486,
|
||||
// remaining: 2754,
|
||||
// valuation: 'جيد جدا',
|
||||
// rentedCount: 18,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (AuthService.isGuest()) {
|
||||
// router.push('/auth/choose-role');
|
||||
// return;
|
||||
// }
|
||||
// if (!AuthService.isOwner()) {
|
||||
// router.push('/');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const authUser = AuthService.getUser();
|
||||
// if (authUser) {
|
||||
// setUser({
|
||||
// name: authUser.name || authUser.email,
|
||||
// email: authUser.email,
|
||||
// });
|
||||
// }
|
||||
// setIsLoading(false);
|
||||
// }, [router]);
|
||||
|
||||
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
|
||||
|
||||
// const handleViewDetails = (property) => {
|
||||
// toast.info(`عرض تفاصيل ${property.title}`);
|
||||
// };
|
||||
|
||||
// const handleExportReport = () => {
|
||||
// toast.success('جاري تصدير التقرير...');
|
||||
// };
|
||||
|
||||
// if (isLoading) {
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center">
|
||||
// <div className="text-center">
|
||||
// <div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
// <p className="text-gray-600">جاري التحميل...</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="min-h-screen bg-gray-50 py-8">
|
||||
// <Toaster position="top-center" reverseOrder={false} />
|
||||
// <div className="container mx-auto px-4 max-w-6xl">
|
||||
// <div className="mb-8">
|
||||
// <h1 className="text-3xl font-bold text-gray-900 mb-2">دفتر الحسابات</h1>
|
||||
// <p className="text-gray-600">نظرة عامة على أرباح المالك</p>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
// <StatCard
|
||||
// title="الإيرادات"
|
||||
// value={formatCurrency(summary.totalRevenue)}
|
||||
// icon={DollarSign}
|
||||
// color="bg-green-500"
|
||||
// />
|
||||
// <StatCard
|
||||
// title="العمولة"
|
||||
// value={formatCurrency(summary.totalCommission)}
|
||||
// icon={TrendingUp}
|
||||
// color="bg-blue-500"
|
||||
// />
|
||||
// <StatCard
|
||||
// title="المتبقي"
|
||||
// value={formatCurrency(summary.remainingBalance)}
|
||||
// icon={Wallet}
|
||||
// color="bg-amber-500"
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <div className="mb-12">
|
||||
// <h2 className="text-xl font-bold text-gray-900 mb-4">عقاراتي</h2>
|
||||
// <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
// {properties.map((property) => (
|
||||
// <PropertyProfitCard
|
||||
// key={property.id}
|
||||
// property={property}
|
||||
// onViewDetails={handleViewDetails}
|
||||
// />
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mb-12">
|
||||
// <h2 className="text-xl font-bold text-gray-900 mb-4">تقويم العقار</h2>
|
||||
// <PropertyCalendar year={2026} month={3} />
|
||||
// </div>
|
||||
|
||||
// {/* <div className="flex justify-end">
|
||||
// <button
|
||||
// onClick={handleExportReport}
|
||||
// className="px-6 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
|
||||
// >
|
||||
// <Download className="w-5 h-5" />
|
||||
// تصدير التقرير
|
||||
// </button>
|
||||
// </div> */}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import * as XLSX from 'xlsx';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function OwnerProfitsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [tableData, setTableData] = useState([]);
|
||||
|
||||
const sampleData = [
|
||||
{
|
||||
id: 1,
|
||||
property: 'A000000001',
|
||||
bookingNumber: 'XX-101',
|
||||
fromDate: '2025-05-01',
|
||||
toDate: '2025-05-07',
|
||||
amountReceived: 500,
|
||||
platformCommission: 0,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: '—',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
property: 'A000000002',
|
||||
bookingNumber: 'XX-202',
|
||||
fromDate: '2025-05-10',
|
||||
toDate: '2025-05-15',
|
||||
amountReceived: 300,
|
||||
platformCommission: 0,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: '—',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
property: 'A000000003',
|
||||
bookingNumber: 'XX-309',
|
||||
fromDate: '2025-06-01',
|
||||
toDate: '2025-06-05',
|
||||
amountReceived: 800,
|
||||
platformCommission: 150,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: 'قيد الانتظار',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const computeRows = (data) => {
|
||||
return data.map((item) => {
|
||||
const platformProfit = item.amountReceived * 0.05;
|
||||
const ownerDue = item.amountReceived - platformProfit;
|
||||
return {
|
||||
...item,
|
||||
platformProfit,
|
||||
ownerDue,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isGuest()) {
|
||||
router.push('/auth/choose-role');
|
||||
return;
|
||||
}
|
||||
if (!AuthService.isOwner()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
});
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem('ownerProfitsTable');
|
||||
if (stored) {
|
||||
setTableData(computeRows(JSON.parse(stored)));
|
||||
} else {
|
||||
setTableData(computeRows(sampleData));
|
||||
localStorage.setItem('ownerProfitsTable', JSON.stringify(sampleData));
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [router]);
|
||||
|
||||
const totals = tableData.reduce(
|
||||
(acc, row) => {
|
||||
acc.totalAmountReceived += row.amountReceived;
|
||||
acc.totalCommission += row.platformCommission;
|
||||
acc.totalPlatformProfit += row.platformProfit;
|
||||
acc.totalOwnerDue += row.ownerDue;
|
||||
acc.totalTransferred += row.transferredToOwner;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalAmountReceived: 0,
|
||||
totalCommission: 0,
|
||||
totalPlatformProfit: 0,
|
||||
totalOwnerDue: 0,
|
||||
totalTransferred: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const handleExportReport = () => {
|
||||
try {
|
||||
const exportData = tableData.map((row) => ({
|
||||
'العقار': row.property,
|
||||
'رقم الحجز': row.bookingNumber,
|
||||
'من تاريخ': row.fromDate,
|
||||
'حتى تاريخ': row.toDate,
|
||||
'العروض المستلم': row.amountReceived,
|
||||
'عمولة المنصة': row.platformCommission,
|
||||
'ربح المنصة (5%)': row.platformProfit,
|
||||
'المستحق للمالك': row.ownerDue,
|
||||
'تم التحويل للمالك': row.transferredToOwner,
|
||||
'رقم وصل التحويل': row.transferReceipt,
|
||||
}));
|
||||
|
||||
exportData.push({
|
||||
'العقار': 'الإجمالي العام',
|
||||
'رقم الحجز': '',
|
||||
'من تاريخ': '',
|
||||
'حتى تاريخ': '',
|
||||
'العروض المستلم': totals.totalAmountReceived,
|
||||
'عمولة المنصة': totals.totalCommission,
|
||||
'ربح المنصة (5%)': totals.totalPlatformProfit,
|
||||
'المستحق للمالك': totals.totalOwnerDue,
|
||||
'تم التحويل للمالك': totals.totalTransferred,
|
||||
'رقم وصل التحويل': '—',
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
const colWidths = [
|
||||
{ wch: 15 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 14 },
|
||||
{ wch: 14 },
|
||||
{ wch: 16 },
|
||||
{ wch: 16 },
|
||||
{ wch: 16 },
|
||||
{ wch: 18 },
|
||||
];
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'أرباح المالك');
|
||||
|
||||
XLSX.writeFile(workbook, `تقرير_الأرباح_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
|
||||
|
||||
toast.success('تم تصدير التقرير بنجاح!');
|
||||
} catch (error) {
|
||||
console.error('خطأ في التصدير:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير التقرير');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل بيانات الأرباح...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
|
||||
<p className="text-gray-600">
|
||||
مرحباً {user?.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportReport}
|
||||
className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-800 text-gray-100">
|
||||
<tr>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العقار</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم الحجز</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">من تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">حتى تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العروض المستلم</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">عمولة المنصة</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider bg-amber-50 text-amber-800">
|
||||
ربح المنصة <span className="font-normal text-[11px] block">(5% من العربون)</span>
|
||||
</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">المستحق للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">تم التحويل للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم وصل التحويل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{tableData.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`hover:bg-amber-50/40 transition-colors ${
|
||||
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-medium text-gray-800">
|
||||
{row.property}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.bookingNumber}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.fromDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.toDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-gray-800">
|
||||
{row.amountReceived}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
|
||||
{row.platformCommission}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-bold text-amber-700 bg-amber-50/50">
|
||||
{row.platformProfit}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-emerald-700">
|
||||
{row.ownerDue}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
|
||||
{row.transferredToOwner}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-500 text-xs">
|
||||
{row.transferReceipt}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-100 border-t-2 border-gray-300">
|
||||
<tr>
|
||||
<td colSpan="4" className="px-4 py-4 text-right font-bold text-gray-800">
|
||||
الإجمالي العام
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalAmountReceived}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalCommission}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-amber-700 bg-amber-100/60">
|
||||
{totals.totalPlatformProfit}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-emerald-700">
|
||||
{totals.totalOwnerDue}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalTransferred}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center text-gray-500">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
|
||||
<span className="inline-flex items-center gap-1"></span> ملاحظة:
|
||||
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/properties/add/error.js
Normal file
27
app/owner/properties/add/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/properties/add/loading.js
Normal file
14
app/owner/properties/add/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -51,12 +51,27 @@ import {
|
||||
Move
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addRentProperty, getCurrencies, uploadPicture } from '../../../utils/api';
|
||||
import {
|
||||
BuildingType,
|
||||
RentPropertyCondition,
|
||||
RentPropertyType,
|
||||
RentType,
|
||||
PropertyService,
|
||||
PropertyServiceLabels,
|
||||
PropertyServicesList,
|
||||
PropertyTerm,
|
||||
PropertyTermLabels,
|
||||
PropertyTermsList,
|
||||
Currency,
|
||||
CurrencyLabels
|
||||
} from '../../../enums';
|
||||
|
||||
const MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false });
|
||||
const useMapEvents = dynamic(() => import('react-leaflet').then(mod => mod.useMapEvents), { ssr: false });
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
|
||||
function MapClickHandler({ onMapClick }) {
|
||||
const map = useMapEvents({
|
||||
@ -84,29 +99,27 @@ export default function AddPropertyPage() {
|
||||
livingRooms: 1,
|
||||
|
||||
services: {
|
||||
electricity: false,
|
||||
internet: false,
|
||||
heating: false,
|
||||
water: false,
|
||||
airConditioning: false,
|
||||
parking: false,
|
||||
elevator: false
|
||||
[PropertyService.ELECTRICITY]: false,
|
||||
[PropertyService.INTERNET]: false,
|
||||
[PropertyService.HEATING]: false,
|
||||
[PropertyService.WATER]: false,
|
||||
[PropertyService.CENTRAL_AIR_CONDITIONING]: false,
|
||||
[PropertyService.PARKING]: false,
|
||||
[PropertyService.ELEVATOR]: false
|
||||
},
|
||||
|
||||
serviceDetails: {},
|
||||
|
||||
terms: {
|
||||
noSmoking: false,
|
||||
noPets: false,
|
||||
noParties: false,
|
||||
noAlcohol: false,
|
||||
suitableForChildren: true,
|
||||
suitableForElderly: true
|
||||
[PropertyTerm.NO_SMOKING]: false,
|
||||
[PropertyTerm.NO_ANIMALS]: false,
|
||||
[PropertyTerm.NO_PARTIES]: false
|
||||
},
|
||||
|
||||
offerType: 'daily',
|
||||
|
||||
dailyPrice: '',
|
||||
monthlyPrice: '',
|
||||
salePrice: '',
|
||||
|
||||
city: '',
|
||||
district: '',
|
||||
@ -120,11 +133,15 @@ export default function AddPropertyPage() {
|
||||
});
|
||||
|
||||
const [imagePreviews, setImagePreviews] = useState([]);
|
||||
const [uploadedImagePaths, setUploadedImagePaths] = useState([]);
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||
const [mapCenter, setMapCenter] = useState([33.5138, 36.2765]);
|
||||
const [mapZoom, setMapZoom] = useState(13);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [selectedCurrencyId, setSelectedCurrencyId] = useState(Currency.SYP);
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
@ -140,30 +157,26 @@ export default function AddPropertyPage() {
|
||||
];
|
||||
|
||||
const serviceList = [
|
||||
{ id: 'electricity', label: 'كهرباء', icon: Zap },
|
||||
{ id: 'internet', label: 'انترنت', icon: Wifi },
|
||||
{ id: 'heating', label: 'تدفئة', icon: Flame },
|
||||
{ id: 'water', label: 'ماء', icon: Droplets },
|
||||
{ id: 'airConditioning', label: 'تكييف', icon: Wind },
|
||||
{ id: 'parking', label: 'موقف سيارات', icon: Warehouse },
|
||||
{ id: 'elevator', label: 'مصعد', icon: Layers }
|
||||
{ id: PropertyService.ELECTRICITY, label: PropertyServiceLabels[PropertyService.ELECTRICITY], icon: Zap },
|
||||
{ id: PropertyService.INTERNET, label: PropertyServiceLabels[PropertyService.INTERNET], icon: Wifi },
|
||||
{ id: PropertyService.HEATING, label: PropertyServiceLabels[PropertyService.HEATING], icon: Flame },
|
||||
{ id: PropertyService.WATER, label: PropertyServiceLabels[PropertyService.WATER], icon: Droplets },
|
||||
{ id: PropertyService.CENTRAL_AIR_CONDITIONING, label: PropertyServiceLabels[PropertyService.CENTRAL_AIR_CONDITIONING], icon: Wind },
|
||||
{ id: PropertyService.PARKING, label: PropertyServiceLabels[PropertyService.PARKING], icon: Warehouse },
|
||||
{ id: PropertyService.ELEVATOR, label: PropertyServiceLabels[PropertyService.ELEVATOR], icon: Layers },
|
||||
];
|
||||
|
||||
const termsList = [
|
||||
{ id: 'noSmoking', label: 'ممنوع التدخين', icon: Cigarette },
|
||||
{ id: 'noPets', label: 'ممنوع الحيوانات', icon: Dog },
|
||||
{ id: 'noParties', label: 'عدم إقامة حفلات', icon: Music },
|
||||
{ id: 'noAlcohol', label: 'ممنوع الكحول', icon: X },
|
||||
{ id: 'suitableForChildren', label: 'مناسب للأطفال', icon: Star },
|
||||
{ id: 'suitableForElderly', label: 'مناسب لكبار السن', icon: Star }
|
||||
{ id: PropertyTerm.NO_SMOKING, label: PropertyTermLabels[PropertyTerm.NO_SMOKING], icon: Cigarette },
|
||||
{ id: PropertyTerm.NO_ANIMALS, label: PropertyTermLabels[PropertyTerm.NO_ANIMALS], icon: Dog },
|
||||
{ id: PropertyTerm.NO_PARTIES, label: PropertyTermLabels[PropertyTerm.NO_PARTIES], icon: Music },
|
||||
];
|
||||
|
||||
const offerTypes = [
|
||||
{ id: 'daily', label: 'إيجار يومي', icon: Clock },
|
||||
{ id: 'monthly', label: 'إيجار شهري', icon: Calendar },
|
||||
{ id: 'both', label: 'إيجار يومي وشهري', icon: Calendar },
|
||||
{ id: 'sale', label: 'للبيع', icon: DollarSign }
|
||||
];
|
||||
].filter(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -176,6 +189,16 @@ export default function AddPropertyPage() {
|
||||
});
|
||||
}
|
||||
setMapLoaded(true);
|
||||
|
||||
// Fetch available currencies
|
||||
getCurrencies().then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setCurrencies(data);
|
||||
console.log('[AddProperty] Currencies loaded:', data);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[AddProperty] Failed to load currencies:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
@ -317,36 +340,48 @@ const handleMapClick = async (coords) => {
|
||||
toast.info('تم إلغاء تحديد الموقع');
|
||||
};
|
||||
|
||||
const handleImageUpload = (files) => {
|
||||
const handleImageUpload = async (files) => {
|
||||
const newImages = Array.from(files);
|
||||
console.log('[AddProperty] handleImageUpload called with', newImages.length, 'files');
|
||||
|
||||
if (formData.images.length + newImages.length > 10) {
|
||||
toast.error('يمكنك رفع 10 صور كحد أقصى');
|
||||
return;
|
||||
}
|
||||
|
||||
newImages.forEach(file => {
|
||||
for (const file of newImages) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('الرجاء اختيار صور صالحة فقط');
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews(prev => [...prev, reader.result]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
images: [...formData.images, file]
|
||||
});
|
||||
});
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, file]
|
||||
}));
|
||||
|
||||
// Upload to server immediately
|
||||
try {
|
||||
const path = await uploadPicture(file);
|
||||
setUploadedImagePaths(prev => [...prev, path]);
|
||||
console.log('[AddProperty] Image uploaded:', path);
|
||||
} catch (err) {
|
||||
console.error('[AddProperty] Image upload failed:', err);
|
||||
toast.error('فشل رفع الصورة: ' + file.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index) => {
|
||||
@ -356,30 +391,34 @@ const handleMapClick = async (coords) => {
|
||||
const newPreviews = [...imagePreviews];
|
||||
newPreviews.splice(index, 1);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
images: newImages
|
||||
});
|
||||
const newPaths = [...uploadedImagePaths];
|
||||
newPaths.splice(index, 1);
|
||||
|
||||
setFormData(prev => ({ ...prev, images: newImages }));
|
||||
setImagePreviews(newPreviews);
|
||||
setUploadedImagePaths(newPaths);
|
||||
};
|
||||
|
||||
const toggleService = (serviceId) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
services: {
|
||||
...formData.services,
|
||||
[serviceId]: !formData.services[serviceId]
|
||||
}
|
||||
setFormData(prev => {
|
||||
const services = { ...prev.services };
|
||||
services[serviceId] = !services[serviceId];
|
||||
return { ...prev, services };
|
||||
});
|
||||
};
|
||||
|
||||
const updateServiceDetail = (serviceId, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
serviceDetails: { ...prev.serviceDetails, [serviceId]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleTerm = (termId) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
terms: {
|
||||
...formData.terms,
|
||||
[termId]: !formData.terms[termId]
|
||||
}
|
||||
setFormData(prev => {
|
||||
const terms = { ...prev.terms };
|
||||
terms[termId] = !terms[termId];
|
||||
return { ...prev, terms };
|
||||
});
|
||||
};
|
||||
|
||||
@ -464,9 +503,6 @@ const handleMapClick = async (coords) => {
|
||||
if (!formData.dailyPrice) newErrors.dailyPrice = 'السعر اليومي مطلوب';
|
||||
if (!formData.monthlyPrice) newErrors.monthlyPrice = 'السعر الشهري مطلوب';
|
||||
}
|
||||
if (formData.offerType === 'sale' && !formData.salePrice) {
|
||||
newErrors.salePrice = 'سعر البيع مطلوب';
|
||||
}
|
||||
break;
|
||||
|
||||
case 4:
|
||||
@ -499,16 +535,92 @@ const handleMapClick = async (coords) => {
|
||||
if (!validateStep()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
console.log('[AddProperty] Building RentPropertyDto payload...');
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Property Data:', formData);
|
||||
setIsLoading(false);
|
||||
// Map UI property type to API BuildingType enum
|
||||
const buildingTypeMap = { apartment: BuildingType.APARTMENT, villa: BuildingType.VILLA, suite: BuildingType.APARTMENT, room: BuildingType.APARTMENT };
|
||||
|
||||
// Map offer type to RentType enum: 0=Monthly, 1=Daily
|
||||
const rentTypeMap = { daily: RentType.DAILY, monthly: RentType.MONTHLY, both: RentType.MONTHLY };
|
||||
|
||||
// Services: collect selected service enum names into array
|
||||
const selectedServices = Object.entries(formData.services)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k); // k is already the enum value (e.g. "Electricity")
|
||||
|
||||
// Terms: collect selected term enum names into array
|
||||
const selectedTerms = Object.entries(formData.terms)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k); // k is already the enum value (e.g. "NoSmoking")
|
||||
|
||||
// Build detailsJSON matching Flutter structure
|
||||
const detailsJSON = JSON.stringify({
|
||||
services: selectedServices,
|
||||
serviceDetails: selectedServices.reduce((acc, s) => ({ ...acc, [s]: 'in general' }), {}),
|
||||
terms: selectedTerms,
|
||||
displayType: formData.offerType === 'both' ? 'Both' : formData.offerType === 'daily' ? 'Daily' : 'Monthly',
|
||||
propertyCondition: formData.furnished ? 'Furnished' : 'Unfurnished',
|
||||
photos: imagePreviews.map((_, i) => `photo_${i}.jpg`),
|
||||
room: {
|
||||
areaType: formData.propertyType === 'room' ? 'Shared room' : 'Private room',
|
||||
peopleAllowed: String(formData.bedrooms),
|
||||
entranceType: formData.propertyType === 'room' ? 'Shared entrance' : 'Private entrance',
|
||||
bathroomType: formData.bathrooms > 1 ? 'Private' : 'Shared',
|
||||
kitchenType: 'Not available',
|
||||
hasRestrictedOwnerAreas: false,
|
||||
languageDialect: '',
|
||||
hasChildren: false,
|
||||
hasPets: false,
|
||||
dedicatedTo: 'Everyone',
|
||||
visitorsAllowed: true,
|
||||
quietTimesEnabled: false,
|
||||
quietTimes: '',
|
||||
}
|
||||
});
|
||||
|
||||
const payload = {
|
||||
propertyInformation: {
|
||||
cordsX: formData.lat ? String(formData.lat) : '',
|
||||
cordsY: formData.lng ? String(formData.lng) : '',
|
||||
address: `${formData.city} - ${formData.district} - ${formData.address}`.trim(),
|
||||
description: formData.description || '',
|
||||
numberOfBathRooms: formData.bathrooms || 0,
|
||||
numberOfRooms: (formData.bedrooms || 0) + (formData.livingRooms || 0),
|
||||
numberOfBedRooms: formData.bedrooms || 0,
|
||||
space: parseFloat(formData.space) || 0,
|
||||
detailsJSON,
|
||||
buildingType: buildingTypeMap[formData.propertyType] ?? BuildingType.APARTMENT,
|
||||
status: 0,
|
||||
propertyType: formData.furnished ? RentPropertyCondition.WITH_FURNITURE : RentPropertyCondition.WITHOUT_FURNITURE,
|
||||
images: uploadedImagePaths,
|
||||
},
|
||||
deposit: parseFloat(formData.deposit) || 0,
|
||||
monthlyRent: parseFloat(formData.monthlyPrice) || 0,
|
||||
dailyRent: parseFloat(formData.dailyPrice) || 0,
|
||||
rating: 0,
|
||||
currencyId: selectedCurrencyId,
|
||||
rentType: rentTypeMap[formData.offerType] ?? RentType.MONTHLY,
|
||||
isSmokeAllow: !formData.terms[PropertyTerm.NO_SMOKING],
|
||||
specializedFor: false,
|
||||
isVisitorAllow: !formData.terms[PropertyTerm.NO_PARTIES],
|
||||
type: formData.furnished ? RentPropertyType.FURNISHED : RentPropertyType.UNFURNISHED,
|
||||
};
|
||||
|
||||
console.log('[AddProperty] Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
try {
|
||||
const res = await addRentProperty(payload);
|
||||
console.log('[AddProperty] API response:', res);
|
||||
toast.success('تم إضافة العقار بنجاح!');
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/owner/properties');
|
||||
}, 1500);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('[AddProperty] API error:', err);
|
||||
toast.error(err.message || 'فشل في إضافة العقار');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
@ -517,15 +629,6 @@ const handleMapClick = async (coords) => {
|
||||
transition: { duration: 0.5 }
|
||||
};
|
||||
|
||||
function MapClickHandler({ onMapClick }) {
|
||||
const map = useMapEvents({
|
||||
dblclick: (e) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
onMapClick([lat, lng]);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
@ -752,34 +855,37 @@ function MapClickHandler({ onMapClick }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة <span className="text-red-500">*</span></h3>
|
||||
<div className="space-y-3">
|
||||
{serviceList.map((service) => {
|
||||
const Icon = service.icon;
|
||||
const isSelected = formData.services[service.id];
|
||||
return (
|
||||
<label
|
||||
key={service.id}
|
||||
className={`flex items-center gap-2 p-3 border rounded-xl cursor-pointer transition-all ${
|
||||
formData.services[service.id]
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-gray-200 hover:border-amber-200 hover:bg-amber-50/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.services[service.id]}
|
||||
onChange={() => toggleService(service.id)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
formData.services[service.id] ? 'text-amber-600' : 'text-gray-400'
|
||||
}`} />
|
||||
<span className={`text-sm ${
|
||||
formData.services[service.id] ? 'text-amber-700' : 'text-gray-600'
|
||||
}`}>
|
||||
{service.label}
|
||||
</span>
|
||||
</label>
|
||||
<div key={service.id} className={`border rounded-xl transition-all ${isSelected ? 'border-amber-500 bg-amber-50' : 'border-gray-200'}`}>
|
||||
<label className="flex items-center gap-3 p-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleService(service.id)}
|
||||
className="w-4 h-4 text-amber-500 rounded"
|
||||
/>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-amber-700' : 'text-gray-600'}`}>
|
||||
{service.label}
|
||||
</span>
|
||||
</label>
|
||||
{isSelected && (
|
||||
<div className="px-3 pb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serviceDetails[service.id] || ''}
|
||||
onChange={(e) => updateServiceDetail(service.id, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="تفاصيل الخدمة (مثال: في جميع الغرف)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -857,6 +963,41 @@ function MapClickHandler({ onMapClick }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currency dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
العملة <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedCurrencyId}
|
||||
onChange={(e) => setSelectedCurrencyId(parseInt(e.target.value))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
{Object.entries(CurrencyLabels).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deposit field */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
مبلغ الضمان (العربون)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
value={formData.deposit || ''}
|
||||
onChange={(e) => setFormData({...formData, deposit: e.target.value})}
|
||||
className="w-full pr-12 pl-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="مثال: 500000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{(formData.offerType === 'daily' || formData.offerType === 'both') && (
|
||||
<motion.div
|
||||
@ -919,37 +1060,6 @@ function MapClickHandler({ onMapClick }) {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{formData.offerType === 'sale' && (
|
||||
<motion.div
|
||||
key="sale"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
سعر البيع (ل.س) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
value={formData.salePrice}
|
||||
onChange={(e) => setFormData({...formData, salePrice: e.target.value})}
|
||||
className={`w-full pr-12 pl-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||
errors.salePrice ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="أدخل السعر المطلوب"
|
||||
/>
|
||||
</div>
|
||||
{errors.salePrice && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.salePrice}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
27
app/owner/properties/error.js
Normal file
27
app/owner/properties/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/properties/loading.js
Normal file
14
app/owner/properties/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -45,6 +45,8 @@ import {
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { getMyRentListings } from '../../utils/api';
|
||||
|
||||
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
||||
if (!isOpen) return null;
|
||||
@ -692,70 +694,84 @@ export default function OwnerPropertiesPage() {
|
||||
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (userData.role !== 'owner') {
|
||||
router.push('/');
|
||||
} else {
|
||||
setUser(userData);
|
||||
loadProperties();
|
||||
}
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadProperties();
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const loadProperties = () => {
|
||||
const storedProperties = localStorage.getItem('ownerProperties');
|
||||
if (storedProperties) {
|
||||
setProperties(JSON.parse(storedProperties));
|
||||
} else {
|
||||
const mockProperties = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
propertyType: 'villa',
|
||||
purpose: 'rent',
|
||||
rentType: 'both',
|
||||
dailyPrice: 500000,
|
||||
monthlyPrice: 15000000,
|
||||
location: 'دمشق، المزة',
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
livingRooms: 3,
|
||||
status: 'available',
|
||||
images: ['/villa1.jpg'],
|
||||
createdAt: new Date().toISOString(),
|
||||
furnished: true,
|
||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح',
|
||||
address: 'شارع المزة - فيلات غربية',
|
||||
city: 'دمشق',
|
||||
district: 'المزة',
|
||||
services: {
|
||||
electricity: true,
|
||||
internet: true,
|
||||
heating: true,
|
||||
water: true,
|
||||
airConditioning: true,
|
||||
parking: true,
|
||||
elevator: false
|
||||
},
|
||||
terms: {
|
||||
noSmoking: true,
|
||||
noPets: false,
|
||||
noParties: true,
|
||||
noAlcohol: false,
|
||||
suitableForChildren: true,
|
||||
suitableForElderly: true
|
||||
}
|
||||
}
|
||||
];
|
||||
setProperties(mockProperties);
|
||||
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties));
|
||||
|
||||
|
||||
const loadProperties = async () => {
|
||||
const authUser = AuthService.getUser();
|
||||
const userId = authUser?.id;
|
||||
|
||||
if (!userId) {
|
||||
console.warn('[OwnerProperties] No user ID found');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[OwnerProperties] Fetching listings for user:', userId);
|
||||
const data = await getMyRentListings();
|
||||
const list = Array.isArray(data) ? data : (data ? [data] : []);
|
||||
console.log('[OwnerProperties] API returned:', list.length, 'properties');
|
||||
|
||||
const mapped = list.map((item) => {
|
||||
const info = item.propertyInformation || {};
|
||||
const details = (() => {
|
||||
try { return JSON.parse(info.detailsJSON || '{}'); } catch { return {}; }
|
||||
})();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: info.address || `عقار #${item.id}`,
|
||||
propertyType: { 0: 'apartment', 1: 'villa', 2: 'house' }[info.buildingType] || 'apartment',
|
||||
purpose: 'rent',
|
||||
rentType: { 0: 'daily', 1: 'weekly', 2: 'monthly' }[item.rentType] || 'daily',
|
||||
dailyPrice: item.dailyRent || 0,
|
||||
monthlyPrice: item.monthlyRent || 0,
|
||||
deposit: item.deposit || 0,
|
||||
location: info.address || '',
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
livingRooms: details.livingRooms || 0,
|
||||
status: { 0: 'available', 1: 'booked', 2: 'maintenance' }[info.status] || 'available',
|
||||
images: (() => {
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const raw = Array.isArray(info.images) ? info.images : [];
|
||||
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`) : ['/property-placeholder.jpg'];
|
||||
})(),
|
||||
createdAt: item.createdAt || new Date().toISOString(),
|
||||
furnished: details.furnished || false,
|
||||
description: info.description || '',
|
||||
address: info.address || '',
|
||||
city: '',
|
||||
district: '',
|
||||
services: details.services || {},
|
||||
terms: details.terms || {},
|
||||
rating: item.rating || 0,
|
||||
currencyId: item.currencyId,
|
||||
_raw: item,
|
||||
};
|
||||
});
|
||||
|
||||
setProperties(mapped);
|
||||
} catch (err) {
|
||||
console.error('[OwnerProperties] Failed to load properties:', err);
|
||||
toast.error('فشل في تحميل العقارات');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const updatePropertiesInStorage = (newProperties) => {
|
||||
|
||||
262
app/owner/reservations/page.js
Normal file
262
app/owner/reservations/page.js
Normal file
@ -0,0 +1,262 @@
|
||||
'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, User, RefreshCw, Mail, Phone,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { getRentProperty } 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-green-100 text-green-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-blue-100 text-blue-800', icon: CheckCircle },
|
||||
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
||||
};
|
||||
|
||||
const sLabel = c => STATUS_UI[STATUS_MAP[c]]?.label ?? String(c);
|
||||
const sColor = c => STATUS_UI[STATUS_MAP[c]]?.color ?? 'bg-gray-100 text-gray-700';
|
||||
const sIcon = c => STATUS_UI[STATUS_MAP[c]]?.icon ?? Clock;
|
||||
|
||||
function StatusBadge({ code }) {
|
||||
const Icon = sIcon(code);
|
||||
return <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${sColor(code)}`}><Icon className="w-3 h-3"/> {sLabel(code)}</span>;
|
||||
}
|
||||
|
||||
async function enrich(r) {
|
||||
if (!r.propertyId) return r;
|
||||
try {
|
||||
const prop = await getRentProperty(r.propertyId);
|
||||
r._prop = prop?.propertyInformation ?? prop ?? null;
|
||||
} catch { /* skip */ }
|
||||
return r;
|
||||
}
|
||||
|
||||
const pAddr = p => p?.address ?? '';
|
||||
const pImgs = p => Array.isArray(p?.images) ? p.images : [];
|
||||
const pBeds = p => p?.numberOfBedRooms ?? 0;
|
||||
const pBaths = p => p?.numberOfBathRooms ?? 0;
|
||||
const API = (token, method, path, body) => fetch(`${API_BASE}${path}`, {
|
||||
method: method || 'GET',
|
||||
headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
function OwnerCard({ r, onViewDetails, onConfirm, onReject }) {
|
||||
const p = r._prop;
|
||||
const imgs = pImgs(p);
|
||||
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
||||
const addr = pAddr(p);
|
||||
const isPending = r.status === 0; // Pending
|
||||
|
||||
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>
|
||||
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{pBeds(p)>0&&<span>{pBeds(p)} غرف</span>}{pBaths(p)>0&&<span>{pBaths(p)} حمامات</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>
|
||||
<div className={`flex gap-3 pt-3 border-t border-gray-100 ${!isPending?'justify-center':''}`}>
|
||||
<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>
|
||||
{isPending && <>
|
||||
<button onClick={()=>onConfirm(r)}
|
||||
className="flex-1 bg-green-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-green-600 transition-colors flex items-center justify-center gap-2">
|
||||
<CheckCircle className="w-4 h-4"/> قبول
|
||||
</button>
|
||||
<button onClick={()=>onReject(r)}
|
||||
className="flex-1 bg-red-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-red-600 transition-colors flex items-center justify-center gap-2">
|
||||
<XCircle className="w-4 h-4"/> رفض
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsModal({ r, isOpen, onClose }) {
|
||||
if (!isOpen || !r) return null;
|
||||
const p = r._prop;
|
||||
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">طلب حجز #{r.id}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
||||
</div>
|
||||
</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> {pAddr(p)||'—'}</p>
|
||||
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mt-2">
|
||||
{pBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBeds(p)} غرف</span>}
|
||||
{pBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBaths(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>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OwnerReservationRequestsPage() {
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.getUser() || !AuthService.isOwner()) { router.push('/auth/choose-role'); return; }
|
||||
loadReservations();
|
||||
}, [router]);
|
||||
|
||||
const loadReservations = useCallback(async () => {
|
||||
try {
|
||||
const token = AuthService.getToken();
|
||||
const res = await fetch(`${API_BASE}/Reservations/GetOwnerResevationRequests`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
const enriched = await Promise.all(list.map(enrich));
|
||||
setReservations(enriched);
|
||||
setFiltered(enriched);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('فشل تحميل طلبات الحجز');
|
||||
}
|
||||
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 => pAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q));
|
||||
}
|
||||
setFiltered(r);
|
||||
}, [reservations, filterStatus, searchTerm]);
|
||||
|
||||
const handleConfirm = async (r) => {
|
||||
try {
|
||||
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/owner-confirm/${r.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
toast.success('تم قبول الحجز بنجاح');
|
||||
await loadReservations();
|
||||
} catch (err) { console.error(err); toast.error('فشل قبول الحجز'); }
|
||||
};
|
||||
|
||||
const handleReject = async (r) => {
|
||||
if (!confirm('هل أنت متأكد من رفض هذا الحجز؟')) return;
|
||||
try {
|
||||
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/reject/${r.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
toast.success('تم رفض الحجز');
|
||||
await loadReservations();
|
||||
} catch (err) { console.error(err); toast.error('فشل رفض الحجز'); }
|
||||
};
|
||||
|
||||
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])) };
|
||||
|
||||
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)} />
|
||||
<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>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">طلبات الحجز</h1>
|
||||
<p className="text-gray-600">لديك {reservations.length} طلب</p>
|
||||
</div>
|
||||
<button onClick={loadReservations} className="p-2 bg-white shadow rounded-xl hover:shadow-md transition-all"><RefreshCw className="w-5 h-5 text-gray-600"/></button>
|
||||
</div>
|
||||
</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 => <OwnerCard key={r.id} r={r} onViewDetails={setSelected} onConfirm={handleConfirm} onReject={handleReject} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
462
app/page.js
462
app/page.js
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
@ -25,14 +25,91 @@ import {
|
||||
Heart,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
import './i18n/config';
|
||||
import HeroSearch from './components/home/HeroSearch';
|
||||
import PropertyMap from './components/home/PropertyMap';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getRentProperties, getSaleProperties } from './utils/api';
|
||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from './enums';
|
||||
import AuthService from './services/AuthService';
|
||||
|
||||
// Map API property data to the format the UI expects
|
||||
// API returns { propertyInformationId, deposit, monthlyRent, dailyRent, rating, propertyInformation: {...}, ... }
|
||||
function mapApiProperty(item, index) {
|
||||
const info = item.propertyInformation || {};
|
||||
|
||||
const dailyPrice = item.dailyRent ?? 0;
|
||||
const monthlyPrice = item.monthlyRent ?? 0;
|
||||
const salePrice = item.price ?? 0;
|
||||
const isRentListing = Boolean(item.dailyRent != null || item.monthlyRent != null);
|
||||
|
||||
const price = isRentListing ? (dailyPrice || monthlyPrice || 0) : salePrice;
|
||||
const priceUnit = isRentListing ? (monthlyPrice ? 'monthly' : 'daily') : 'sale';
|
||||
|
||||
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? (item.type || 'apartment');
|
||||
const status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
|
||||
|
||||
const features = [];
|
||||
if (item.isSmokeAllow) features.push('يسمح بالتدخين');
|
||||
if (item.isVisitorAllow) features.push('يسمح بالزوار');
|
||||
if (item.specializedFor) features.push('متخصص');
|
||||
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} غرف نوم`);
|
||||
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const rawImages = Array.isArray(info.images) ? info.images : [];
|
||||
const images = rawImages.length > 0
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg'];
|
||||
|
||||
const ownerSource = info.ownerType == null && item.ownerType == null
|
||||
? 'all'
|
||||
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
|
||||
? 'agency'
|
||||
: 'owner';
|
||||
|
||||
return {
|
||||
id: item.id ?? index + 1,
|
||||
title: info.address || `عقار #${item.id || index + 1}`,
|
||||
description: info.description || '',
|
||||
type: propType,
|
||||
price: price,
|
||||
priceUSD: price,
|
||||
priceUnit,
|
||||
listingType: isRentListing ? 'rent' : 'sale',
|
||||
location: {
|
||||
city: extractCity(info.address) || 'دمشق',
|
||||
district: info.address || '',
|
||||
address: info.address || '',
|
||||
lat: parseFloat(info.cordsX) || 0,
|
||||
lng: parseFloat(info.cordsY) || 0,
|
||||
},
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
features,
|
||||
images,
|
||||
status,
|
||||
rating: item.rating || 4.5,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: dailyPrice,
|
||||
monthly: monthlyPrice,
|
||||
sale: salePrice,
|
||||
},
|
||||
ownerSource,
|
||||
bookings: [],
|
||||
_raw: item,
|
||||
};
|
||||
}
|
||||
|
||||
// extractCity is now imported from @/app/enums
|
||||
|
||||
// API-only — no fallback data
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const mapSectionRef = useRef(null);
|
||||
const [searchFilters, setSearchFilters] = useState(null);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
@ -41,12 +118,55 @@ export default function HomePage() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const [allProperties, setAllProperties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Re-read user from JWT on every route change
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: AuthService.isOwner() ? 'owner' : 'customer',
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Fetch properties from API on mount
|
||||
useEffect(() => {
|
||||
|
||||
async function fetchProperties() {
|
||||
try {
|
||||
const [rentData, saleData] = await Promise.all([
|
||||
getRentProperties().catch(() => []),
|
||||
getSaleProperties().catch(() => []),
|
||||
]);
|
||||
|
||||
const rentList = Array.isArray(rentData) ? rentData : [];
|
||||
const saleList = Array.isArray(saleData) ? saleData : [];
|
||||
|
||||
const mapped = [
|
||||
...rentList.map((p, i) => mapApiProperty(p, i)),
|
||||
...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
|
||||
];
|
||||
|
||||
if (mapped.length > 0) {
|
||||
setAllProperties(mapped);
|
||||
}
|
||||
// If API returns empty, keep fallback
|
||||
} catch (err) {
|
||||
console.error('[Home] Failed to fetch properties:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -60,184 +180,36 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('user');
|
||||
AuthService.deleteToken();
|
||||
setUser(null);
|
||||
setShowUserMenu(false);
|
||||
};
|
||||
|
||||
const [allProperties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.',
|
||||
type: 'villa',
|
||||
price: 500000,
|
||||
priceUSD: 50,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'دمشق',
|
||||
district: 'المزة',
|
||||
address: 'شارع المزة - فيلات غربية',
|
||||
lat: 33.5138,
|
||||
lng: 36.2765
|
||||
},
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7', 'تدفئة مركزية', 'تكييف مركزي'],
|
||||
images: ['/villa1.jpg', '/villa2.jpg', '/villa3.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.8,
|
||||
isNew: true,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: 500000,
|
||||
monthly: 15000000
|
||||
},
|
||||
bookings: [
|
||||
{ startDate: '2024-03-10', endDate: '2024-03-15' },
|
||||
{ startDate: '2024-03-20', endDate: '2024-03-25' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
description: 'شقة عصرية في حي الشهباء الراقي بحلب.',
|
||||
type: 'apartment',
|
||||
price: 250000,
|
||||
priceUSD: 25,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'حلب',
|
||||
district: 'الشهباء',
|
||||
address: 'شارع النيل - بناء الرحاب',
|
||||
lat: 36.2021,
|
||||
lng: 37.1347
|
||||
},
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
images: ['/apartment1.jpg', '/apartment2.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.5,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian'],
|
||||
priceDisplay: {
|
||||
daily: 250000,
|
||||
monthly: 7500000
|
||||
},
|
||||
bookings: [
|
||||
{ startDate: '2024-03-05', endDate: '2024-03-08' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
description: 'بيت واسع مناسب للعائلات في حمص.',
|
||||
type: 'house',
|
||||
price: 350000,
|
||||
priceUSD: 35,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'حمص',
|
||||
district: 'بابا عمرو',
|
||||
address: 'حي الزهور',
|
||||
lat: 34.7265,
|
||||
lng: 36.7186
|
||||
},
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
rating: 4.3,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: 350000,
|
||||
monthly: 10500000
|
||||
},
|
||||
bookings: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'شقة بجانب البحر',
|
||||
description: 'شقة رائعة مع إطلالة بحرية في اللاذقية.',
|
||||
type: 'apartment',
|
||||
price: 300000,
|
||||
priceUSD: 30,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'اللاذقية',
|
||||
district: 'الشاطئ الأزرق',
|
||||
address: 'الكورنيش الغربي',
|
||||
lat: 35.5306,
|
||||
lng: 35.7801
|
||||
},
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 200,
|
||||
features: ['إطلالة بحرية', 'شرفة', 'تكييف', 'أمن'],
|
||||
images: ['/seaside1.jpg', '/seaside2.jpg', '/seaside3.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.9,
|
||||
isNew: true,
|
||||
allowedIdentities: ['passport'],
|
||||
priceDisplay: {
|
||||
daily: 300000,
|
||||
monthly: 9000000
|
||||
},
|
||||
bookings: []
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'فيلا في درعا',
|
||||
description: 'فيلا فاخرة في حي الأطباء بدرعا.',
|
||||
type: 'villa',
|
||||
price: 400000,
|
||||
priceUSD: 40,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'درعا',
|
||||
district: 'حي الأطباء',
|
||||
address: 'شارع الشفاء',
|
||||
lat: 32.6237,
|
||||
lng: 36.1016
|
||||
},
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 350,
|
||||
features: ['حديقة مثمرة', 'أنظمة أمن', 'مسبح', 'كراج'],
|
||||
images: ['/villa4.jpg', '/villa5.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.6,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: 400000,
|
||||
monthly: 12000000
|
||||
},
|
||||
bookings: []
|
||||
}
|
||||
]);
|
||||
|
||||
const applyFilters = (filters) => {
|
||||
setSearchFilters(filters);
|
||||
|
||||
|
||||
const filtered = allProperties.filter(property => {
|
||||
if (filters.mode === 'rent' && property.listingType !== 'rent') {
|
||||
return false;
|
||||
}
|
||||
if (filters.mode === 'sell' && property.listingType !== 'sale') {
|
||||
return false;
|
||||
}
|
||||
if (filters.mode === 'buy' && property.listingType !== 'sale') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (filters.propertyType && filters.propertyType !== 'all' && property.type !== filters.propertyType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (filters.priceRange && filters.priceRange !== 'all') {
|
||||
const priceUSD = property.priceUSD;
|
||||
switch(filters.priceRange) {
|
||||
switch (filters.priceRange) {
|
||||
case '0-500': if (priceUSD > 50) return false; break;
|
||||
case '500-1000': if (priceUSD < 51 || priceUSD > 100) return false; break;
|
||||
case '1000-2000': if (priceUSD < 101 || priceUSD > 200) return false; break;
|
||||
@ -245,37 +217,51 @@ export default function HomePage() {
|
||||
case '3000+': if (priceUSD < 301) return false; break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filters.ownerSource && filters.ownerSource !== 'all') {
|
||||
if (filters.ownerSource === 'owner' && property.ownerSource !== 'owner') return false;
|
||||
if (filters.ownerSource === 'agency' && property.ownerSource !== 'agency') return false;
|
||||
}
|
||||
|
||||
if (filters.rentPeriod && filters.rentPeriod !== 'all' && property.listingType === 'rent') {
|
||||
if (filters.rentPeriod === 'daily' && !property.priceDisplay.daily) return false;
|
||||
if (filters.rentPeriod === 'monthly' && !property.priceDisplay.monthly) return false;
|
||||
}
|
||||
|
||||
if (filters.availableToday) {
|
||||
if (property.status !== 'available') return false;
|
||||
}
|
||||
|
||||
if (filters.identityType && property.allowedIdentities) {
|
||||
if (!property.allowedIdentities.includes(filters.identityType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
setFilteredProperties(filtered);
|
||||
|
||||
|
||||
if (!showMap) {
|
||||
setShowMap(true);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (mapSectionRef.current) {
|
||||
setIsScrolling(true);
|
||||
mapSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
mapSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
|
||||
|
||||
setTimeout(() => setIsScrolling(false), 1000);
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
if (mapSectionRef.current) {
|
||||
setIsScrolling(true);
|
||||
mapSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
mapSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
setTimeout(() => setIsScrolling(false), 1000);
|
||||
@ -306,7 +292,7 @@ export default function HomePage() {
|
||||
<div className="min-h-screen">
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: 'url(/hero.jpg)',
|
||||
@ -319,7 +305,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@ -331,15 +317,15 @@ export default function HomePage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<motion.h1
|
||||
<motion.h1
|
||||
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight tracking-tight"
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
}}
|
||||
>
|
||||
{t("heroTitleLine1")}<br />
|
||||
<motion.span
|
||||
إيجاد منزلك الجديد<br />
|
||||
<motion.span
|
||||
className="text-amber-400"
|
||||
animate={{
|
||||
y: [0, -10, 0],
|
||||
@ -350,22 +336,22 @@ export default function HomePage() {
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
{t("heroTitleLine2")}
|
||||
أصبح سهلاً
|
||||
</motion.span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
<motion.p
|
||||
className="text-base sm:text-lg text-gray-200 max-w-2xl mx-auto leading-relaxed"
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
}}
|
||||
>
|
||||
{t("heroSubtitle")}
|
||||
نوفر قوائم عقارات عالية الجودة لمساعدتك في إيجاد المنزل المثالي
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} />}
|
||||
|
||||
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
|
||||
|
||||
{isOwner && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -379,20 +365,20 @@ export default function HomePage() {
|
||||
<p className="text-gray-200 mb-4">
|
||||
يمكنك إدارة عقاراتك من خلال لوحة التحكم الخاصة بك
|
||||
</p>
|
||||
{/* <Link
|
||||
<Link
|
||||
href="/owner/properties"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Building className="w-5 h-5" />
|
||||
إدارة عقاراتي
|
||||
</Link> */}
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{!showMap && !isOwner && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 cursor-pointer"
|
||||
animate={{
|
||||
y: [0, 10, 0],
|
||||
@ -417,12 +403,12 @@ export default function HomePage() {
|
||||
{!isOwner && (
|
||||
<AnimatePresence mode="wait">
|
||||
{showMap && (
|
||||
<motion.section
|
||||
<motion.section
|
||||
ref={mapSectionRef}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
transition={{
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
stiffness: 100,
|
||||
@ -431,7 +417,7 @@ export default function HomePage() {
|
||||
className="py-12 bg-gray-50 relative"
|
||||
>
|
||||
{isScrolling && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 h-1 bg-amber-500 z-10"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
@ -473,15 +459,15 @@ export default function HomePage() {
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-200"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3, type: "spring" }}
|
||||
>
|
||||
{filteredProperties.length > 0 ? (
|
||||
<PropertyMap
|
||||
<PropertyMap
|
||||
properties={filteredProperties}
|
||||
userIdentity={searchFilters?.identityType || 'syrian'}
|
||||
/>
|
||||
@ -497,7 +483,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{filteredProperties.length > 0 && searchFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -514,21 +500,40 @@ export default function HomePage() {
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">نوع العقار: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.propertyType === 'all' ? 'الكل' :
|
||||
searchFilters.propertyType === 'apartment' ? 'شقة' :
|
||||
searchFilters.propertyType === 'villa' ? 'فيلا' : 'بيت'}
|
||||
{searchFilters.propertyType === 'all' ? 'الكل' :
|
||||
searchFilters.propertyType === 'apartment' ? 'شقة' :
|
||||
searchFilters.propertyType === 'villa' ? 'فيلا' : 'بيت'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">نطاق السعر: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.priceRange === 'all' ? 'جميع الأسعار' :
|
||||
searchFilters.priceRange === '0-500' ? 'أقل من 50$' :
|
||||
searchFilters.priceRange === '500-1000' ? '50$ - 100$' :
|
||||
searchFilters.priceRange === '1000-2000' ? '100$ - 200$' :
|
||||
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
||||
{searchFilters.priceRange === 'all' ? 'جميع الأسعار' :
|
||||
searchFilters.priceRange === '0-500' ? 'أقل من 50$' :
|
||||
searchFilters.priceRange === '500-1000' ? '50$ - 100$' :
|
||||
searchFilters.priceRange === '1000-2000' ? '100$ - 200$' :
|
||||
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">مصدر العرض: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.ownerSource === 'all' ? 'الكل' :
|
||||
searchFilters.ownerSource === 'owner' ? 'من المالك' : 'من مكتب عقاري'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">نوع الإيجار: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.rentPeriod === 'all' ? 'الكل' :
|
||||
searchFilters.rentPeriod === 'daily' ? 'إيجار يومي' : 'إيجار شهري'}
|
||||
</span>
|
||||
</div>
|
||||
{searchFilters.availableToday && (
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="font-bold text-gray-900">فقط المتاحة من اليوم</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@ -539,26 +544,23 @@ export default function HomePage() {
|
||||
|
||||
<section className="py-20 bg-gradient-to-b from-white to-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="text-center mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="inline-block px-4 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium mb-4">
|
||||
لماذا نحن؟
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4 tracking-tight">
|
||||
{t("whyChooseUsTitle")}
|
||||
لماذا تختار سويت هوم؟
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto text-lg">
|
||||
{t("whyChooseUsSubtitle")}
|
||||
نجعل عملية إيجاد منزلك المثالي سهلة وسريعة
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@ -571,16 +573,16 @@ export default function HomePage() {
|
||||
<ShieldCheck className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t("feature1Title")}
|
||||
قوائم موثوقة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("feature1Description")}
|
||||
كل عقار يتم التحقق منه بدقة لضمان الدقة والجودة.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@ -593,16 +595,16 @@ export default function HomePage() {
|
||||
<Lock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t("feature2Title")}
|
||||
عمليات آمنة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("feature2Description")}
|
||||
سلامتك هي أولويتنا. نوفر معاملات آمنة ونحمي معلوماتك الشخصية.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@ -615,12 +617,12 @@ export default function HomePage() {
|
||||
<Zap className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t("feature3Title")}
|
||||
نتائج سريعة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("feature3Description")}
|
||||
اعثر على منزلك المثالي في دقائق باستخدام خوارزميات البحث والمطابقة المتقدمة لدينا.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -628,4 +630,4 @@ export default function HomePage() {
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
96
app/payments/page.js
Normal file
96
app/payments/page.js
Normal file
@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Download, Eye } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
const mockPayments = [
|
||||
{
|
||||
id: 1,
|
||||
property: 'فيلا فاخرة في المزة',
|
||||
amount: 2500000,
|
||||
date: '2024-03-10',
|
||||
status: 'completed',
|
||||
invoiceId: 'INV-001'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
property: 'شقة حديثة في الشهباء',
|
||||
amount: 750000,
|
||||
date: '2024-03-05',
|
||||
status: 'completed',
|
||||
invoiceId: 'INV-002'
|
||||
}
|
||||
];
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const router = useRouter();
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setPayments(mockPayments);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => amount?.toLocaleString() + ' ل.س';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">المدفوعات</h1>
|
||||
<p className="text-gray-600">سجل المعاملات المالية والفواتير</p>
|
||||
</div>
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<CreditCard className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد معاملات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا مدفوعاتك للحجوزات</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => (
|
||||
<div key={payment.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 hover:shadow-md transition-all">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{payment.property}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">رقم الفاتورة: {payment.invoiceId}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">{payment.date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-amber-600">{formatCurrency(payment.amount)}</div>
|
||||
<span className="inline-block px-2 py-1 bg-green-100 text-green-800 rounded-lg text-xs mt-1">
|
||||
مكتمل
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/profile/error.js
Normal file
27
app/profile/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/profile/loading.js
Normal file
14
app/profile/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -27,6 +27,8 @@ import {
|
||||
Pencil
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../services/AuthService';
|
||||
import { getCustomerByUserId, getOwnerByUserId } from '../utils/api';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
@ -62,37 +64,75 @@ export default function ProfilePage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
const userData = {
|
||||
id: authUser.id,
|
||||
name: authUser.name || '',
|
||||
email: authUser.email || '',
|
||||
phone: authUser.phone || '',
|
||||
role: AuthService.isOwner() ? 'owner' : 'customer',
|
||||
};
|
||||
setUser(userData);
|
||||
|
||||
const savedProfile = localStorage.getItem('userProfile');
|
||||
let profileData;
|
||||
|
||||
if (savedProfile) {
|
||||
profileData = JSON.parse(savedProfile);
|
||||
} else {
|
||||
profileData = {
|
||||
name: userData.name || '',
|
||||
email: userData.email || '',
|
||||
phone: '',
|
||||
whatsapp: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
||||
};
|
||||
console.log('[Profile] User from JWT:', userData);
|
||||
|
||||
// Fetch full profile from API using user ID (SID from JWT)
|
||||
async function fetchProfile() {
|
||||
try {
|
||||
const fetchFn = userData.role === 'owner' ? getOwnerByUserId : getCustomerByUserId;
|
||||
console.log('[Profile] Fetching profile via', userData.role === 'owner' ? 'Owner' : 'Customer', 'GetByUserId:', userData.id);
|
||||
const profile = await fetchFn(userData.id);
|
||||
console.log('[Profile] API profile:', profile);
|
||||
|
||||
if (profile) {
|
||||
const profileData = {
|
||||
name: profile.fullName || profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || userData.name || '',
|
||||
email: profile.email || userData.email || '',
|
||||
phone: profile.phone || profile.phoneNumber || userData.phone || '',
|
||||
whatsapp: profile.whatsAppNumber || profile.whatsapp || '',
|
||||
bio: profile.bio || '',
|
||||
location: profile.address || profile.location || '',
|
||||
joinedDate: profile.createdAt
|
||||
? new Date(profile.createdAt).toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
||||
: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' }),
|
||||
};
|
||||
setFormData(profileData);
|
||||
setTempValues(profileData);
|
||||
localStorage.setItem('userProfile', JSON.stringify(profileData));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Profile] API fetch failed, falling back to JWT/localStorage:', err);
|
||||
}
|
||||
|
||||
// Fallback to JWT + localStorage
|
||||
const savedProfile = localStorage.getItem('userProfile');
|
||||
let profileData;
|
||||
if (savedProfile) {
|
||||
profileData = JSON.parse(savedProfile);
|
||||
} else {
|
||||
profileData = {
|
||||
name: userData.name || '',
|
||||
email: userData.email || '',
|
||||
phone: '',
|
||||
whatsapp: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
||||
};
|
||||
}
|
||||
setFormData(profileData);
|
||||
setTempValues(profileData);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setFormData(profileData);
|
||||
setTempValues(profileData);
|
||||
|
||||
|
||||
const savedAvatar = localStorage.getItem('userAvatar');
|
||||
if (savedAvatar) {
|
||||
setAvatarPreview(savedAvatar);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
fetchProfile();
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
@ -167,7 +207,6 @@ export default function ProfilePage() {
|
||||
|
||||
if (field === 'name') {
|
||||
const updatedUser = { ...user, name: value };
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
setUser(updatedUser);
|
||||
}
|
||||
|
||||
|
||||
27
app/properties/error.js
Normal file
27
app/properties/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/properties/loading.js
Normal file
14
app/properties/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Search,
|
||||
MapPin,
|
||||
@ -32,18 +31,98 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getRentProperties, getSaleProperties } from '../utils/api';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Map API data to UI format
|
||||
function mapApiProperty(item, index) {
|
||||
const info = item.propertyInformation || {};
|
||||
|
||||
const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
|
||||
const monthlyPrice = item.monthlyRent ?? 0;
|
||||
|
||||
const buildingTypeMap = { 0: 'apartment', 1: 'villa', 2: 'house' };
|
||||
const propType = buildingTypeMap[info.buildingType] ?? buildingTypeMap[item.type] ?? 'apartment';
|
||||
|
||||
const statusMap = { 0: 'available', 1: 'booked', 2: 'maintenance' };
|
||||
const status = statusMap[info.status] ?? statusMap[item.status] ?? 'available';
|
||||
|
||||
const features = [];
|
||||
if (item.isSmokeAllow) features.push('يسمح بالتدخين');
|
||||
if (item.isVisitorAllow) features.push('يسمح بالزوار');
|
||||
if (item.specializedFor) features.push('متخصص');
|
||||
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} غرف نوم`);
|
||||
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const rawImages = Array.isArray(info.images) ? info.images : [];
|
||||
const images = rawImages.length > 0
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg'];
|
||||
|
||||
return {
|
||||
id: item.id ?? index + 1,
|
||||
title: info.address || `عقار #${item.id || index + 1}`,
|
||||
description: info.description || '',
|
||||
type: propType,
|
||||
price: dailyPrice,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: extractCity(info.address) || 'دمشق',
|
||||
district: info.address || '',
|
||||
},
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
features,
|
||||
images,
|
||||
status,
|
||||
rating: item.rating || 4.5,
|
||||
isNew: false,
|
||||
_raw: item,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCity(address) {
|
||||
if (!address) return '';
|
||||
const cities = ['دمشق', 'حلب', 'حمص', 'اللاذقية', 'درعا', 'طرطوس', 'السويداء', 'دير الزور', 'الرقة', 'إدلب', 'الحسكة', 'القامشلي', 'ريف دمشق'];
|
||||
for (const city of cities) {
|
||||
if (address.includes(city)) return city;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// API-only — no fallback data
|
||||
|
||||
const PropertyCard = ({ property, viewMode = 'grid', onLoginRequired }) => {
|
||||
const { isFavorite: checkFavorite, addFavorite, removeFavorite } = useFavorites();
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
|
||||
const isFav = checkFavorite(property.id);
|
||||
|
||||
const toggleFavorite = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!AuthService.isAuthenticated()) { onLoginRequired?.(); return; }
|
||||
setFavLoading(true);
|
||||
if (isFav) {
|
||||
await removeFavorite(property.id);
|
||||
} else {
|
||||
await addFavorite(property.id);
|
||||
}
|
||||
setFavLoading(false);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const getPropertyTypeIcon = (type) => {
|
||||
switch(type) {
|
||||
switch (type) {
|
||||
case 'villa': return <Home className="w-4 h-4" />;
|
||||
case 'apartment': return <Building2 className="w-4 h-4" />;
|
||||
case 'house': return <Home className="w-4 h-4" />;
|
||||
@ -53,7 +132,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
};
|
||||
|
||||
const getPropertyTypeLabel = (type) => {
|
||||
switch(type) {
|
||||
switch (type) {
|
||||
case 'villa': return 'فيلا';
|
||||
case 'apartment': return 'شقة';
|
||||
case 'house': return 'بيت';
|
||||
@ -83,26 +162,20 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx)}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
|
||||
}`}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
onClick={toggleFavorite}
|
||||
disabled={favLoading}
|
||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{property.isNew && (
|
||||
<div className="absolute top-2 left-2 bg-gray-800 text-white px-2 py-1 rounded-lg text-xs font-medium">
|
||||
جديد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:w-2/3 p-6">
|
||||
@ -113,11 +186,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
{getPropertyTypeIcon(property.type)}
|
||||
{getPropertyTypeLabel(property.type)}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
property.status === 'available'
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${property.status === 'available' ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-600'}`}>
|
||||
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
||||
</span>
|
||||
</div>
|
||||
@ -148,22 +217,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{property.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{property.features.slice(0, 4).map((feature, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{property.features.length > 4 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
+{property.features.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">{property.description}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
@ -195,32 +249,15 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{property.images.length > 1 && (
|
||||
<div className="absolute bottom-2 left-2 right-2 flex justify-center gap-1">
|
||||
{property.images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx)}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
onClick={toggleFavorite}
|
||||
disabled={favLoading}
|
||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{property.isNew && (
|
||||
<div className="absolute top-2 left-2 bg-gray-800 text-white px-2 py-1 rounded-lg text-xs font-medium">
|
||||
جديد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
@ -232,9 +269,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
{getPropertyTypeLabel(property.type)}
|
||||
</span>
|
||||
{property.status === 'available' && (
|
||||
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">
|
||||
متاح
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">متاح</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
|
||||
@ -270,19 +305,6 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{property.features.slice(0, 3).map((feature, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{property.features.length > 3 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
+{property.features.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/property/${property.id}`}
|
||||
className="block w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors text-center"
|
||||
@ -302,7 +324,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
{ id: 'apartment', label: 'شقة', icon: Building2 },
|
||||
{ id: 'villa', label: 'فيلا', icon: Home },
|
||||
{ id: 'house', label: 'بيت', icon: Home },
|
||||
{ id: 'studio', label: 'استوديو', icon: Building2 }
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
@ -364,11 +385,7 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onFilterChange({ ...filters, propertyType: type.id })}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
||||
filters.propertyType === type.id
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${filters.propertyType === type.id ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
{type.label}
|
||||
@ -439,30 +456,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">المميزات</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['مسبح', 'حديقة', 'موقف سيارات', 'أمن', 'مصعد', 'تكييف'].map((feature) => (
|
||||
<button
|
||||
key={feature}
|
||||
onClick={() => {
|
||||
const newFeatures = filters.features.includes(feature)
|
||||
? filters.features.filter(f => f !== feature)
|
||||
: [...filters.features, feature];
|
||||
onFilterChange({ ...filters, features: newFeatures });
|
||||
}}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
filters.features.includes(feature)
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{feature}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||
@ -496,8 +489,11 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
};
|
||||
|
||||
export default function PropertiesPage() {
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [sortBy, setSortBy] = useState('newest');
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
propertyType: 'all',
|
||||
@ -509,93 +505,34 @@ export default function PropertiesPage() {
|
||||
features: []
|
||||
});
|
||||
|
||||
const [properties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.',
|
||||
type: 'villa',
|
||||
price: 500000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'دمشق', district: 'المزة' },
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن'],
|
||||
images: ['/villa1.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.8,
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
description: 'شقة عصرية في حي الشهباء الراقي بحلب.',
|
||||
type: 'apartment',
|
||||
price: 250000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'حلب', district: 'الشهباء' },
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
images: ['/apartment1.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.5,
|
||||
isNew: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
description: 'بيت واسع مناسب للعائلات في حمص.',
|
||||
type: 'house',
|
||||
price: 350000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'حمص', district: 'بابا عمرو' },
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة'],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
rating: 4.3,
|
||||
isNew: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'شقة بجانب البحر',
|
||||
description: 'شقة رائعة مع إطلالة بحرية في اللاذقية.',
|
||||
type: 'apartment',
|
||||
price: 300000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'اللاذقية', district: 'الشاطئ الأزرق' },
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 200,
|
||||
features: ['إطلالة بحرية', 'شرفة', 'تكييف'],
|
||||
images: ['/seaside1.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.9,
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'فيلا في درعا',
|
||||
description: 'فيلا فاخرة في حي الأطباء بدرعا.',
|
||||
type: 'villa',
|
||||
price: 400000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'درعا', district: 'حي الأطباء' },
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 350,
|
||||
features: ['حديقة مثمرة', 'أنظمة أمن', 'مسبح'],
|
||||
images: ['/villa4.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.6,
|
||||
isNew: false
|
||||
useEffect(() => {
|
||||
async function fetchProperties() {
|
||||
try {
|
||||
const [rentData, saleData] = await Promise.all([
|
||||
getRentProperties().catch(() => []),
|
||||
getSaleProperties().catch(() => []),
|
||||
]);
|
||||
|
||||
const rentList = Array.isArray(rentData) ? rentData : [];
|
||||
const saleList = Array.isArray(saleData) ? saleData : [];
|
||||
|
||||
const mapped = [
|
||||
...rentList.map((p, i) => mapApiProperty(p, i)),
|
||||
...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
|
||||
];
|
||||
|
||||
if (mapped.length > 0) {
|
||||
setProperties(mapped);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Properties] Failed to fetch properties:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
const filteredProperties = properties
|
||||
.filter(property => {
|
||||
@ -613,8 +550,8 @@ export default function PropertiesPage() {
|
||||
if (max) {
|
||||
if (property.price < parseInt(min) || property.price > parseInt(max)) return false;
|
||||
} else if (filters.priceRange.endsWith('+')) {
|
||||
const min = parseInt(filters.priceRange.replace('+', ''));
|
||||
if (property.price < min) return false;
|
||||
const minVal = parseInt(filters.priceRange.replace('+', ''));
|
||||
if (property.price < minVal) return false;
|
||||
}
|
||||
}
|
||||
if (filters.bedrooms !== 'all' && property.bedrooms < parseInt(filters.bedrooms)) {
|
||||
@ -622,17 +559,14 @@ export default function PropertiesPage() {
|
||||
}
|
||||
if (filters.minArea && property.area < parseInt(filters.minArea)) return false;
|
||||
if (filters.maxArea && property.area > parseInt(filters.maxArea)) return false;
|
||||
if (filters.features.length > 0) {
|
||||
if (!filters.features.every(f => property.features.includes(f))) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch(sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'price_asc': return a.price - b.price;
|
||||
case 'price_desc': return b.price - a.price;
|
||||
case 'rating': return b.rating - a.rating;
|
||||
default: return b.isNew ? 1 : -1;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
@ -646,6 +580,11 @@ export default function PropertiesPage() {
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">عقارات للإيجار</h1>
|
||||
<p className="text-gray-500">أفضل العقارات في سوريا</p>
|
||||
{loading && (
|
||||
<div className="mt-4">
|
||||
<div className="inline-block w-6 h-6 border-2 border-gray-200 border-t-gray-800 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<FilterBar filters={filters} onFilterChange={setFilters} />
|
||||
@ -668,19 +607,13 @@ export default function PropertiesPage() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
title="عرض شبكي"
|
||||
className={`p-2 rounded-xl transition-colors ${viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<Grid3x3 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
title="عرض قائمة"
|
||||
className={`p-2 rounded-xl transition-colors ${viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</button>
|
||||
@ -688,12 +621,12 @@ export default function PropertiesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={viewMode === 'grid'
|
||||
<div className={viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredProperties.map((property) => (
|
||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} />
|
||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} onLoginRequired={() => setShowLoginDialog(true)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -711,6 +644,37 @@ export default function PropertiesPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
{showLoginDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowLoginDialog(false)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl text-center"
|
||||
>
|
||||
<div className="w-14 h-14 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-7 h-7 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">تسجيل الدخول مطلوب</h3>
|
||||
<p className="text-gray-500 mb-6">يجب تسجيل الدخول لإضافة العقارات إلى المفضلة</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="flex-1 py-3 border border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1083
app/property/[id]/PropertyDetail.js
Normal file
1083
app/property/[id]/PropertyDetail.js
Normal file
File diff suppressed because it is too large
Load Diff
27
app/property/[id]/error.js
Normal file
27
app/property/[id]/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/property/[id]/loading.js
Normal file
14
app/property/[id]/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,681 +1,96 @@
|
||||
'use client';
|
||||
import PropertyDetail from './PropertyDetail';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
DollarSign,
|
||||
Heart,
|
||||
Share2,
|
||||
Phone,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
Shield,
|
||||
Star,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Check,
|
||||
X,
|
||||
Wifi,
|
||||
Car,
|
||||
Coffee,
|
||||
Wind,
|
||||
Thermometer,
|
||||
Lock,
|
||||
Camera,
|
||||
Home,
|
||||
Building2,
|
||||
Users,
|
||||
Ruler,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Award,
|
||||
FileText,
|
||||
Printer,
|
||||
Download,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
// Server-side API fetch for metadata (runs at request time on server)
|
||||
async function fetchPropertyForMeta(id) {
|
||||
try {
|
||||
const res = await fetch(`http://45.93.137.91/api/RentProperties/GetRentProperties`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
const json = JSON.parse(text);
|
||||
const items = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
||||
return items.find(p => p.id == id) || items[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PropertyDetailsPage() {
|
||||
const params = useParams();
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
const [showContact, setShowContact] = useState(false);
|
||||
const [bookingDates, setBookingDates] = useState({ start: '', end: '' });
|
||||
const [selectedDuration, setSelectedDuration] = useState(1);
|
||||
const [property, setProperty] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
function mapProperty(item) {
|
||||
const info = item.propertyInformation || item.PropertyInformation || {};
|
||||
let details = {};
|
||||
try { details = JSON.parse(info.detailsJSON || info.DetailsJSON || '{}'); } catch {}
|
||||
|
||||
const propertiesData = {
|
||||
1: {
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
description: `تتميز هذه الفيلا الفاخرة بتصميمها العصري وموقعها المميز في أفضل أحياء دمشق. تم بناء الفيلا بأعلى المواصفات باستخدام أفضل المواد، مع مساحات واسعة وحديقة خاصة.
|
||||
const price = item.monthlyRent || item.MonthlyRent || item.dailyRent || item.DailyRent || 0;
|
||||
const priceUnit = item.monthlyRent || item.MonthlyRent ? 'monthly' : 'daily';
|
||||
const buildingType = info.buildingType ?? info.BuildingType ?? 0;
|
||||
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
|
||||
const address = info.address || info.Address || '';
|
||||
const bedrooms = info.numberOfBedRooms || info.NumberOfBedRooms || 0;
|
||||
const bathrooms = info.numberOfBathRooms || info.NumberOfBathRooms || 0;
|
||||
const area = info.space || info.Space || 0;
|
||||
const desc = info.description || info.Description || '';
|
||||
const images = info.images || info.Images || [];
|
||||
const firstImage = Array.isArray(images) && images[0] ? images[0] : '';
|
||||
|
||||
المميزات الرئيسية:
|
||||
• موقع راقي وقريب من جميع الخدمات
|
||||
• تصميم داخلي عصري مع أثاث فاخر
|
||||
• إطلالة رائعة على المدينة
|
||||
• خصوصية تامة وأمن على مدار الساعة
|
||||
return {
|
||||
title: `${typeLabel} في ${address}`,
|
||||
description: desc || `${typeLabel} في ${address} · ${bedrooms} غرف نوم · ${bathrooms} حمامات · ${area} م²`,
|
||||
price,
|
||||
priceUnit,
|
||||
typeLabel,
|
||||
address,
|
||||
bedrooms,
|
||||
bathrooms,
|
||||
area,
|
||||
image: firstImage,
|
||||
};
|
||||
}
|
||||
|
||||
المساحات الداخلية:
|
||||
• الطابق الأرضي: صالة استقبال كبيرة (80 م²)، مجلس رجال (40 م²)، مجلس نساء (35 م²)، مطبخ (25 م²)، غرفة طعام (30 م²)
|
||||
• الطابق الأول: 5 غرف نوم ماستر مع حمامات خاصة (كل غرفة 35-45 م²)
|
||||
• الطابق الثاني: غرفة معيشة عائلية (50 م²)، غرفة ترفيه (40 م²)، سطح مع إطلالة (100 م²)
|
||||
export async function generateMetadata({ params }) {
|
||||
const { id } = await params;
|
||||
const raw = await fetchPropertyForMeta(id);
|
||||
|
||||
الخدمات القريبة:
|
||||
• مدارس وجامعات على بعد 5 دقائق
|
||||
• مستشفيات ومراكز طبية
|
||||
• مولات تجارية ومطاعم
|
||||
• حدائق عامة ومسارات مشي`,
|
||||
type: 'villa',
|
||||
price: 500000,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'دمشق',
|
||||
district: 'المزة',
|
||||
address: 'شارع المزة - فيلات غربية',
|
||||
lat: 33.5,
|
||||
lng: 36.3
|
||||
},
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
features: [
|
||||
{ name: 'مسبح', available: true, description: 'مسبح خاص بمساحة 40 م²' },
|
||||
{ name: 'حديقة خاصة', available: true, description: 'حديقة بمساحة 200 م² مع نوافير' },
|
||||
{ name: 'موقف سيارات', available: true, description: 'موقف يتسع لـ 4 سيارات' },
|
||||
{ name: 'أمن 24/7', available: true, description: 'كاميرات مراقبة وحراسة' },
|
||||
{ name: 'تدفئة مركزية', available: true, description: 'تدفئة مركزية لجميع الغرف' },
|
||||
{ name: 'تكييف مركزي', available: true, description: 'تكييف مركزي في جميع الغرف' },
|
||||
{ name: 'مطبخ مجهز', available: true, description: 'مطبخ أمريكي مجهز بالكامل' },
|
||||
{ name: 'غرفة خادمة', available: true, description: 'غرفة خادمة مع حمام خاص' },
|
||||
{ name: 'مصعد', available: false, description: 'قابل للتركيب' },
|
||||
{ name: 'واي فاي', available: true, description: 'ألياف بصرية' }
|
||||
],
|
||||
images: [
|
||||
'/villa1.jpg',
|
||||
'/villa2.jpg',
|
||||
'/villa3.jpg',
|
||||
'/villa4.jpg',
|
||||
'/villa5.jpg',
|
||||
'/villa6.jpg'
|
||||
],
|
||||
status: 'available',
|
||||
rating: 4.8,
|
||||
reviews: 24,
|
||||
reviewList: [
|
||||
{ user: 'أحمد محمد', rating: 5, comment: 'فيلا رائعة ونظيفة، موقع ممتاز', date: '2024-01-15' },
|
||||
{ user: 'سارة أحمد', rating: 5, comment: 'إقامة مريحة، خدمات ممتازة', date: '2024-01-10' },
|
||||
{ user: 'خالد عمر', rating: 4, comment: 'مكان جميل ولكن السعر مرتفع قليلاً', date: '2023-12-20' }
|
||||
],
|
||||
owner: {
|
||||
name: 'محمد الخالد',
|
||||
phone: '0933111222',
|
||||
email: 'mohamed@example.com',
|
||||
rating: 4.9,
|
||||
properties: 5,
|
||||
memberSince: '2023',
|
||||
responseRate: '98%',
|
||||
responseTime: 'خلال ساعة'
|
||||
},
|
||||
nearby: [
|
||||
{ type: 'مدرسة', distance: '500م' },
|
||||
{ type: 'مستشفى', distance: '1كم' },
|
||||
{ type: 'مول تجاري', distance: '2كم' },
|
||||
{ type: 'مطعم', distance: '300م' },
|
||||
{ type: 'جامعة', distance: '1.5كم' },
|
||||
{ type: 'حديقة', distance: '800م' }
|
||||
],
|
||||
specifications: {
|
||||
constructionYear: 2022,
|
||||
floor: 'أرضي + 2',
|
||||
parking: 4,
|
||||
gardenArea: 200,
|
||||
poolArea: 40,
|
||||
furnished: true,
|
||||
airConditioning: 'مركزي',
|
||||
heating: 'مركزي',
|
||||
electricity: '220V',
|
||||
water: 'شبكة عامة'
|
||||
},
|
||||
rules: [
|
||||
'لا يسمح بالحيوانات الأليفة',
|
||||
'لا يسمح بالتدخين داخل الغرف',
|
||||
'حفلات مسموحة بعد التنسيق',
|
||||
'وقت المغادرة: 12:00 ظهراً'
|
||||
]
|
||||
if (!raw) {
|
||||
return {
|
||||
title: 'SweetHome - عقار',
|
||||
description: 'اكتشف أفضل العقارات للإيجار',
|
||||
};
|
||||
}
|
||||
|
||||
const p = mapProperty(raw);
|
||||
const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === 'daily' ? 'يوم' : 'شهر'}`;
|
||||
const propertyImage = p.image
|
||||
? (p.image.startsWith('http') ? p.image : `http://45.93.137.91${p.image}`)
|
||||
: '';
|
||||
const logoUrl = `http://45.93.137.91/logo.png`;
|
||||
|
||||
// Use property image if available, otherwise logo
|
||||
const ogImages = propertyImage
|
||||
? [{ url: propertyImage, width: 1200, height: 630 }, { url: logoUrl, width: 512, height: 512 }]
|
||||
: [{ url: logoUrl, width: 512, height: 512 }];
|
||||
|
||||
return {
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
openGraph: {
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
images: ogImages,
|
||||
url: `http://45.93.137.91/property/${id}`,
|
||||
type: 'website',
|
||||
siteName: 'SweetHome',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
images: ogImages.map(i => i.url),
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
description: 'شقة عصرية في حي الشهباء الراقي بحلب. إطلالة رائعة وتشطيب فاخر.',
|
||||
type: 'apartment',
|
||||
price: 250000,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'حلب',
|
||||
district: 'الشهباء',
|
||||
address: 'شارع النيل - بناء الرحاب',
|
||||
lat: 36.2,
|
||||
lng: 37.1
|
||||
},
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
features: [
|
||||
{ name: 'مطبخ مجهز', available: true, description: 'مطبخ أمريكي' },
|
||||
{ name: 'بلكونة', available: true, description: 'بلكونة بمساحة 10 م²' },
|
||||
{ name: 'موقف سيارات', available: true, description: 'موقف خاص' },
|
||||
{ name: 'مصعد', available: true, description: 'مصعد حديث' }
|
||||
],
|
||||
images: ['/apartment1.jpg', '/apartment2.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.5,
|
||||
reviews: 12,
|
||||
owner: {
|
||||
name: 'أحمد حلبي',
|
||||
phone: '0944222333',
|
||||
email: 'ahmad@example.com',
|
||||
rating: 4.7,
|
||||
properties: 3,
|
||||
memberSince: '2023'
|
||||
},
|
||||
nearby: [
|
||||
{ type: 'مدرسة', distance: '300م' },
|
||||
{ type: 'مستشفى', distance: '1.2كم' },
|
||||
{ type: 'مول', distance: '500م' }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setProperty(propertiesData[params.id] || propertiesData[1]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, [params.id]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const calculateTotalPrice = () => {
|
||||
if (!property) return 0;
|
||||
const days = bookingDates.start && bookingDates.end
|
||||
? Math.ceil((new Date(bookingDates.end) - new Date(bookingDates.start)) / (1000 * 60 * 60 * 24))
|
||||
: selectedDuration;
|
||||
return property.price * (days > 0 ? days : 1);
|
||||
};
|
||||
|
||||
const handleBooking = () => {
|
||||
alert('تم إرسال طلب الحجز بنجاح. سيتم التواصل معك قريباً.');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-gray-800 rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">جاري تحميل تفاصيل العقار...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Home className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">العقار غير موجود</h2>
|
||||
<p className="text-gray-600 mb-4">لم نتمكن من العثور على العقار المطلوب</p>
|
||||
<Link href="/properties" className="bg-gray-800 text-white px-6 py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors">
|
||||
العودة إلى العقارات
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b sticky top-16 z-40 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/properties" className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>العودة إلى العقارات</span>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<Heart className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<Share2 className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="relative h-[500px] rounded-2xl overflow-hidden group bg-gray-100">
|
||||
<Image
|
||||
src={property.images[currentImage] || '/property-placeholder.jpg'}
|
||||
alt={property.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{property.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setCurrentImage(prev => Math.max(0, prev - 1))}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-white"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentImage(prev => Math.min(property.images.length - 1, prev + 1))}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-white"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2">
|
||||
{property.images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
idx === currentImage ? 'bg-gray-800 w-4' : 'bg-white/70 hover:bg-white'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-3 py-1 rounded-full text-sm backdrop-blur-sm">
|
||||
<Camera className="w-4 h-4 inline ml-1" />
|
||||
{currentImage + 1} / {property.images.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{property.images.slice(1, 5).map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx + 1)}
|
||||
className="relative h-[240px] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity bg-gray-100"
|
||||
>
|
||||
<Image src={img} alt={`${property.title} ${idx + 2}`} fill className="object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{property.title}</h1>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<span>{property.location.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-3xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
|
||||
<div className="text-sm text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-5 h-5 fill-gray-800 text-gray-800" />
|
||||
<span className="font-bold text-gray-900">{property.rating}</span>
|
||||
<span className="text-gray-500">({property.reviews} تقييم)</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-gray-200" />
|
||||
<span className={`font-medium ${
|
||||
property.status === 'available' ? 'text-gray-800' : 'text-gray-500'
|
||||
}`}>
|
||||
{property.status === 'available' ? 'متاح للإيجار' : 'محجوز حالياً'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">المواصفات الرئيسية</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Bed className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">{property.bedrooms}</div>
|
||||
<div className="text-sm text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Bath className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">{property.bathrooms}</div>
|
||||
<div className="text-sm text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Square className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">{property.area}</div>
|
||||
<div className="text-sm text-gray-500">م²</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Home className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">
|
||||
{property.type === 'villa' ? 'فيلا' :
|
||||
property.type === 'apartment' ? 'شقة' : 'بيت'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">نوع العقار</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{property.specifications && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>بناء: {property.specifications.constructionYear}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Ruler className="w-4 h-4" />
|
||||
<span>حديقة: {property.specifications.gardenArea} م²</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Car className="w-4 h-4" />
|
||||
<span>موقف: {property.specifications.parking}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Wind className="w-4 h-4" />
|
||||
<span>{property.specifications.airConditioning}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">وصف العقار</h2>
|
||||
<p className="text-gray-600 whitespace-pre-line leading-relaxed">{property.description}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">المميزات والخدمات</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{property.features.map((feature, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 p-3 bg-gray-50 rounded-xl">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
feature.available ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{feature.available ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<X className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{feature.icon}</span>
|
||||
<span className={`font-medium ${feature.available ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{feature.name}
|
||||
</span>
|
||||
</div>
|
||||
{feature.description && (
|
||||
<p className={`text-sm mt-1 ${feature.available ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
{feature.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">القرب من الخدمات</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{property.nearby.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span className="text-gray-700">{item.type}</span>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{item.distance}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{property.reviewList && property.reviewList.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">تقييمات المستأجرين</h2>
|
||||
<div className="space-y-4">
|
||||
{property.reviewList.map((review, idx) => (
|
||||
<div key={idx} className="border-b border-gray-100 last:border-0 pb-4 last:pb-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className="font-bold text-gray-900">{review.user}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className={`w-4 h-4 ${
|
||||
i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{review.date}</span>
|
||||
</div>
|
||||
<p className="text-gray-600">{review.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{property.rules && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">قوانين المنزل</h2>
|
||||
<ul className="space-y-2">
|
||||
{property.rules.map((rule, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 text-gray-600">
|
||||
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="sticky top-28">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">احجز هذا العقار</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">اختر المدة (أيام)</label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 3, 7, 14, 30].map(days => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => setSelectedDuration(days)}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
selectedDuration === days
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{days}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ البداية</label>
|
||||
<input
|
||||
type="date"
|
||||
value={bookingDates.start}
|
||||
onChange={(e) => setBookingDates({ ...bookingDates, start: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ النهاية</label>
|
||||
<input
|
||||
type="date"
|
||||
value={bookingDates.end}
|
||||
onChange={(e) => setBookingDates({ ...bookingDates, end: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl mb-6">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-600">السعر لـ {selectedDuration} أيام</span>
|
||||
<span className="font-bold text-gray-900">{formatCurrency(property.price * selectedDuration)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-600">سلفة ضمان</span>
|
||||
<span className="font-bold text-gray-900">{formatCurrency(500000)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-gray-200 font-bold">
|
||||
<span className="text-gray-900">الإجمالي</span>
|
||||
<span className="text-gray-900">{formatCurrency(property.price * selectedDuration + 500000)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBooking}
|
||||
className="w-full bg-gray-800 text-white py-4 rounded-xl font-bold text-lg hover:bg-gray-900 transition-colors mb-4"
|
||||
>
|
||||
تأكيد الحجز
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Shield className="w-4 h-4 text-gray-600" />
|
||||
<span>الدفع آمن ومضمون. سلفة الضمان قابلة للاسترداد.</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h3 className="font-bold mb-4 text-gray-900">معلومات المالك</h3>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-gray-700">
|
||||
{property.owner.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">{property.owner.name}</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<Star className="w-3 h-3 fill-gray-600 text-gray-600" />
|
||||
<span>{property.owner.rating}</span>
|
||||
<span>· {property.owner.properties} عقارات</span>
|
||||
</div>
|
||||
{property.owner.responseRate && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>استجابة: {property.owner.responseRate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showContact ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-xl">
|
||||
<Phone className="w-4 h-4 text-gray-600" />
|
||||
<span className="font-medium text-gray-900">{property.owner.phone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-xl">
|
||||
<Mail className="w-4 h-4 text-gray-600" />
|
||||
<span className="font-medium text-gray-900">{property.owner.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowContact(true)}
|
||||
className="w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Phone className="w-5 h-5" />
|
||||
عرض معلومات الاتصال
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="w-full mt-3 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
مراسلة المالك
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function PropertyPage({ params }) {
|
||||
return <PropertyDetail params={params} />;
|
||||
}
|
||||
|
||||
27
app/register/owner/error.js
Normal file
27
app/register/owner/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/register/owner/loading.js
Normal file
14
app/register/owner/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
27
app/register/tenant/error.js
Normal file
27
app/register/tenant/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/register/tenant/loading.js
Normal file
14
app/register/tenant/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,117 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
Loader2
|
||||
User, Mail, Phone, Lock, Eye, EyeOff,
|
||||
CheckCircle, XCircle, ArrowLeft, Home, Loader2,
|
||||
Shield, KeyRound, Camera, X
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addCustomer, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { CustomerType, CustomerTypeLabels } from '../../enums';
|
||||
|
||||
export default function TenantRegisterPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1); // 1=form, 2=id images
|
||||
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
whatsapp: '',
|
||||
phone2: '',
|
||||
nationalNumber: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
customerType: CustomerType.PERSONAL,
|
||||
agreeTerms: false
|
||||
});
|
||||
|
||||
const [idImages, setIdImages] = useState({ front: null, back: null });
|
||||
const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
const fileInputFrontRef = useRef(null);
|
||||
const fileInputBackRef = useRef(null);
|
||||
|
||||
const handleImageUpload = (side, file) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('الرجاء اختيار صورة صالحة');
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setIdImagePreviews(prev => ({ ...prev, [side]: reader.result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setIdImages(prev => ({ ...prev, [side]: file }));
|
||||
console.log('[CustomerRegister] Image uploaded:', side);
|
||||
toast.success('تم رفع الصورة بنجاح', { style: { background: '#dcfce7', color: '#166534' } });
|
||||
};
|
||||
|
||||
const validatePhone = (phone) => {
|
||||
const re = /^(09|05)[0-9]{8}$/;
|
||||
return re.test(phone);
|
||||
};
|
||||
const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
|
||||
|
||||
const validateForm = () => {
|
||||
const validateStep1 = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.firstName) newErrors.firstName = 'الاسم الأول مطلوب';
|
||||
if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
|
||||
|
||||
|
||||
if (!formData.name) {
|
||||
newErrors.name = 'الاسم الكامل مطلوب';
|
||||
} else if (formData.name.length < 3) {
|
||||
newErrors.name = 'الاسم يجب أن يكون 3 أحرف على الأقل';
|
||||
}
|
||||
if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
}
|
||||
if (!formData.phone) newErrors.phone = 'رقم الهاتف مطلوب';
|
||||
else if (!validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
|
||||
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = 'رقم الهاتف مطلوب';
|
||||
} else if (!validatePhone(formData.phone)) {
|
||||
newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
|
||||
}
|
||||
if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
|
||||
else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'كلمة المرور مطلوبة';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
}
|
||||
if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
|
||||
if (!formData.phone2 || formData.phone2.length !== 7) newErrors.phone2 = 'رقم الهاتف يجب أن يكون 7 أرقام';
|
||||
if (!formData.nationalNumber) newErrors.nationalNumber = 'الرقم الوطني مطلوب';
|
||||
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validateStep2 = () => {
|
||||
const newErrors = {};
|
||||
if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
|
||||
if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (validateStep1()) {
|
||||
console.log('[CustomerRegister] Step 1 valid, moving to step 2');
|
||||
setStep(2);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
toast.error('يرجى تصحيح الأخطاء في النموذج');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Main signup handler ───
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
toast.error('يرجى تصحيح الأخطاء في النموذج');
|
||||
if (!validateStep2()) {
|
||||
toast.error('يرجى إكمال جميع الصور المطلوبة');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.agreeTerms) {
|
||||
toast.error('يجب الموافقة على الشروط والأحكام');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
console.log('[CustomerRegister] Submitting customer registration...');
|
||||
|
||||
setTimeout(() => {
|
||||
const payload = {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phoneNumber: formData.phone,
|
||||
whatsAppNumber: formData.whatsapp,
|
||||
phone: formData.phone2,
|
||||
nationalNumber: formData.nationalNumber,
|
||||
password: formData.password,
|
||||
customerType: formData.customerType,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await addCustomer(payload, idImages.front, idImages.back);
|
||||
console.log('[CustomerRegister] addCustomer response:', res);
|
||||
|
||||
if (res.status === 200 || res.ok) {
|
||||
const tempToken = res.data;
|
||||
if (tempToken) {
|
||||
AuthService.addToken(tempToken);
|
||||
console.log('[CustomerRegister] Temp token stored for OTP');
|
||||
}
|
||||
|
||||
const apiMessage = res.message || res.data?.message;
|
||||
toast.success(apiMessage || 'تم إنشاء الحساب! يرجى التحقق من بريدك الإلكتروني', { duration: 4000 });
|
||||
|
||||
// Auto-login to trigger OTP
|
||||
console.log('[CustomerRegister] Auto-login to send OTP...');
|
||||
const loginRes = await loginWithEmail(formData.email, formData.password);
|
||||
console.log('[CustomerRegister] login response:', loginRes);
|
||||
|
||||
if (loginRes.status === 206) {
|
||||
const otpToken = loginRes.data;
|
||||
if (otpToken) AuthService.addToken(otpToken);
|
||||
const loginMsg = loginRes.message || loginRes.data?.message;
|
||||
toast(loginMsg || 'تم إرسال رمز التحقق إلى بريدك الإلكتروني', { icon: '📧' });
|
||||
setShowOtpModal(true);
|
||||
} else if (loginRes.status === 200) {
|
||||
const loginToken = loginRes.data;
|
||||
if (loginToken) AuthService.addToken(loginToken);
|
||||
toast.success(loginRes.message || 'تم تسجيل الدخول بنجاح!');
|
||||
router.push('/');
|
||||
}
|
||||
} else {
|
||||
const errMsg = res.message || res.data?.message || 'فشل في إنشاء الحساب';
|
||||
console.error('[CustomerRegister] Registration failed:', errMsg);
|
||||
toast.error(errMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CustomerRegister] Error:', err);
|
||||
toast.error(err.message || 'حدث خطأ أثناء التسجيل');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
toast.success('تم إنشاء الحساب بنجاح!', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.setItem('user', JSON.stringify({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: 'tenant',
|
||||
avatar: formData.name.charAt(0).toUpperCase()
|
||||
}));
|
||||
// ─── OTP verification handler ───
|
||||
const handleVerifyOTP = async () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
toast.error('يرجى إدخال رمز التحقق');
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
}, 1500);
|
||||
}, 2000);
|
||||
setIsLoading(true);
|
||||
console.log('[CustomerRegister] Verifying OTP:', otpCode);
|
||||
|
||||
try {
|
||||
const res = await verifyEmail(otpCode);
|
||||
console.log('[CustomerRegister] VerifyEmail response:', res);
|
||||
|
||||
if (res.status === 200) {
|
||||
AuthService.deleteToken();
|
||||
console.log('[CustomerRegister] Temp token removed after verification');
|
||||
toast.success(res.message || 'تم التحقق من البريد الإلكتروني بنجاح!', { duration: 3000 });
|
||||
setShowOtpModal(false);
|
||||
setTimeout(() => router.push('/login'), 1500);
|
||||
} else {
|
||||
const errMsg = res.message || res.data?.message || 'رمز التحقق غير صحيح';
|
||||
console.error('[CustomerRegister] Verification failed:', errMsg);
|
||||
toast.error(errMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CustomerRegister] Verify error:', err);
|
||||
toast.error(err.message || 'حدث خطأ أثناء التحقق');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
setIsLoading(true);
|
||||
console.log('[CustomerRegister] Resending email OTP...');
|
||||
try {
|
||||
await sendEmailOTP();
|
||||
toast.success('تم إرسال رمز تحقق جديد');
|
||||
} catch (err) {
|
||||
console.error('[CustomerRegister] Resend OTP error:', err);
|
||||
toast.error('فشل في إرسال الرمز');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
@ -121,318 +236,379 @@ export default function TenantRegisterPage() {
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
animate: { transition: { staggerChildren: 0.1 } }
|
||||
};
|
||||
|
||||
|
||||
const backgroundElements = useMemo(() => {
|
||||
const circles = [
|
||||
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-blue-500/10' },
|
||||
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/10' },
|
||||
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-blue-500/10' },
|
||||
];
|
||||
|
||||
const dots = [
|
||||
{ left: '5%', top: '10%', size: '120px' },
|
||||
{ left: '15%', top: '70%', size: '80px' },
|
||||
{ left: '25%', top: '30%', size: '150px' },
|
||||
{ left: '35%', top: '85%', size: '100px' },
|
||||
{ left: '45%', top: '15%', size: '90px' },
|
||||
{ left: '55%', top: '60%', size: '130px' },
|
||||
{ left: '65%', top: '40%', size: '70px' },
|
||||
{ left: '75%', top: '80%', size: '110px' },
|
||||
{ left: '85%', top: '20%', size: '140px' },
|
||||
{ left: '95%', top: '50%', size: '85px' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{circles.map((circle, i) => (
|
||||
<div
|
||||
key={`circle-${i}`}
|
||||
className={`absolute rounded-full ${circle.className}`}
|
||||
style={circle.style}
|
||||
/>
|
||||
))}
|
||||
{dots.map((dot, i) => (
|
||||
<div
|
||||
key={`dot-${i}`}
|
||||
className="absolute rounded-full bg-blue-500/10"
|
||||
style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* <div className="absolute inset-0 overflow-hidden">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-blue-500/10"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
width: Math.random() * 200 + 50,
|
||||
height: Math.random() * 200 + 50,
|
||||
}}
|
||||
animate={{
|
||||
x: [0, Math.random() * 100 - 50, 0],
|
||||
y: [0, Math.random() * 100 - 50, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 15 + 15,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
/>
|
||||
<motion.div key={i} className="absolute rounded-full bg-blue-500/10"
|
||||
style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
|
||||
animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
|
||||
transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative z-10 w-full max-w-md"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="absolute -top-16 left-0"
|
||||
>
|
||||
<Link
|
||||
href="/auth/choose-role"
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group"
|
||||
>
|
||||
<motion.div whileHover={{ x: -5 }}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
</div> */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{backgroundElements}
|
||||
</div>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
|
||||
className="relative z-10 w-full max-w-md">
|
||||
{/* Back */}
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-8">
|
||||
<Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
|
||||
<motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
|
||||
<span>العودة</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-6 flex gap-2">
|
||||
{[1, 2].map((s) => (
|
||||
<motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? 'bg-blue-500' : 'bg-gray-700'}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-8 text-center relative overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring" }}
|
||||
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="relative z-10"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
||||
>
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2, type: "spring" }}
|
||||
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
|
||||
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="relative z-10">
|
||||
<motion.div animate={{ rotate: [0, 10, -10, 0] }} transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">إنشاء حساب مستأجر</h1>
|
||||
<p className="text-blue-100">انضم إلينا وابحث عن منزل أحلامك</p>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
{step === 1 ? 'إنشاء حساب مستأجر' : 'الوثائق الرسمية'}
|
||||
</h1>
|
||||
<p className="text-blue-100">
|
||||
{step === 1 ? 'انضم إلينا وابحث عن منزل أحلامك' : 'يرجى رفع صور الهوية للتحقق'}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<motion.form
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
الاسم الكامل <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<User className={`w-5 h-5 ${
|
||||
errors.name ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, name: e.target.value});
|
||||
setErrors({...errors, name: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.name ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل اسمك الكامل"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
<motion.form variants={staggerContainer} initial="initial" animate="animate"
|
||||
onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
|
||||
className="space-y-6">
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
البريد الإلكتروني <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Mail className={`w-5 h-5 ${
|
||||
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, email: e.target.value});
|
||||
setErrors({...errors, email: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل بريدك الإلكتروني"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
{/* ─── STEP 1: Form ─── */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<User className={`w-5 h-5 ${errors.firstName ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="text" value={formData.firstName}
|
||||
onChange={(e) => { setFormData({...formData, firstName: e.target.value}); setErrors({...errors, firstName: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.firstName ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="الاسم الأول" />
|
||||
</div>
|
||||
{errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">اسم العائلة <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={formData.lastName}
|
||||
onChange={(e) => { setFormData({...formData, lastName: e.target.value}); setErrors({...errors, lastName: null}); }}
|
||||
className={`w-full px-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.lastName ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="اسم العائلة" />
|
||||
{errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
رقم الهاتف <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${
|
||||
errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, phone: e.target.value});
|
||||
setErrors({...errors, phone: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.phone ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل رقم هاتفك"
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.phone}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">البريد الإلكتروني <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Mail className={`w-5 h-5 ${errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="email" value={formData.email}
|
||||
onChange={(e) => { setFormData({...formData, email: e.target.value}); setErrors({...errors, email: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.email ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل بريدك الإلكتروني" />
|
||||
</div>
|
||||
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 ${
|
||||
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, password: e.target.value});
|
||||
setErrors({...errors, password: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="tel" value={formData.phone}
|
||||
onChange={(e) => { setFormData({...formData, phone: e.target.value}); setErrors({...errors, phone: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم هاتفك" />
|
||||
</div>
|
||||
{errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
تأكيد كلمة المرور <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 ${
|
||||
errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, confirmPassword: e.target.value});
|
||||
setErrors({...errors, confirmPassword: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.confirmPassword ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أعد إدخال كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
)}
|
||||
</button>
|
||||
{formData.confirmPassword && (
|
||||
<div className="absolute inset-y-0 left-12 flex items-center">
|
||||
{formData.password === formData.confirmPassword ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الواتساب <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="tel" value={formData.whatsapp}
|
||||
onChange={(e) => { setFormData({...formData, whatsapp: e.target.value}); setErrors({...errors, whatsapp: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.whatsapp ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم الواتساب" />
|
||||
</div>
|
||||
{errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="tel" value={formData.phone2}
|
||||
onChange={(e) => { setFormData({...formData, phone2: e.target.value}); setErrors({...errors, phone2: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone2 ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم الهاتف" maxLength={7} />
|
||||
</div>
|
||||
{errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">الرقم الوطني <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<User className={`w-5 h-5 ${errors.nationalNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="text" value={formData.nationalNumber}
|
||||
onChange={(e) => { setFormData({...formData, nationalNumber: e.target.value}); setErrors({...errors, nationalNumber: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.nationalNumber ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل الرقم الوطني" />
|
||||
</div>
|
||||
{errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">نوع العميل <span className="text-red-500">*</span></label>
|
||||
<select value={formData.customerType}
|
||||
onChange={(e) => setFormData({...formData, customerType: e.target.value})}
|
||||
className="w-full py-3 px-4 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white appearance-none cursor-pointer">
|
||||
{Object.entries(CustomerTypeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value} className="bg-gray-900 text-white">{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type={showPassword ? "text" : "password"} value={formData.password}
|
||||
onChange={(e) => { setFormData({...formData, password: e.target.value}); setErrors({...errors, password: null}); }}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.password ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل كلمة المرور" />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
{showPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">تأكيد كلمة المرور <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 ${errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword}
|
||||
onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: null}); }}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.confirmPassword ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أعد إدخال كلمة المرور" />
|
||||
<button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
{showConfirmPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||
</button>
|
||||
{formData.confirmPassword && (
|
||||
<div className="absolute inset-y-0 left-12 flex items-center">
|
||||
{formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
{errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<motion.div variants={fadeInUp} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={formData.agreeTerms}
|
||||
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-gray-300">
|
||||
أوافق على{' '}
|
||||
<Link href="/terms" className="text-blue-400 hover:text-blue-300">
|
||||
شروط الاستخدام
|
||||
</Link>
|
||||
{' '}و{' '}
|
||||
<Link href="/privacy" className="text-blue-400 hover:text-blue-300">
|
||||
سياسة الخصوصية
|
||||
</Link>
|
||||
</label>
|
||||
</motion.div>
|
||||
{/* ─── STEP 2: ID Images ─── */}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الأمامي <span className="text-red-500">*</span></label>
|
||||
<div onClick={() => fileInputFrontRef.current?.click()}
|
||||
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.front ? 'border-green-500 bg-green-500/10' : errors.front ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-blue-500 hover:bg-white/5'}`}>
|
||||
<input ref={fileInputFrontRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('front', e.target.files?.[0])} className="hidden" />
|
||||
{idImagePreviews.front ? (
|
||||
<div className="relative">
|
||||
<Image src={idImagePreviews.front} alt="Front ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, front: null})); setIdImagePreviews(prev => ({...prev, front: ''})); }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
|
||||
</div>
|
||||
{errors.front && <p className="text-red-500 text-sm mt-1">{errors.front}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
variants={fadeInUp}
|
||||
type="submit"
|
||||
disabled={isLoading || !formData.agreeTerms}
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 rounded-xl font-bold text-lg hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-500/25"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>جاري إنشاء الحساب...</span>
|
||||
</div>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الخلفي <span className="text-red-500">*</span></label>
|
||||
<div onClick={() => fileInputBackRef.current?.click()}
|
||||
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.back ? 'border-green-500 bg-green-500/10' : errors.back ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-blue-500 hover:bg-white/5'}`}>
|
||||
<input ref={fileInputBackRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('back', e.target.files?.[0])} className="hidden" />
|
||||
{idImagePreviews.back ? (
|
||||
<div className="relative">
|
||||
<Image src={idImagePreviews.back} alt="Back ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, back: null})); setIdImagePreviews(prev => ({...prev, back: ''})); }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
|
||||
</div>
|
||||
{errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp} className="flex items-center gap-2">
|
||||
<input type="checkbox" id="terms" checked={formData.agreeTerms}
|
||||
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500" required />
|
||||
<label htmlFor="terms" className="text-sm text-gray-300">
|
||||
أوافق على <Link href="/terms" className="text-blue-400 hover:text-blue-300">شروط الاستخدام</Link> و <Link href="/privacy" className="text-blue-400 hover:text-blue-300">سياسة الخصوصية</Link>
|
||||
</label>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Buttons ─── */}
|
||||
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<button type="button" onClick={() => router.push('/auth/choose-role')}
|
||||
className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">إلغاء</button>
|
||||
<button type="submit"
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transition-all">التالي</button>
|
||||
</>
|
||||
) : (
|
||||
'إنشاء حساب'
|
||||
<>
|
||||
<button type="button" onClick={() => setStep(1)}
|
||||
className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">السابق</button>
|
||||
<button type="submit" disabled={isLoading || !formData.agreeTerms}
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isLoading ? (<div className="flex items-center justify-center gap-2"><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التسجيل...</span></div>) : 'إنشاء حساب'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
|
||||
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
|
||||
لديك حساب بالفعل؟{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-400 hover:text-blue-300 font-medium transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</motion.p>
|
||||
{step === 1 && (
|
||||
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
|
||||
لديك حساب بالفعل؟{' '}
|
||||
<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">تسجيل الدخول</Link>
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.form>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── OTP Modal ─── */}
|
||||
<AnimatePresence>
|
||||
{showOtpModal && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Shield className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">التحقق من البريد</h2>
|
||||
<p className="text-gray-400 text-sm mt-1">تم إرسال رمز التحقق إلى</p>
|
||||
<p className="text-blue-400 font-medium text-sm">{formData.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رمز التحقق</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<KeyRound className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input type="text" value={otpCode} maxLength={6}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
className="w-full pr-12 pl-4 py-3 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-white text-center tracking-[0.5em] text-xl"
|
||||
placeholder="------" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleVerifyOTP} disabled={isLoading || !otpCode}
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{isLoading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التحقق...</span></> : 'تحقق'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={handleResendOTP} disabled={isLoading}
|
||||
className="w-full text-center text-blue-400 hover:text-blue-300 text-sm mt-3 disabled:opacity-50">
|
||||
إعادة إرسال الرمز
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
226
app/reservations/page.js
Normal file
226
app/reservations/page.js
Normal file
@ -0,0 +1,226 @@
|
||||
'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,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../services/AuthService';
|
||||
import { getRentProperty } 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;
|
||||
|
||||
function ReservationCard({ r, onViewDetails }) {
|
||||
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);
|
||||
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsModal({ r, isOpen, onClose }) {
|
||||
if (!isOpen || !r) return null;
|
||||
const p = r._prop;
|
||||
|
||||
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>
|
||||
</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('');
|
||||
|
||||
useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
|
||||
|
||||
const loadReservations = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/Reservations/GetUserResevations`, {
|
||||
headers: { Authorization: `Bearer ${AuthService.getToken()}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
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])) };
|
||||
|
||||
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)} />
|
||||
<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} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
app/services/AuthService.js
Normal file
162
app/services/AuthService.js
Normal file
@ -0,0 +1,162 @@
|
||||
/**
|
||||
* AuthService
|
||||
* Manages authentication tokens and user role detection via JWT decoding.
|
||||
*
|
||||
* Roles (from JWT claims):
|
||||
* - Owner: roles array contains "Owner"
|
||||
* - Customer: authenticated but no "Owner" role
|
||||
* - Guest: no token
|
||||
*
|
||||
* Methods:
|
||||
* addToken(token) — store JWT token
|
||||
* getToken() — retrieve JWT token
|
||||
* deleteToken() — remove JWT token
|
||||
* decodeToken() — decode JWT payload
|
||||
* getUser() — get decoded user info
|
||||
* getRoles() — get roles array from JWT
|
||||
* isOwner() — check if user has Owner role
|
||||
* isCustomer() — check if user is authenticated but not Owner
|
||||
* isGuest() — check if no token exists
|
||||
* isAuthenticated() — check if token exists
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const USER_KEY = 'cached_user';
|
||||
|
||||
const AuthService = Object.freeze({
|
||||
addToken(token) {
|
||||
if (!token || typeof token !== 'string') return;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
deleteToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache full user profile (from API)
|
||||
* @param {object} user — { name, email, phone, ... }
|
||||
*/
|
||||
cacheUser(user) {
|
||||
if (!user) return;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cached user profile
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getCachedUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Decode JWT payload (base64)
|
||||
* @returns {object|null}
|
||||
*/
|
||||
decodeToken() {
|
||||
const token = this.getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
return JSON.parse(atob(payload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract user info from JWT
|
||||
* @returns {object|null} — { id, name, email, phone, roles }
|
||||
*/
|
||||
getUser() {
|
||||
const payload = this.decodeToken();
|
||||
if (!payload) return null;
|
||||
|
||||
const cached = this.getCachedUser();
|
||||
|
||||
return {
|
||||
id: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] || payload.sub || null,
|
||||
name: cached?.name || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null,
|
||||
email: cached?.email || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || null,
|
||||
phone: cached?.phone || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone'] || null,
|
||||
roles: this.getRoles(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current authenticated user id
|
||||
* @returns {number|string|null}
|
||||
*/
|
||||
getUserId() {
|
||||
const user = this.getUser();
|
||||
if (!user?.id) return null;
|
||||
|
||||
const parsedId = Number(user.id);
|
||||
return Number.isFinite(parsedId) ? parsedId : user.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get roles array from JWT
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getRoles() {
|
||||
const payload = this.decodeToken();
|
||||
if (!payload) return [];
|
||||
const roles = payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
|
||||
if (Array.isArray(roles)) return roles;
|
||||
if (typeof roles === 'string') return [roles];
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* User has Owner role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOwner() {
|
||||
return this.getRoles().includes('Owner');
|
||||
},
|
||||
|
||||
/**
|
||||
* User has Admin role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAdmin() {
|
||||
return this.getRoles().includes('Admin');
|
||||
},
|
||||
|
||||
/**
|
||||
* Authenticated user without Owner or Admin role (i.e. customer)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isCustomer() {
|
||||
return this.isAuthenticated() && !this.isOwner() && !this.isAdmin();
|
||||
},
|
||||
|
||||
/**
|
||||
* No token — guest user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isGuest() {
|
||||
return !this.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* Token exists
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAuthenticated() {
|
||||
return !!this.getToken();
|
||||
},
|
||||
});
|
||||
|
||||
export default AuthService;
|
||||
450
app/utils/api.js
Normal file
450
app/utils/api.js
Normal file
@ -0,0 +1,450 @@
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
/**
|
||||
* Generic API fetch — attaches auth token, unwraps { data } envelope
|
||||
*/
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = AuthService.getToken();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
console.log('[API] Request:', options.method || 'GET', `${API_BASE}${endpoint}`);
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
console.log('[API] Response:', res.status, endpoint);
|
||||
|
||||
if (!res.ok && res.status !== 206) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[API] Error:', res.status, text);
|
||||
throw new Error(`API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json && typeof json === 'object' && 'data' in json) {
|
||||
return json.data;
|
||||
}
|
||||
return json;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth fetch — returns full { status, data, ok } for status-code handling
|
||||
*/
|
||||
async function authFetch(endpoint, body, token = null) {
|
||||
console.log('[Auth] Request:', `${API_BASE}${endpoint}`);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
console.log('[Auth] Sending with Bearer token');
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log('[Auth] Response status:', res.status, endpoint);
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
// Build message from response for toast display
|
||||
const message = (typeof data === 'object' && data?.message) ? data.message : null;
|
||||
|
||||
return { status: res.status, data, ok: res.ok || res.status === 206, message };
|
||||
}
|
||||
|
||||
// ─── Rent Properties ───
|
||||
|
||||
export async function getRentProperties() {
|
||||
return apiFetch('/RentProperties/GetRentProperties');
|
||||
}
|
||||
|
||||
export async function getRentProperty(id) {
|
||||
return apiFetch(`/RentProperties/GetRentPropertyById/${id}`);
|
||||
}
|
||||
|
||||
export async function getRentPropertyLocations(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.maxOffset != null) qs.set('maxOffset', params.maxOffset);
|
||||
if (params.minOffset != null) qs.set('minOffset', params.minOffset);
|
||||
const query = qs.toString();
|
||||
return apiFetch(`/RentProperties/GetRentPropertiesLocations${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
// ─── Sale Properties ───
|
||||
|
||||
export async function getSaleProperties() {
|
||||
return apiFetch('/SaleProperties/GetSaleProperties');
|
||||
}
|
||||
|
||||
export async function getSaleProperty(id) {
|
||||
const items = await apiFetch('/SaleProperties/GetSaleProperties');
|
||||
if (!Array.isArray(items)) return items;
|
||||
return items.find(p => p.id == id) || items[0];
|
||||
}
|
||||
|
||||
// ─── Properties (generic) ───
|
||||
|
||||
export async function getProperty(id) {
|
||||
return apiFetch(`/Properties/Get/${id}`);
|
||||
}
|
||||
|
||||
// ─── Recommendations ───
|
||||
|
||||
export async function getRecommendations() {
|
||||
return apiFetch('/Recommendations/GetRecommendations');
|
||||
}
|
||||
|
||||
export async function getTopRecommendations(count = 10) {
|
||||
return apiFetch(`/Recommendations/GetTopRecommendations?count=${count}`);
|
||||
}
|
||||
|
||||
// ─── Reservations ───
|
||||
|
||||
export async function getAvailableDateRanges(propertyId) {
|
||||
console.log('[API] Fetching available dates for property:', propertyId);
|
||||
return apiFetch(`/Reservations/GetAvailableDates/available/${propertyId}`);
|
||||
}
|
||||
|
||||
export async function getReservations() {
|
||||
return apiFetch('/Reservations/GetAllReservations');
|
||||
}
|
||||
|
||||
export async function getReservation(id) {
|
||||
return apiFetch(`/Reservations/GetReservation?id=${id}`);
|
||||
}
|
||||
|
||||
export async function checkAvailability(propertyId, fromDate = null, toDate = null) {
|
||||
const qs = new URLSearchParams();
|
||||
if (fromDate) qs.set('fromDate', fromDate);
|
||||
if (toDate) qs.set('toDate', toDate);
|
||||
const query = qs.toString();
|
||||
return apiFetch(`/Reservations/GetAvailable/${propertyId}${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function bookReservation(propertyId, startDate, endDate) {
|
||||
console.log('[API] Booking reservation:', { propertyId, startDate, endDate });
|
||||
return apiFetch('/Reservations/BookReservation/book', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ propertyId, startDate, endDate }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Terms ───
|
||||
|
||||
export async function getTerms() {
|
||||
return apiFetch('/Terms/GetTerms');
|
||||
}
|
||||
|
||||
// ─── Profile ───
|
||||
|
||||
export async function getCustomerByUserId(userId) {
|
||||
console.log('[API] Fetching customer by user ID:', userId);
|
||||
return apiFetch(`/Customer/GetByUserId/${userId}`);
|
||||
}
|
||||
|
||||
export async function getOwnerByUserId(userId) {
|
||||
console.log('[API] Fetching owner by user ID:', userId);
|
||||
return apiFetch(`/Owner/GetByUserId/${userId}`);
|
||||
}
|
||||
|
||||
// ─── Properties ───
|
||||
|
||||
export async function getMyRentListings() {
|
||||
console.log('[API] Fetching my rent listings');
|
||||
return apiFetch(`/RentProperties/GetMyRentListings`);
|
||||
}
|
||||
|
||||
export async function addRentProperty(data) {
|
||||
console.log('[API] Adding rent property:', data.PropertyInformation?.Address);
|
||||
return apiFetch('/RentProperties/AddRentProperty', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Currencies ───
|
||||
|
||||
export async function getCurrencies() {
|
||||
return apiFetch('/Currency/GetAll');
|
||||
}
|
||||
|
||||
// ─── Files ───
|
||||
|
||||
export async function uploadPicture(file) {
|
||||
console.log('[API] Uploading picture:', file.name);
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const token = AuthService.getToken();
|
||||
const res = await fetch(`${API_BASE}/Files/UploadPicture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
const text = await res.text();
|
||||
console.log('[API] Upload response:', res.status, text?.substring(0, 100));
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${text}`);
|
||||
// Response is the relative path string (e.g. /Pictures/abc123.jpg)
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json?.data || json;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth: Registration ───
|
||||
|
||||
/**
|
||||
* Register a new owner
|
||||
* @param {Object} data — { name, email, phoneNumber, whatsAppNumber, password, ownerType }
|
||||
* @returns {Promise<{status, data, ok, message}>}
|
||||
*/
|
||||
// Multipart form-data fetch for file uploads
|
||||
async function multipartAuthFetch(endpoint, formData) {
|
||||
console.log('[Auth] Multipart request:', `${API_BASE}${endpoint}`);
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
// Don't set Content-Type — browser sets it with boundary
|
||||
body: formData,
|
||||
});
|
||||
|
||||
console.log('[Auth] Response status:', res.status, endpoint);
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
return { status: res.status, data, ok: res.ok || res.status === 206, message: data?.message };
|
||||
}
|
||||
|
||||
export async function addOwner(data, frontImage = null, backImage = null) {
|
||||
console.log('[Auth] Registering owner (multipart):', data.email);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('FirstName', data.firstName || data.FirstName || '');
|
||||
formData.append('LastName', data.lastName || data.LastName || '');
|
||||
formData.append('Email', data.email || '');
|
||||
formData.append('PhoneNumber', data.phoneNumber || '');
|
||||
formData.append('WhatsAppNumber', data.whatsAppNumber || '');
|
||||
formData.append('Phone', data.phone || '');
|
||||
formData.append('NationalNumber', data.nationalNumber || '');
|
||||
formData.append('Password', data.password || '');
|
||||
formData.append('Type', String(data.ownerType ?? data.Type ?? 0));
|
||||
formData.append('Language', '0');
|
||||
|
||||
if (frontImage) formData.append('FrontIdCarImagePath', frontImage);
|
||||
if (backImage) formData.append('RearIdCarImagePath', backImage);
|
||||
|
||||
return multipartAuthFetch('/Owner/Add', formData);
|
||||
}
|
||||
|
||||
export async function addCustomer(data, frontImage = null, backImage = null) {
|
||||
console.log('[Auth] Registering customer (multipart):', data.email);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('FirstName', data.firstName || data.FirstName || '');
|
||||
formData.append('LastName', data.lastName || data.LastName || '');
|
||||
formData.append('Email', data.email || '');
|
||||
formData.append('PhoneNumber', data.phoneNumber || '');
|
||||
formData.append('WhatsAppNumber', data.whatsAppNumber || '');
|
||||
formData.append('Phone', data.phone || '');
|
||||
formData.append('NationalNumber', data.nationalNumber || '');
|
||||
formData.append('Password', data.password || '');
|
||||
formData.append('Type', String(data.customerType ?? data.Type ?? 0));
|
||||
formData.append('Language', '0');
|
||||
|
||||
if (frontImage) formData.append('FrontIdCarImagePath', frontImage);
|
||||
if (backImage) formData.append('RearIdCarImagePath', backImage);
|
||||
|
||||
return multipartAuthFetch('/Customer/Add', formData);
|
||||
}
|
||||
|
||||
// ─── Auth: Login ───
|
||||
|
||||
export async function loginWithEmail(credential, password) {
|
||||
console.log('[Auth] Login with email:', credential);
|
||||
return authFetch('/Auth/LogInWithEmail', {
|
||||
credential,
|
||||
password,
|
||||
device: 0,
|
||||
appVersion: '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginWithPhone(credential, password) {
|
||||
console.log('[Auth] Login with phone:', credential);
|
||||
return authFetch('/Auth/LogInWithPhoneNumber', {
|
||||
credential,
|
||||
password,
|
||||
device: 0,
|
||||
appVersion: '',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth: OTP ───
|
||||
|
||||
export async function sendEmailOTP() {
|
||||
console.log('[Auth] Sending email OTP...');
|
||||
return apiFetch('/Auth/SendEmailOTP', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function sendPhoneOTP() {
|
||||
console.log('[Auth] Sending phone OTP...');
|
||||
return apiFetch('/Auth/SendPhoneNumberOTP', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function verifyEmail(code) {
|
||||
console.log('[Auth] Verifying email with code:', code);
|
||||
const token = AuthService.getToken();
|
||||
return authFetch(`/Auth/VerifyEmail?code=${encodeURIComponent(code)}`, {}, token);
|
||||
}
|
||||
|
||||
export async function verifyPhone(code) {
|
||||
console.log('[Auth] Verifying phone with code:', code);
|
||||
const token = AuthService.getToken();
|
||||
return authFetch(`/Auth/VerifyPhoneNumber?code=${encodeURIComponent(code)}`, {}, token);
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
export function isEmail(value) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
export function isPhoneNumber(value) {
|
||||
return /^\+?\d{7,15}$/.test(value.replace(/[\s\-()]/g, ''));
|
||||
}
|
||||
|
||||
// ─── Favorites ───
|
||||
|
||||
export async function getUserFavoriteProperties() {
|
||||
return apiFetch('/FavoriteProperty/GetUserFavoriteProperties');
|
||||
}
|
||||
|
||||
export async function addFavoriteProperty(propId) {
|
||||
return apiFetch(`/FavoriteProperty/Add?propId=${propId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function removeFavoriteProperty(favePropId) {
|
||||
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getUserNotifications() {
|
||||
return apiFetch('/Notifications/GetUserNotifications');
|
||||
}
|
||||
|
||||
// ─── Booking/Reservation Management ───
|
||||
|
||||
export async function confirmDepositPayment(bookingId) {
|
||||
return apiFetch('/Reservations/ConfirmDepositPayment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ bookingId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
|
||||
const token = AuthService.getToken();
|
||||
const endpoint = `${API_BASE}/Reservations/AdminConfirmDeposit/admin-confirm-deposit`;
|
||||
const normalizedComment =
|
||||
typeof comment === 'string' && comment.trim()
|
||||
? comment.trim()
|
||||
: null;
|
||||
const payload = {
|
||||
reservationId,
|
||||
adminId,
|
||||
comment: normalizedComment,
|
||||
};
|
||||
|
||||
console.log('[API] AdminConfirmDeposit request', {
|
||||
method: 'PUT',
|
||||
endpoint,
|
||||
payload,
|
||||
adminIdSource: 'jwt-user-id',
|
||||
hasToken: Boolean(token),
|
||||
tokenPreview: token ? `${token.slice(0, 18)}...${token.slice(-8)}` : null,
|
||||
});
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
|
||||
console.log('[API] AdminConfirmDeposit raw response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
endpoint,
|
||||
rawText: text,
|
||||
});
|
||||
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
const message = typeof data === 'object' && data?.message ? data.message : null;
|
||||
|
||||
console.log('[API] AdminConfirmDeposit parsed response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
message,
|
||||
data,
|
||||
});
|
||||
|
||||
return { status: res.status, data, ok: res.ok, message };
|
||||
}
|
||||
|
||||
export async function updateBookingStatus(bookingId, status) {
|
||||
return apiFetch('/Reservations/UpdateStatus', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ bookingId, status }),
|
||||
});
|
||||
}
|
||||
@ -1,41 +1,71 @@
|
||||
export const PROPERTY_STATUS = {
|
||||
AVAILABLE: 'available',
|
||||
BOOKED: 'booked',
|
||||
MAINTENANCE: 'maintenance'
|
||||
};
|
||||
/**
|
||||
* Constants — re-exports from enums for backward compatibility
|
||||
*
|
||||
* New code should import directly from:
|
||||
* import { BuildingType, BookingStatus, City, ... } from '@/app/enums';
|
||||
*
|
||||
* Old imports from '@/app/utils/constants' continue to work.
|
||||
*/
|
||||
|
||||
export const BOOKING_STATUS = {
|
||||
PENDING: 'pending',
|
||||
OWNER_APPROVED: 'owner_approved',
|
||||
ADMIN_APPROVED: 'admin_approved',
|
||||
REJECTED: 'rejected',
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
CANCELLED: 'cancelled'
|
||||
};
|
||||
// Re-export all enums
|
||||
export {
|
||||
BuildingType,
|
||||
BuildingTypeLabels,
|
||||
BuildingTypeKeys,
|
||||
BuildingTypeByKey,
|
||||
} from '../enums/BuildingType';
|
||||
|
||||
export const COMMISSION_TYPE = {
|
||||
FROM_OWNER: 'from_owner',
|
||||
FROM_TENANT: 'from_tenant',
|
||||
FROM_BOTH: 'from_both'
|
||||
};
|
||||
export {
|
||||
PropertyStatus,
|
||||
PropertyStatusLabels,
|
||||
PropertyStatusKeys,
|
||||
PropertyStatusByKey,
|
||||
} from '../enums/PropertyStatus';
|
||||
|
||||
export const IDENTITY_TYPE = {
|
||||
SYRIAN: 'syrian',
|
||||
PASSPORT: 'passport'
|
||||
};
|
||||
export {
|
||||
BookingStatus,
|
||||
BookingStatusLabels,
|
||||
BookingStatusColors,
|
||||
} from '../enums/BookingStatus';
|
||||
|
||||
export const PAYMENT_METHOD = {
|
||||
export {
|
||||
CommissionType,
|
||||
CommissionTypeLabels,
|
||||
} from '../enums/CommissionType';
|
||||
|
||||
export {
|
||||
IdentityType,
|
||||
IdentityTypeLabels,
|
||||
IdentityTypeFlags,
|
||||
} from '../enums/IdentityType';
|
||||
|
||||
export {
|
||||
UserRole,
|
||||
UserRoleLabels,
|
||||
UserRoleColors,
|
||||
} from '../enums/UserRole';
|
||||
|
||||
export {
|
||||
City,
|
||||
CitiesList,
|
||||
extractCity,
|
||||
} from '../enums/City';
|
||||
|
||||
export { LoginMethod } from '../enums/LoginMethod';
|
||||
export { OwnerType, OwnerTypeLabels } from '../enums/OwnerType';
|
||||
export { CustomerType, CustomerTypeLabels } from '../enums/CustomerType';
|
||||
|
||||
// ─── Legacy aliases (keep old imports working) ───
|
||||
export const PROPERTY_STATUS = PropertyStatusKeys;
|
||||
export const BOOKING_STATUS = BookingStatus;
|
||||
export const COMMISSION_TYPE = CommissionType;
|
||||
export const IDENTITY_TYPE = IdentityType;
|
||||
export const CITIES = City;
|
||||
|
||||
// ─── Misc constants ───
|
||||
export const PAYMENT_METHOD = Object.freeze({
|
||||
CASH: 'cash',
|
||||
ELECTRONIC: 'electronic'
|
||||
};
|
||||
ELECTRONIC: 'electronic',
|
||||
});
|
||||
|
||||
export const CITIES = {
|
||||
DAMASCUS: 'damascus',
|
||||
ALEPPO: 'aleppo',
|
||||
HOMS: 'homs',
|
||||
LATTAKIA: 'latakia',
|
||||
DARAA: 'daraa'
|
||||
};
|
||||
|
||||
export const DEFAULT_COMMISSION_RATE = 5;
|
||||
export const DEFAULT_COMMISSION_RATE = 5;
|
||||
|
||||
85
app/utils/firebase.js
Normal file
85
app/utils/firebase.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { initializeApp, getApps } from "firebase/app";
|
||||
import { getMessaging, getToken, onMessage } from "firebase/messaging";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
// Initialize Firebase (avoid duplicate init in SSR)
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
// Get messaging instance (only works in browser)
|
||||
let messaging = null;
|
||||
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
|
||||
try {
|
||||
messaging = getMessaging(app);
|
||||
} catch (e) {
|
||||
console.warn("[Firebase] Messaging init failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Request notification permission and get FCM token
|
||||
export async function requestNotificationPermission() {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
console.log("[FCM] Notification permission denied");
|
||||
return null;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
|
||||
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
|
||||
console.log("[FCM] Token:", token);
|
||||
|
||||
// Send token to backend
|
||||
if (token) {
|
||||
try {
|
||||
const authToken = localStorage.getItem("auth_token");
|
||||
if (authToken) {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
|
||||
await fetch(`${apiBase}/User/SetFCMToken`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token, deviceType: 2 }), // 2 = Web
|
||||
});
|
||||
console.log("[FCM] Token sent to backend");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[FCM] Failed to send token to backend:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (err) {
|
||||
console.error("[FCM] Error getting token:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for foreground messages
|
||||
export function onForegroundMessage(callback) {
|
||||
if (!messaging) return () => {};
|
||||
|
||||
return onMessage(messaging, (payload) => {
|
||||
console.log("[FCM] Foreground message:", payload);
|
||||
callback(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export { app, messaging };
|
||||
193
app/utils/ratings.js
Normal file
193
app/utils/ratings.js
Normal file
@ -0,0 +1,193 @@
|
||||
// Rating API endpoints for SweetHome
|
||||
// Handles both customer ratings and property ratings
|
||||
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
/**
|
||||
* Rate a property as a customer
|
||||
* @param {Object} data - Rating data
|
||||
* @param {number} data.propertyId - ID of the property being rated
|
||||
* @param {number} data.customerId - ID of the customer doing the rating
|
||||
* @param {number} data.rating - Rating value (1-5)
|
||||
* @param {string} data.comment - Optional comment
|
||||
* @returns {Promise} - API response
|
||||
*/
|
||||
export async function rateProperty(data) {
|
||||
console.log('[Rating] Customer rating property:', data);
|
||||
return apiFetch('/Ratings/CustomerRateProperty', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a customer as a property owner
|
||||
* @param {Object} data - Rating data
|
||||
* @param {number} data.propertyId - ID of the property
|
||||
* @param {number} data.customerId - ID of the customer being rated
|
||||
* @param {number} data.rating - Rating value (1-5)
|
||||
* @param {string} data.comment - Optional comment
|
||||
* @returns {Promise} - API response
|
||||
*/
|
||||
export async function rateCustomer(data) {
|
||||
console.log('[Rating] Property owner rating customer:', data);
|
||||
return apiFetch('/Ratings/PropertyRateCustomer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ratings for a property
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @returns {Promise} - Array of ratings
|
||||
*/
|
||||
export async function getPropertyRatings(propertyId) {
|
||||
console.log('[Rating] Fetching property ratings for:', propertyId);
|
||||
return apiFetch(`/Ratings/GetPropertyRatings?propertyId=${propertyId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ratings for a customer
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @returns {Promise} - Array of ratings
|
||||
*/
|
||||
export async function getCustomerRatings(customerId) {
|
||||
console.log('[Rating] Fetching customer ratings for:', customerId);
|
||||
return apiFetch(`/Ratings/GetCustomerRatings?customerId=${customerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average rating for a property
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @returns {Promise} - Average rating
|
||||
*/
|
||||
export async function getPropertyAverageRating(propertyId) {
|
||||
console.log('[Rating] Fetching average rating for property:', propertyId);
|
||||
const ratings = await getPropertyRatings(propertyId);
|
||||
if (!Array.isArray(ratings) || ratings.length === 0) return 0;
|
||||
|
||||
const total = ratings.reduce((sum, rating) => sum + rating.rating, 0);
|
||||
return Math.round((total / ratings.length) * 10) / 10; // Round to 1 decimal
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average rating for a customer
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @returns {Promise} - Average rating
|
||||
*/
|
||||
export async function getCustomerAverageRating(customerId) {
|
||||
console.log('[Rating] Fetching average rating for customer:', customerId);
|
||||
const ratings = await getCustomerRatings(customerId);
|
||||
if (!Array.isArray(ratings) || ratings.length === 0) return 0;
|
||||
|
||||
const total = ratings.reduce((sum, rating) => sum + rating.rating, 0);
|
||||
return Math.round((total / ratings.length) * 10) / 10; // Round to 1 decimal
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's rating for a specific property (if any)
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @param {number} userId - ID of the user
|
||||
* @returns {Promise} - User's rating or null
|
||||
*/
|
||||
export async function getUserPropertyRating(propertyId, userId) {
|
||||
console.log('[Rating] Fetching user rating for property:', propertyId, 'user:', userId);
|
||||
const allRatings = await getPropertyRatings(propertyId);
|
||||
if (!Array.isArray(allRatings)) return null;
|
||||
|
||||
return allRatings.find(r => r.userId === userId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's rating for a specific customer (if any)
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @param {number} userId - ID of the user
|
||||
* @returns {Promise} - User's rating or null
|
||||
*/
|
||||
export async function getUserCustomerRating(customerId, userId) {
|
||||
console.log('[Rating] Fetching user rating for customer:', customerId, 'user:', userId);
|
||||
const allRatings = await getCustomerRatings(customerId);
|
||||
if (!Array.isArray(allRatings)) return null;
|
||||
|
||||
return allRatings.find(r => r.userId === userId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can rate a property (after renting)
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @param {number} userId - ID of the user
|
||||
* @returns {Promise} - Boolean indicating if rating is allowed
|
||||
*/
|
||||
export async function canRateProperty(propertyId, userId) {
|
||||
console.log('[Rating] Checking if user can rate property:', propertyId, 'user:', userId);
|
||||
|
||||
// Logic: User can rate if they have completed a rental in the past
|
||||
// This would typically check reservation history
|
||||
// For now, we'll simulate this with a simple check
|
||||
|
||||
// In a real implementation, this would check:
|
||||
// 1. User's reservation history for this property
|
||||
// 2. Whether the rental period has ended
|
||||
// 3. Whether they've already rated
|
||||
|
||||
const userRating = await getUserPropertyRating(propertyId, userId);
|
||||
return !userRating; // Can rate if no existing rating
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can rate a customer (after renting to them)
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @param {number} userId - ID of the user (owner)
|
||||
* @returns {Promise} - Boolean indicating if rating is allowed
|
||||
*/
|
||||
export async function canRateCustomer(customerId, userId) {
|
||||
console.log('[Rating] Checking if user can rate customer:', customerId, 'user:', userId);
|
||||
|
||||
// Logic: Owner can rate if they have rented to this customer
|
||||
// This would typically check reservation history
|
||||
|
||||
const userRating = await getUserCustomerRating(customerId, userId);
|
||||
return !userRating; // Can rate if no existing rating
|
||||
}
|
||||
|
||||
// Helper function for API calls
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = AuthService.getToken();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
console.log('[Rating API] Request:', options.method || 'GET', `${API_BASE}${endpoint}`);
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
console.log('[Rating API] Response:', res.status, endpoint);
|
||||
|
||||
if (!res.ok && res.status !== 206) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[Rating API] Error:', res.status, text);
|
||||
throw new Error(`Rating API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json && typeof json === 'object' && 'data' in json) {
|
||||
return json.data;
|
||||
}
|
||||
return json;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,20 @@
|
||||
const nextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "45.93.137.91.nip.io",
|
||||
pathname: "/api/Pictures/**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "45.93.137.91",
|
||||
pathname: "/api/Pictures/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
// basePath: "/sweetHome",
|
||||
// assetPrefix: "/sweetHome/",
|
||||
};
|
||||
|
||||
1376
package-lock.json
generated
1376
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,11 +9,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@pbe/react-yandex-maps": "^1.2.5",
|
||||
"firebase": "^12.11.0",
|
||||
"flowbite": "^4.0.1",
|
||||
"flowbite-react": "^0.12.16",
|
||||
"framer-motion": "^12.29.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
@ -22,7 +25,8 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-intersection-observer": "^10.0.3",
|
||||
"react-leaflet": "^4.2.1"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
BIN
public/files/app-release.apk
Normal file
BIN
public/files/app-release.apk
Normal file
Binary file not shown.
38
public/firebase-messaging-sw.js
Normal file
38
public/firebase-messaging-sw.js
Normal file
@ -0,0 +1,38 @@
|
||||
// Firebase Cloud Messaging Service Worker
|
||||
// This file MUST be in the public/ directory (served at /firebase-messaging-sw.js)
|
||||
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js");
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js");
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
console.log("[FCM SW] Background message:", payload);
|
||||
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
|
||||
const options = {
|
||||
body: payload.notification?.body || payload.data?.body || "",
|
||||
icon: payload.notification?.icon || "/logo.png",
|
||||
badge: "/logo.png",
|
||||
data: payload.data,
|
||||
tag: "sweethome-notification",
|
||||
};
|
||||
self.registration.showNotification(title, options);
|
||||
});
|
||||
|
||||
// Handle notification click
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/";
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
});
|
||||
BIN
public/fonts/Madani Arabic Black.ttf
Normal file
BIN
public/fonts/Madani Arabic Black.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Black.woff2
Normal file
BIN
public/fonts/Madani Arabic Black.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Extra Bold.ttf
Normal file
BIN
public/fonts/Madani Arabic Extra Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Extra Bold.woff2
Normal file
BIN
public/fonts/Madani Arabic Extra Bold.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user