Compare commits
122 Commits
082f20da40
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d3ead55ca | |||
| 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 |
@ -5,6 +5,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
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 {
|
import {
|
||||||
Globe,
|
Globe,
|
||||||
LogIn,
|
LogIn,
|
||||||
@ -36,7 +39,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import AuthService from "./services/AuthService";
|
||||||
|
import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||||
import "./i18n/config";
|
import "./i18n/config";
|
||||||
|
import NotificationHandler from "./components/NotificationHandler";
|
||||||
|
|
||||||
export default function ClientLayout({ children }) {
|
export default function ClientLayout({ children }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@ -55,13 +61,6 @@ export default function ClientLayout({ children }) {
|
|||||||
setCurrentLanguage(savedLanguage);
|
setCurrentLanguage(savedLanguage);
|
||||||
i18n.changeLanguage(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") {
|
if (savedLanguage === "ar") {
|
||||||
document.documentElement.dir = "rtl";
|
document.documentElement.dir = "rtl";
|
||||||
document.documentElement.lang = "ar";
|
document.documentElement.lang = "ar";
|
||||||
@ -71,6 +70,23 @@ export default function ClientLayout({ children }) {
|
|||||||
}
|
}
|
||||||
}, [i18n]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@ -104,7 +120,7 @@ export default function ClientLayout({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem("user");
|
AuthService.deleteToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowUserMenu(false);
|
setShowUserMenu(false);
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
@ -119,11 +135,10 @@ export default function ClientLayout({ children }) {
|
|||||||
|
|
||||||
const isProfilePage = pathname === "/profile";
|
const isProfilePage = pathname === "/profile";
|
||||||
|
|
||||||
const isOwner = user?.role === "owner";
|
const isOwner = user?.role === UserRole.OWNER;
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === UserRole.ADMIN;
|
||||||
|
const isCustomer = user?.role === UserRole.CUSTOMER;
|
||||||
console.log("User role:", user?.role);
|
const isAuthenticated = !!user;
|
||||||
console.log("Is Admin:", isAdmin);
|
|
||||||
|
|
||||||
const getUserInitial = () => {
|
const getUserInitial = () => {
|
||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
@ -175,24 +190,45 @@ export default function ClientLayout({ children }) {
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
|
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
|
||||||
>
|
>
|
||||||
<Link
|
{/* Download App Dropdown */}
|
||||||
href="/files/SweetHome.apk"
|
<div className="relative group">
|
||||||
className="group flex items-center gap-2 text-gray-700 hover:text-green-600 transition-colors"
|
<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">
|
||||||
<svg
|
<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"/>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<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"/>
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="currentColor"
|
|
||||||
className="bi bi-android"
|
|
||||||
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>
|
</svg>
|
||||||
<span className="text-green-600 text-sm font-semibold opacity-0 max-w-0 overflow-hidden whitespace-nowrap group-hover:opacity-100 group-hover:max-w-xs transition-all duration-300">
|
<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>
|
||||||
</span>
|
</button>
|
||||||
</Link>
|
<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="/">الرئيسية</NavLink>
|
||||||
<NavLink href="/properties">عقاراتنا</NavLink>
|
<NavLink href="/properties">عقاراتنا</NavLink>
|
||||||
|
|
||||||
@ -213,18 +249,18 @@ export default function ClientLayout({ children }) {
|
|||||||
عقاراتي
|
عقاراتي
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/owner/bookings">
|
<NavLink href="/owner/reservations">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
الحجوزات
|
الحجوزات
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/owner/calendar">
|
{/* <NavLink href="/owner/calendar">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
التقويم
|
التقويم
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink> */}
|
||||||
<NavLink href="/owner/profits">
|
<NavLink href="/owner/profits">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
@ -295,11 +331,7 @@ export default function ClientLayout({ children }) {
|
|||||||
{user?.email || ""}
|
{user?.email || ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-100 mt-1">
|
<p className="text-xs text-amber-100 mt-1">
|
||||||
{isOwner
|
{UserRoleLabels[user?.role] || 'زائر'}
|
||||||
? "مالك عقار"
|
|
||||||
: isAdmin
|
|
||||||
? "مدير النظام"
|
|
||||||
: "مستأجر"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -367,7 +399,7 @@ export default function ClientLayout({ children }) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<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"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
@ -486,12 +518,12 @@ export default function ClientLayout({ children }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOwner && !isAdmin && user && (
|
{isCustomer && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t border-gray-100 my-2"></div>
|
<div className="border-t border-gray-100 my-2"></div>
|
||||||
|
|
||||||
<Link
|
<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"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
@ -580,6 +612,24 @@ export default function ClientLayout({ children }) {
|
|||||||
{t("ourProducts")}
|
{t("ourProducts")}
|
||||||
</MobileNavLink>
|
</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 && (
|
{isAdmin && (
|
||||||
<MobileNavLink href="/admin" onClick={closeMobileMenu}>
|
<MobileNavLink href="/admin" onClick={closeMobileMenu}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@ -601,7 +651,7 @@ export default function ClientLayout({ children }) {
|
|||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink
|
<MobileNavLink
|
||||||
href="/owner/bookings"
|
href="/owner/reservations"
|
||||||
onClick={closeMobileMenu}
|
onClick={closeMobileMenu}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@ -659,7 +709,12 @@ export default function ClientLayout({ children }) {
|
|||||||
<main
|
<main
|
||||||
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||||
>
|
>
|
||||||
|
<NotificationsProvider>
|
||||||
|
<FavoritesProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
|
||||||
|
</FavoritesProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isAuthPage && !isProfilePage && (
|
{!isAuthPage && !isProfilePage && (
|
||||||
@ -730,7 +785,7 @@ export default function ClientLayout({ children }) {
|
|||||||
<ul className="space-y-3 text-gray-400">
|
<ul className="space-y-3 text-gray-400">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<Phone className="w-5 h-5" />
|
<Phone className="w-5 h-5" />
|
||||||
<span>{t("phone")}</span>
|
<span dir="ltr" className="text-right">{t("phone")}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
@ -747,6 +802,7 @@ export default function ClientLayout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</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,15 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
Calendar,
|
Calendar,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Bell
|
Bell,
|
||||||
|
Frown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import DashboardStats from '../components/admin/DashboardStats';
|
import DashboardStats from '../components/admin/DashboardStats';
|
||||||
import PropertiesTable from '../components/admin/PropertiesTable';
|
import PropertiesTable from '../components/admin/PropertiesTable';
|
||||||
@ -18,6 +20,7 @@ import UsersList from '../components/admin/UsersList';
|
|||||||
import LedgerBook from '../components/admin/LedgerBook';
|
import LedgerBook from '../components/admin/LedgerBook';
|
||||||
import AddPropertyForm from '../components/admin/AddPropertyForm';
|
import AddPropertyForm from '../components/admin/AddPropertyForm';
|
||||||
import { PropertyProvider } from '../contexts/PropertyContext';
|
import { PropertyProvider } from '../contexts/PropertyContext';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
import '../i18n/config';
|
import '../i18n/config';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@ -25,6 +28,54 @@ export default function AdminPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('dashboard');
|
const [activeTab, setActiveTab] = useState('dashboard');
|
||||||
const [showAddProperty, setShowAddProperty] = useState(false);
|
const [showAddProperty, setShowAddProperty] = useState(false);
|
||||||
const [notifications, setNotifications] = useState(3);
|
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 = [
|
const tabs = [
|
||||||
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home },
|
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home },
|
||||||
|
|||||||
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 { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
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';
|
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
||||||
|
|
||||||
export default function AddPropertyForm({ onClose, onSuccess }) {
|
export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||||
@ -25,7 +25,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
|
|
||||||
dailyPrice: 0,
|
dailyPrice: 0,
|
||||||
commissionRate: 5,
|
commissionRate: 5,
|
||||||
commissionType: COMMISSION_TYPE.FROM_OWNER,
|
commissionType: CommissionType.FROM_OWNER,
|
||||||
|
|
||||||
securityDeposit: 0,
|
securityDeposit: 0,
|
||||||
|
|
||||||
@ -86,11 +86,11 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
const commission = (dailyPrice * commissionRate) / 100;
|
const commission = (dailyPrice * commissionRate) / 100;
|
||||||
|
|
||||||
switch(commissionType) {
|
switch(commissionType) {
|
||||||
case COMMISSION_TYPE.FROM_TENANT:
|
case CommissionType.FROM_TENANT:
|
||||||
return dailyPrice + commission;
|
return dailyPrice + commission;
|
||||||
case COMMISSION_TYPE.FROM_OWNER:
|
case CommissionType.FROM_OWNER:
|
||||||
return dailyPrice;
|
return dailyPrice;
|
||||||
case COMMISSION_TYPE.FROM_BOTH:
|
case CommissionType.FROM_BOTH:
|
||||||
return dailyPrice + (commission / 2);
|
return dailyPrice + (commission / 2);
|
||||||
default:
|
default:
|
||||||
return dailyPrice;
|
return dailyPrice;
|
||||||
@ -131,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">اختر المدينة</option>
|
<option value="">اختر المدينة</option>
|
||||||
{Object.values(CITIES).map(city => (
|
{CitiesList.map(city => (
|
||||||
<option key={city} value={city}>{city}</option>
|
<option key={city} value={city}>{city}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -232,8 +232,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="commissionType"
|
name="commissionType"
|
||||||
value={COMMISSION_TYPE.FROM_OWNER}
|
value={CommissionType.FROM_OWNER}
|
||||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_OWNER}
|
checked={formData.commissionType === CommissionType.FROM_OWNER}
|
||||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||||
/>
|
/>
|
||||||
<span>من المالك</span>
|
<span>من المالك</span>
|
||||||
@ -242,8 +242,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="commissionType"
|
name="commissionType"
|
||||||
value={COMMISSION_TYPE.FROM_TENANT}
|
value={CommissionType.FROM_TENANT}
|
||||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_TENANT}
|
checked={formData.commissionType === CommissionType.FROM_TENANT}
|
||||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||||
/>
|
/>
|
||||||
<span>من المستأجر</span>
|
<span>من المستأجر</span>
|
||||||
@ -252,8 +252,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="commissionType"
|
name="commissionType"
|
||||||
value={COMMISSION_TYPE.FROM_BOTH}
|
value={CommissionType.FROM_BOTH}
|
||||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_BOTH}
|
checked={formData.commissionType === CommissionType.FROM_BOTH}
|
||||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||||
/>
|
/>
|
||||||
<span>من الاثنين</span>
|
<span>من الاثنين</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState('rent');
|
const [activeTab, setActiveTab] = useState('buy');
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
city: '',
|
city: 'all',
|
||||||
propertyType: '',
|
propertyType: 'all',
|
||||||
priceRange: '',
|
priceRange: 'all',
|
||||||
identityType: 'syrian'
|
identityType: 'syrian',
|
||||||
|
ownerSource: 'all',
|
||||||
|
rentPeriod: 'all',
|
||||||
|
availableToday: false
|
||||||
});
|
});
|
||||||
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
|
|
||||||
const cities = [
|
const cities = [
|
||||||
{ id: 'all', label: 'جميع المدن' },
|
{ id: 'all', label: 'جميع المدن' },
|
||||||
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
|
|
||||||
const propertyTypes = [
|
const propertyTypes = [
|
||||||
{ id: 'all', label: 'الكل' },
|
{ id: 'all', label: 'الكل' },
|
||||||
{ id: 'apartment', label: 'شقة' },
|
{ id: 'apartment', label: 'شقق سكنية' },
|
||||||
{ id: 'villa', label: 'فيلا' },
|
{ id: 'studio', label: 'استوديو' },
|
||||||
{ id: 'house', label: 'بيت' },
|
{ id: 'commercial', label: 'عقار تجاري' },
|
||||||
{ id: 'studio', label: 'استوديو' }
|
{ id: 'villa', label: 'فيلا / مزرعة' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const priceRanges = [
|
const priceRanges = [
|
||||||
@ -46,16 +51,44 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
{ id: 'passport', label: 'جواز سفر' }
|
{ 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 = () => {
|
const handleSearch = () => {
|
||||||
|
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||||
|
setShowLoginDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onSearch({
|
onSearch({
|
||||||
...filters,
|
...filters,
|
||||||
propertyType: filters.propertyType || 'all',
|
mode: activeTab,
|
||||||
city: filters.city || 'all',
|
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 (
|
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"
|
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 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
{['rent', 'buy', 'sell'].map((tab) => (
|
{['rent', 'buy', 'sell'].map((tab) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => handleTabClick(tab)}
|
||||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-amber-500 text-white'
|
? 'bg-amber-500 text-white'
|
||||||
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="mt-6">
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
317
app/components/ratings/PropertyRatingForm.js
Normal file
317
app/components/ratings/PropertyRatingForm.js
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import StarRating from './StarRating';
|
||||||
|
import { addPropertyRating } from '../../utils/ratings';
|
||||||
|
|
||||||
|
const RatingField = ({ label, value, onChange }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">{label} <span className="text-red-500">*</span></label>
|
||||||
|
<StarRating rating={value} onRatingChange={onChange} size={28} />
|
||||||
|
{value === 0 && <p className="text-xs text-red-500">مطلوب</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PropertyRatingForm({ reservationId, onSuccess, onCancel }) {
|
||||||
|
const [cleanRating, setCleanRating] = useState(0);
|
||||||
|
const [servicesRating, setServicesRating] = useState(0);
|
||||||
|
const [ownerBehaviorRating, setOwnerBehaviorRating] = useState(0);
|
||||||
|
const [experienceRating, setExperienceRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (cleanRating === 0) return 'نظافة العقار';
|
||||||
|
if (servicesRating === 0) return 'جودة الخدمات';
|
||||||
|
if (ownerBehaviorRating === 0) return 'سلوك المالك';
|
||||||
|
if (experienceRating === 0) return 'التجربة العامة';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const missing = validate();
|
||||||
|
if (missing) {
|
||||||
|
toast.error(`يرجى تقييم: ${missing}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await addPropertyRating({
|
||||||
|
reservationId,
|
||||||
|
cleanRating,
|
||||||
|
servicesRating,
|
||||||
|
ownerBehaviorRating,
|
||||||
|
experienceRating,
|
||||||
|
comment: comment.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success('تم إرسال التقييم بنجاح!');
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('حدث خطأ، حاول مرة أخرى');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">تقييم العقار</h3>
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<RatingField label="نظافة العقار" value={cleanRating} onChange={setCleanRating} />
|
||||||
|
<RatingField label="جودة الخدمات" value={servicesRating} onChange={setServicesRating} />
|
||||||
|
<RatingField label="سلوك المالك" value={ownerBehaviorRating} onChange={setOwnerBehaviorRating} />
|
||||||
|
<RatingField label="التجربة العامة" value={experienceRating} onChange={setExperienceRating} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">تعليق (اختياري)</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="w-full mt-1 px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||||
|
placeholder="شارك تجربتك..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 rounded-xl transition flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||||
|
{loading ? 'جاري الإرسال...' : 'إرسال التقييم'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
app/components/ratings/PropertyRatingList.js
Normal file
286
app/components/ratings/PropertyRatingList.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
// '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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Star, User, Calendar, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
import { getPropertyRatings, getPropertyAverageRating } from '../../utils/ratings';
|
||||||
|
|
||||||
|
const RatingItem = ({ rating }) => {
|
||||||
|
const overall = (
|
||||||
|
rating.cleanRating + rating.servicesRating +
|
||||||
|
rating.ownerBehaviorRating + rating.experienceRating
|
||||||
|
) / 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-100 py-4 last:border-0">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-800">{rating.customerName || 'مستأجر'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-amber-50 px-2 py-1 rounded-full">
|
||||||
|
<Star className="w-3 h-3 text-amber-500 fill-amber-500" />
|
||||||
|
<span className="text-sm font-bold">{overall.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-2">
|
||||||
|
<div>النظافة: {rating.cleanRating}/5</div>
|
||||||
|
<div>الخدمات: {rating.servicesRating}/5</div>
|
||||||
|
<div>سلوك المالك: {rating.ownerBehaviorRating}/5</div>
|
||||||
|
<div>التجربة: {rating.experienceRating}/5</div>
|
||||||
|
</div>
|
||||||
|
{rating.comment && (
|
||||||
|
<p className="text-gray-700 text-sm mt-2 pr-4 border-r-2 border-amber-200">"{rating.comment}"</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400 mt-2">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{new Date(rating.createdAt).toLocaleDateString('ar-SA')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PropertyRatingList({ propertyId }) {
|
||||||
|
const [ratings, setRatings] = useState([]);
|
||||||
|
const [average, setAverage] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const fetchRatings = async (reset = false) => {
|
||||||
|
const currentPage = reset ? 1 : page;
|
||||||
|
try {
|
||||||
|
if (reset) setLoading(true);
|
||||||
|
else setLoadingMore(true);
|
||||||
|
const result = await getPropertyRatings(propertyId, currentPage, 10);
|
||||||
|
const items = result?.items || result?.data?.items || result || [];
|
||||||
|
const totalPages = result?.totalPages || result?.data?.totalPages || 1;
|
||||||
|
setRatings(prev => reset ? items : [...prev, ...items]);
|
||||||
|
setHasMore(currentPage < totalPages);
|
||||||
|
if (reset) setPage(1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAverage = async () => {
|
||||||
|
try {
|
||||||
|
const avg = await getPropertyAverageRating(propertyId);
|
||||||
|
setAverage(avg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (propertyId) {
|
||||||
|
fetchRatings(true);
|
||||||
|
fetchAverage();
|
||||||
|
}
|
||||||
|
}, [propertyId]);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
fetchRatings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && ratings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-amber-500 mx-auto" />
|
||||||
|
<p className="text-gray-500 mt-2">جاري تحميل التقييمات...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4 pb-3 border-b border-gray-100">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">تقييمات المستأجرين</h3>
|
||||||
|
{average !== null && (
|
||||||
|
<div className="flex items-center gap-1 bg-amber-50 px-3 py-1 rounded-full">
|
||||||
|
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
|
||||||
|
<span className="font-bold text-lg">{average.toFixed(1)}</span>
|
||||||
|
<span className="text-gray-500 text-sm">/5</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ratings.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-6">لا توجد تقييمات حتى الآن. كن أول من يقيم هذا العقار.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ratings.map((r, idx) => <RatingItem key={idx} rating={r} />)}
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="w-full mt-4 py-2 text-amber-600 hover:text-amber-700 font-medium text-sm flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{loadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
{loadingMore ? 'جاري التحميل...' : 'عرض المزيد'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
app/components/ratings/StarRating.js
Normal file
134
app/components/ratings/StarRating.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// 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 'ضعيف';
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
|
||||||
|
const StarRating = ({ rating = 0, onRatingChange, size = 28, readOnly = false }) => {
|
||||||
|
const [hoverRating, setHoverRating] = useState(0);
|
||||||
|
|
||||||
|
const handleClick = (value) => {
|
||||||
|
if (!readOnly && onRatingChange) onRatingChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex gap-1"
|
||||||
|
onMouseLeave={() => !readOnly && setHoverRating(0)}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const filled = (hoverRating || rating) >= star;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClick(star)}
|
||||||
|
onMouseEnter={() => !readOnly && setHoverRating(star)}
|
||||||
|
className="focus:outline-none transition-transform hover:scale-110"
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={size}
|
||||||
|
className={`${filled ? 'fill-amber-500 text-amber-500' : 'text-gray-300 fill-transparent'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StarRating;
|
||||||
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";
|
@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 {
|
:root {
|
||||||
--background: #ede6e6;
|
--background: #ede6e6;
|
||||||
--foreground: #156874;
|
--foreground: #156874;
|
||||||
@ -19,10 +92,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
|
|||||||
@ -25,9 +25,32 @@ export const metadata = {
|
|||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ar" dir="rtl">
|
<html lang="ar" dir="rtl">
|
||||||
<head />
|
<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
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
|
||||||
>
|
>
|
||||||
<ClientLayout>{children}</ClientLayout>
|
<ClientLayout>{children}</ClientLayout>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
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 { useState } from "react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import Image from 'next/image';
|
import { useRouter } from "next/navigation";
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Lock,
|
Lock,
|
||||||
@ -16,106 +15,277 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Home,
|
Home,
|
||||||
Shield
|
Shield,
|
||||||
} from 'lucide-react';
|
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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Step: 'login' | 'otp'
|
||||||
|
const [step, setStep] = useState("login");
|
||||||
|
const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
credential: "",
|
||||||
password: '',
|
password: "",
|
||||||
rememberMe: false
|
rememberMe: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [otpError, setOtpError] = useState("");
|
||||||
const [errors, setErrors] = 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 validateForm = () => {
|
||||||
const newErrors = {};
|
const newErrors = {};
|
||||||
|
|
||||||
if (!formData.email) {
|
if (!formData.credential) {
|
||||||
newErrors.email = 'البريد الإلكتروني مطلوب';
|
newErrors.credential =
|
||||||
} else if (!validateEmail(formData.email)) {
|
loginMethod === "email"
|
||||||
newErrors.email = 'البريد الإلكتروني غير صالح';
|
? "البريد الإلكتروني مطلوب"
|
||||||
|
: "رقم الهاتف مطلوب";
|
||||||
|
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||||
|
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||||
|
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||||
|
newErrors.credential = "رقم الهاتف غير صالح";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
newErrors.password = 'كلمة المرور مطلوبة';
|
newErrors.password = "كلمة المرور مطلوبة";
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleLogin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
if (!validateForm()) {
|
setIsLoading(true);
|
||||||
toast.error('يرجى تصحيح الأخطاء في النموذج', {
|
setErrors({});
|
||||||
style: { background: '#fee2e2', color: '#991b1b' }
|
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (userRole === "admin") {
|
||||||
|
router.push("/admin");
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
} 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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Login] Error:", err);
|
||||||
|
toast.error(err.message || "حدث خطأ في الاتصال", {
|
||||||
|
style: { background: "#fee2e2", color: "#991b1b" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOTP = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!otpCode || otpCode.length < 4) {
|
||||||
|
setOtpError("يرجى إدخال رمز التحقق");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (formData.email.toLowerCase() === ADMIN_EMAIL && formData.password === ADMIN_PASSWORD) {
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
|
toast.success("تم التحقق بنجاح!", {
|
||||||
toast.success('تم تسجيل الدخول كأدمن!', {
|
style: { background: "#dcfce7", color: "#166534" },
|
||||||
style: { background: '#dcfce7', color: '#166534' },
|
|
||||||
duration: 3000
|
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify({
|
|
||||||
name: 'مدير النظام',
|
|
||||||
email: ADMIN_EMAIL,
|
|
||||||
role: 'admin',
|
|
||||||
avatar: 'أ'
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/admin');
|
console.log("[OTP] Redirecting to home");
|
||||||
|
router.push("/");
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
console.error("[OTP] Verification failed:", result.data);
|
||||||
toast.error('بيانات الدخول غير صحيحة. حاول مع admin@gmail.com / 123', {
|
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
|
||||||
style: { background: '#fee2e2', color: '#991b1b' },
|
}
|
||||||
duration: 4000
|
} catch (err) {
|
||||||
});
|
console.error("[OTP] Error:", err);
|
||||||
|
setOtpError(err.message || "حدث خطأ في التحقق");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const particles = Array.from({ length: 30 }, (_, i) => ({
|
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,
|
id: i,
|
||||||
x: Math.random() * 100,
|
x: Math.random() * 100,
|
||||||
y: Math.random() * 100,
|
y: Math.random() * 100,
|
||||||
size: Math.random() * 3 + 1,
|
size: Math.random() * 3 + 1,
|
||||||
duration: Math.random() * 15 + 10,
|
duration: Math.random() * 15 + 10,
|
||||||
delay: Math.random() * 5
|
delay: Math.random() * 5,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
|
||||||
staggerChildren: 0.1,
|
},
|
||||||
delayChildren: 0.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
@ -123,24 +293,25 @@ export default function LoginPage() {
|
|||||||
visible: {
|
visible: {
|
||||||
y: 0,
|
y: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: { type: 'spring', stiffness: 100 }
|
transition: { type: "spring", stiffness: 100 },
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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} />
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
|
||||||
|
{/* Particles */}
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
{particles.map((particle) => (
|
{particles.map((p) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={particle.id}
|
key={p.id}
|
||||||
className="absolute rounded-full bg-amber-500/20"
|
className="absolute rounded-full bg-amber-500/20"
|
||||||
style={{
|
style={{
|
||||||
left: `${particle.x}%`,
|
left: `${p.x}%`,
|
||||||
top: `${particle.y}%`,
|
top: `${p.y}%`,
|
||||||
width: particle.size,
|
width: p.size,
|
||||||
height: particle.size,
|
height: p.size,
|
||||||
}}
|
}}
|
||||||
animate={{
|
animate={{
|
||||||
y: [0, -20, 0],
|
y: [0, -20, 0],
|
||||||
@ -148,31 +319,24 @@ export default function LoginPage() {
|
|||||||
opacity: [0.2, 0.4, 0.2],
|
opacity: [0.2, 0.4, 0.2],
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
duration: particle.duration,
|
duration: p.duration,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
delay: particle.delay,
|
delay: p.delay,
|
||||||
ease: "linear"
|
ease: "linear",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Glow orbs */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute top-20 left-20 w-64 h-64 bg-amber-500/10 rounded-full blur-3xl"
|
className="absolute top-20 left-20 w-64 h-64 bg-amber-500/10 rounded-full blur-3xl"
|
||||||
animate={{
|
animate={{ scale: [1, 1.2, 1], x: [0, 30, 0], y: [0, -20, 0] }}
|
||||||
scale: [1, 1.2, 1],
|
|
||||||
x: [0, 30, 0],
|
|
||||||
y: [0, -20, 0],
|
|
||||||
}}
|
|
||||||
transition={{ duration: 12, repeat: Infinity }}
|
transition={{ duration: 12, repeat: Infinity }}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute bottom-20 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"
|
className="absolute bottom-20 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"
|
||||||
animate={{
|
animate={{ scale: [1, 1.3, 1], x: [0, -30, 0], y: [0, 20, 0] }}
|
||||||
scale: [1, 1.3, 1],
|
|
||||||
x: [0, -30, 0],
|
|
||||||
y: [0, 20, 0],
|
|
||||||
}}
|
|
||||||
transition={{ duration: 15, repeat: Infinity }}
|
transition={{ duration: 15, repeat: Infinity }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -182,17 +346,15 @@ export default function LoginPage() {
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
className="relative w-full max-w-md z-10"
|
className="relative w-full max-w-md z-10"
|
||||||
>
|
>
|
||||||
<motion.div
|
{/* Back link */}
|
||||||
variants={itemVariants}
|
<motion.div variants={itemVariants} className="absolute -top-16 left-0">
|
||||||
className="absolute -top-16 left-0"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ x: -5 }}
|
whileHover={{ x: -5 }}
|
||||||
transition={{ type: 'spring', stiffness: 400 }}
|
transition={{ type: "spring", stiffness: 400 }}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -204,6 +366,7 @@ export default function LoginPage() {
|
|||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"
|
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">
|
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
@ -229,81 +392,136 @@ export default function LoginPage() {
|
|||||||
transition={{ duration: 2, repeat: Infinity }}
|
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"
|
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
|
{step === "otp" ? (
|
||||||
|
<KeyRound className="w-10 h-10 text-white" />
|
||||||
|
) : (
|
||||||
<Home className="w-10 h-10 text-white" />
|
<Home className="w-10 h-10 text-white" />
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
|
<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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === "login" ? (
|
||||||
<motion.form
|
<motion.form
|
||||||
variants={itemVariants}
|
key="login"
|
||||||
onSubmit={handleSubmit}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
onSubmit={handleLogin}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<motion.div variants={itemVariants}>
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Credential input */}
|
||||||
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
البريد الإلكتروني
|
{loginMethod === "email"
|
||||||
|
? "البريد الإلكتروني"
|
||||||
|
: "رقم الهاتف"}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Mail className={`w-5 h-5 transition-colors ${
|
{loginMethod === "email" ? (
|
||||||
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
|
<Mail
|
||||||
}`} />
|
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||||
</div>
|
/>
|
||||||
<input
|
) : (
|
||||||
type="email"
|
<Phone
|
||||||
value={formData.email}
|
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||||
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>
|
</div>
|
||||||
{errors.email && (
|
<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
|
<motion.p
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="text-red-500 text-sm mt-1"
|
className="text-red-500 text-sm mt-1"
|
||||||
>
|
>
|
||||||
{errors.email}
|
{errors.credential}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div variants={itemVariants}>
|
{/* Password */}
|
||||||
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
كلمة المرور
|
كلمة المرور
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Lock className={`w-5 h-5 transition-colors ${
|
<Lock
|
||||||
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
|
className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||||
}`} />
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFormData({...formData, password: e.target.value});
|
setFormData({
|
||||||
if (errors.password) setErrors({...errors, password: null});
|
...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 ${
|
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'
|
errors.password ? "border-red-500" : "border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
placeholder="أدخل كلمة المرور"
|
placeholder="أدخل كلمة المرور"
|
||||||
/>
|
/>
|
||||||
@ -328,19 +546,20 @@ export default function LoginPage() {
|
|||||||
{errors.password}
|
{errors.password}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
|
{/* Remember + Forgot */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<motion.div
|
|
||||||
variants={itemVariants}
|
|
||||||
className="flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.rememberMe}
|
checked={formData.rememberMe}
|
||||||
onChange={(e) => setFormData({...formData, rememberMe: e.target.checked})}
|
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"
|
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 className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||||
@ -353,22 +572,16 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
نسيت كلمة المرور؟
|
نسيت كلمة المرور؟
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
<motion.button
|
<motion.button
|
||||||
variants={itemVariants}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || isSuccess}
|
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"
|
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 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
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">
|
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
@ -389,12 +602,112 @@ export default function LoginPage() {
|
|||||||
</span>
|
</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.form>
|
</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
|
<motion.p
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="text-center text-gray-400 mt-6"
|
className="text-center text-gray-400 mt-6"
|
||||||
>
|
>
|
||||||
ليس لديك حساب؟{' '}
|
ليس لديك حساب؟{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/auth/choose-role"
|
href="/auth/choose-role"
|
||||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||||
@ -409,12 +722,18 @@ export default function LoginPage() {
|
|||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="text-center text-gray-500 text-xs mt-4"
|
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>{" "}
|
||||||
{' '}و{' '}
|
و{" "}
|
||||||
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
سياسة الخصوصية
|
سياسة الخصوصية
|
||||||
</Link>
|
</Link>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ import {
|
|||||||
Building
|
Building
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import AuthService from '../../services/AuthService';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => {
|
const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => {
|
||||||
@ -424,20 +425,20 @@ export default function OwnerBookingsPage() {
|
|||||||
const [showCalendar, setShowCalendar] = useState(false);
|
const [showCalendar, setShowCalendar] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('user');
|
const authUser = AuthService.getUser();
|
||||||
if (storedUser) {
|
if (authUser && AuthService.isOwner()) {
|
||||||
const userData = JSON.parse(storedUser);
|
setUser({
|
||||||
if (userData.role !== 'owner') {
|
name: authUser.name || authUser.email,
|
||||||
router.push('/');
|
email: authUser.email,
|
||||||
} else {
|
role: 'owner',
|
||||||
setUser(userData);
|
});
|
||||||
loadBookings();
|
loadBookings();
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
router.push('/auth/choose-role');
|
router.push('/auth/choose-role');
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|
||||||
const loadBookings = () => {
|
const loadBookings = () => {
|
||||||
const storedBookings = localStorage.getItem('ownerBookings');
|
const storedBookings = localStorage.getItem('ownerBookings');
|
||||||
if (storedBookings) {
|
if (storedBookings) {
|
||||||
@ -510,30 +511,7 @@ export default function OwnerBookingsPage() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let filtered = [...bookings];
|
|
||||||
|
|
||||||
if (filterStatus !== 'all') {
|
|
||||||
filtered = filtered.filter(b => b.status === filterStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
filtered = filtered.filter(b =>
|
|
||||||
b.propertyTitle.includes(searchTerm) ||
|
|
||||||
b.tenantName.includes(searchTerm) ||
|
|
||||||
b.id.includes(searchTerm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateRange.start) {
|
|
||||||
filtered = filtered.filter(b => b.startDate >= dateRange.start);
|
|
||||||
}
|
|
||||||
if (dateRange.end) {
|
|
||||||
filtered = filtered.filter(b => b.endDate <= dateRange.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredBookings(filtered);
|
|
||||||
}, [filterStatus, searchTerm, dateRange, bookings]);
|
|
||||||
|
|
||||||
const handleViewDetails = (booking) => {
|
const handleViewDetails = (booking) => {
|
||||||
setSelectedBooking(booking);
|
setSelectedBooking(booking);
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@ import {
|
|||||||
Calendar as CalendarIcon
|
Calendar as CalendarIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import AuthService from '../../services/AuthService';
|
||||||
|
|
||||||
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
|
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
@ -483,20 +484,21 @@ export default function OwnerCalendarPage() {
|
|||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('user');
|
const authUser = AuthService.getUser();
|
||||||
if (storedUser) {
|
if (authUser && AuthService.isOwner()) {
|
||||||
const userData = JSON.parse(storedUser);
|
setUser({
|
||||||
if (userData.role !== 'owner') {
|
name: authUser.name || authUser.email,
|
||||||
router.push('/');
|
email: authUser.email,
|
||||||
} else {
|
role: 'owner',
|
||||||
setUser(userData);
|
});
|
||||||
loadProperties();
|
loadCalendar();
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
router.push('/auth/choose-role');
|
router.push('/auth/choose-role');
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loadProperties = () => {
|
const loadProperties = () => {
|
||||||
const storedProperties = localStorage.getItem('ownerProperties');
|
const storedProperties = localStorage.getItem('ownerProperties');
|
||||||
if (storedProperties) {
|
if (storedProperties) {
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,326 +1,459 @@
|
|||||||
|
// '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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import { Download, Loader2 } from 'lucide-react';
|
||||||
import {
|
|
||||||
DollarSign,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Calendar,
|
|
||||||
Home,
|
|
||||||
Building,
|
|
||||||
Download,
|
|
||||||
Filter,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ArrowLeft,
|
|
||||||
Loader2,
|
|
||||||
Eye,
|
|
||||||
PieChart,
|
|
||||||
BarChart,
|
|
||||||
LineChart,
|
|
||||||
Wallet,
|
|
||||||
CreditCard,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
const StatCard = ({ title, value, change, icon: Icon, color, trend }) => {
|
import AuthService from '@/app/services/AuthService';
|
||||||
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 justify-between items-start 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 className={`flex items-center gap-1 text-sm ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{trend === 'up' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
|
||||||
<span>{Math.abs(change)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm text-gray-600 mb-1">{title}</h3>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PropertyProfitCard = ({ property, onViewDetails }) => {
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return 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-gray-900 mb-1">{property.title}</h3>
|
|
||||||
<p className="text-sm text-gray-500">{property.location}</p>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
|
||||||
property.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{property.status === 'active' ? 'نشط' : 'غير نشط'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<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-xs text-gray-500">إجمالي الأرباح</div>
|
|
||||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
|
||||||
<Calendar className="w-5 h-5 text-blue-500 mx-auto mb-1" />
|
|
||||||
<div className="text-xs text-gray-500">عدد الحجوزات</div>
|
|
||||||
<div className="text-lg font-bold text-blue-600">{property.totalBookings}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">هذا الشهر</span>
|
|
||||||
<span className="font-medium text-gray-900">{formatCurrency(property.monthlyProfit)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">الأسبوع الماضي</span>
|
|
||||||
<span className="font-medium text-gray-900">{formatCurrency(property.weeklyProfit)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">متوسط السعر اليومي</span>
|
|
||||||
<span className="font-medium text-gray-900">{formatCurrency(property.avgDailyPrice)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onViewDetails(property)}
|
|
||||||
className="w-full 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>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfitDetailsModal = ({ property, isOpen, onClose }) => {
|
|
||||||
if (!isOpen || !property) return null;
|
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
|
||||||
};
|
|
||||||
|
|
||||||
const monthlyData = [
|
|
||||||
{ month: 'يناير', profit: 1250000 },
|
|
||||||
{ month: 'فبراير', profit: 1500000 },
|
|
||||||
{ month: 'مارس', profit: 1800000 },
|
|
||||||
{ month: 'إبريل', profit: 2100000 },
|
|
||||||
{ month: 'مايو', profit: 2500000 },
|
|
||||||
{ month: 'يونيو', profit: 2300000 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const maxProfit = Math.max(...monthlyData.map(d => d.profit));
|
|
||||||
|
|
||||||
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-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">
|
|
||||||
<XCircle 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-amber-50 p-4 rounded-xl text-center">
|
|
||||||
<div className="text-2xl font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
|
|
||||||
<div className="text-xs text-gray-600 mt-1">إجمالي الأرباح</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{property.totalBookings}</div>
|
|
||||||
<div className="text-xs text-gray-600 mt-1">عدد الحجوزات</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">{property.occupancyRate}%</div>
|
|
||||||
<div className="text-xs text-gray-600 mt-1">نسبة الإشغال</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 p-4 rounded-xl text-center">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">{formatCurrency(property.avgDailyPrice)}</div>
|
|
||||||
<div className="text-xs text-gray-600 mt-1">متوسط السعر اليومي</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 p-4 rounded-xl">
|
|
||||||
<h3 className="font-bold text-gray-900 mb-4">الأرباح الشهرية</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{monthlyData.map((data, index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-gray-600">{data.month}</span>
|
|
||||||
<span className="font-medium text-gray-900">{formatCurrency(data.profit)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<motion.div
|
|
||||||
initial={{ width: 0 }}
|
|
||||||
animate={{ width: `${(data.profit / maxProfit) * 100}%` }}
|
|
||||||
transition={{ duration: 0.8, delay: index * 0.1 }}
|
|
||||||
className="bg-amber-500 h-2 rounded-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 p-4 rounded-xl">
|
|
||||||
<h3 className="font-bold text-gray-900 mb-4">آخر الحجوزات</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{property.recentBookings?.map((booking, index) => (
|
|
||||||
<div key={index} className="bg-white p-3 rounded-lg flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{booking.tenantName}</p>
|
|
||||||
<p className="text-xs text-gray-500">{booking.startDate} - {booking.endDate}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-bold text-amber-600">{formatCurrency(booking.amount)}</p>
|
|
||||||
<p className="text-xs text-gray-500">{booking.status === 'completed' ? 'مكتمل' : 'قيد التنفيذ'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OwnerProfitsPage() {
|
export default function OwnerProfitsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [properties, setProperties] = useState([]);
|
|
||||||
const [filteredProperties, setFilteredProperties] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedProperty, setSelectedProperty] = useState(null);
|
const [tableData, setTableData] = useState([]);
|
||||||
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState('month'); // month, year, all
|
|
||||||
|
|
||||||
useEffect(() => {
|
const sampleData = [
|
||||||
const storedUser = localStorage.getItem('user');
|
|
||||||
if (storedUser) {
|
|
||||||
const userData = JSON.parse(storedUser);
|
|
||||||
if (userData.role !== 'owner') {
|
|
||||||
router.push('/');
|
|
||||||
} else {
|
|
||||||
setUser(userData);
|
|
||||||
loadProfitsData();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
router.push('/auth/choose-role');
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const loadProfitsData = () => {
|
|
||||||
const storedProfits = localStorage.getItem('ownerProfits');
|
|
||||||
if (storedProfits) {
|
|
||||||
setProperties(JSON.parse(storedProfits));
|
|
||||||
setFilteredProperties(JSON.parse(storedProfits));
|
|
||||||
} else {
|
|
||||||
const mockProperties = [
|
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'فيلا فاخرة في المزة',
|
property: 'A000000001',
|
||||||
location: 'دمشق، المزة',
|
bookingNumber: 'XX-101',
|
||||||
status: 'active',
|
fromDate: '2025-05-01',
|
||||||
totalProfit: 12500000,
|
toDate: '2025-05-07',
|
||||||
totalBookings: 24,
|
amountReceived: 500,
|
||||||
monthlyProfit: 3200000,
|
platformCommission: 0,
|
||||||
weeklyProfit: 850000,
|
transferredToOwner: 0,
|
||||||
avgDailyPrice: 500000,
|
transferReceipt: '—',
|
||||||
occupancyRate: 78,
|
|
||||||
recentBookings: [
|
|
||||||
{ tenantName: 'أحمد محمد', startDate: '2024-03-10', endDate: '2024-03-15', amount: 2500000, status: 'completed' },
|
|
||||||
{ tenantName: 'سارة أحمد', startDate: '2024-03-05', endDate: '2024-03-08', amount: 1500000, status: 'completed' }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: 'شقة حديثة في الشهباء',
|
property: 'A000000002',
|
||||||
location: 'حلب، الشهباء',
|
bookingNumber: 'XX-202',
|
||||||
status: 'active',
|
fromDate: '2025-05-10',
|
||||||
totalProfit: 5800000,
|
toDate: '2025-05-15',
|
||||||
totalBookings: 18,
|
amountReceived: 300,
|
||||||
monthlyProfit: 1500000,
|
platformCommission: 0,
|
||||||
weeklyProfit: 400000,
|
transferredToOwner: 0,
|
||||||
avgDailyPrice: 250000,
|
transferReceipt: '—',
|
||||||
occupancyRate: 65,
|
|
||||||
recentBookings: [
|
|
||||||
{ tenantName: 'محمد علي', startDate: '2024-03-12', endDate: '2024-03-14', amount: 750000, status: 'completed' }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: 'بيت عائلي في بابا عمرو',
|
property: 'A000000003',
|
||||||
location: 'حمص، بابا عمرو',
|
bookingNumber: 'XX-309',
|
||||||
status: 'active',
|
fromDate: '2025-06-01',
|
||||||
totalProfit: 8400000,
|
toDate: '2025-06-05',
|
||||||
totalBookings: 12,
|
amountReceived: 800,
|
||||||
monthlyProfit: 2100000,
|
platformCommission: 150,
|
||||||
weeklyProfit: 525000,
|
transferredToOwner: 0,
|
||||||
avgDailyPrice: 350000,
|
transferReceipt: 'قيد الانتظار',
|
||||||
occupancyRate: 45,
|
},
|
||||||
recentBookings: []
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
setProperties(mockProperties);
|
|
||||||
setFilteredProperties(mockProperties);
|
|
||||||
localStorage.setItem('ownerProfits', JSON.stringify(mockProperties));
|
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);
|
setIsLoading(false);
|
||||||
};
|
}, [router]);
|
||||||
|
|
||||||
const totalStats = {
|
const totals = tableData.reduce(
|
||||||
totalProfit: properties.reduce((sum, p) => sum + p.totalProfit, 0),
|
(acc, row) => {
|
||||||
totalBookings: properties.reduce((sum, p) => sum + p.totalBookings, 0),
|
acc.totalAmountReceived += row.amountReceived;
|
||||||
avgOccupancy: Math.round(properties.reduce((sum, p) => sum + p.occupancyRate, 0) / properties.length),
|
acc.totalCommission += row.platformCommission;
|
||||||
activeProperties: properties.filter(p => p.status === 'active').length
|
acc.totalPlatformProfit += row.platformProfit;
|
||||||
};
|
acc.totalOwnerDue += row.ownerDue;
|
||||||
|
acc.totalTransferred += row.transferredToOwner;
|
||||||
const formatCurrency = (amount) => {
|
return acc;
|
||||||
if (amount >= 1000000) {
|
},
|
||||||
return (amount / 1000000).toFixed(1) + ' مليون ل.س';
|
{
|
||||||
|
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('حدث خطأ أثناء تصدير التقرير');
|
||||||
}
|
}
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -335,129 +468,122 @@ export default function OwnerProfitsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||||
<Toaster position="top-center" reverseOrder={false} />
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<div className="container mx-auto px-4 max-w-7xl">
|
||||||
<ProfitDetailsModal
|
|
||||||
property={selectedProperty}
|
|
||||||
isOpen={!!selectedProperty}
|
|
||||||
onClose={() => setSelectedProperty(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأرباح والإحصائيات</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
|
||||||
<p className="text-gray-600">مرحباً {user?.name}، إليك ملخص أرباحك</p>
|
<p className="text-gray-600">
|
||||||
|
مرحباً {user?.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<div className="flex gap-3">
|
onClick={handleExportReport}
|
||||||
<select
|
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"
|
||||||
value={selectedPeriod}
|
|
||||||
onChange={(e) => setSelectedPeriod(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="month">آخر 30 يوم</option>
|
|
||||||
<option value="year">آخر 12 شهر</option>
|
|
||||||
<option value="all">جميع الفترات</option>
|
|
||||||
</select>
|
|
||||||
{/* <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" />
|
<Download className="w-5 h-5" />
|
||||||
تصدير التقرير
|
تصدير التقرير
|
||||||
</button> */}
|
</button>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
<StatCard
|
|
||||||
title="إجمالي الأرباح"
|
|
||||||
value={formatCurrency(totalStats.totalProfit)}
|
|
||||||
change={12.5}
|
|
||||||
icon={Wallet}
|
|
||||||
color="bg-amber-500"
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="عدد الحجوزات"
|
|
||||||
value={totalStats.totalBookings}
|
|
||||||
change={8.2}
|
|
||||||
icon={Calendar}
|
|
||||||
color="bg-blue-500"
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="متوسط نسبة الإشغال"
|
|
||||||
value={`${totalStats.avgOccupancy}%`}
|
|
||||||
change={5.3}
|
|
||||||
icon={PieChart}
|
|
||||||
color="bg-green-500"
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="العقارات النشطة"
|
|
||||||
value={totalStats.activeProperties}
|
|
||||||
change={0}
|
|
||||||
icon={Building}
|
|
||||||
color="bg-purple-500"
|
|
||||||
trend="up"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">أرباح العقارات</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setFilteredProperties(properties)}
|
|
||||||
className="px-3 py-1.5 bg-gray-100 rounded-lg text-sm hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
عرض الكل
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredProperties.length === 0 ? (
|
|
||||||
<div 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">
|
|
||||||
<DollarSign 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">لا توجد أرباح مسجلة حتى الآن</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{filteredProperties.map((property) => (
|
|
||||||
<PropertyProfitCard
|
|
||||||
key={property.id}
|
|
||||||
property={property}
|
|
||||||
onViewDetails={setSelectedProperty}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.6 }}
|
className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
|
||||||
className="bg-gradient-to-r from-amber-500 to-amber-600 rounded-2xl p-6 text-white mt-8"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="overflow-x-auto">
|
||||||
<div>
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
<h3 className="text-lg font-bold mb-1">احصل على المزيد من الأرباح</h3>
|
<thead className="bg-gray-800 text-gray-100">
|
||||||
<p className="text-amber-100 text-sm">أضف عقارات جديدة وحسّن أسعارك لزيادة الإشغال</p>
|
<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>
|
||||||
<Link
|
|
||||||
href="/owner/properties/add"
|
<div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
|
||||||
className="px-6 py-2 bg-white text-amber-600 rounded-xl font-medium hover:bg-amber-50 transition-colors"
|
<span className="inline-flex items-center gap-1"></span> ملاحظة:
|
||||||
>
|
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
|
||||||
إضافة عقار جديد
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.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
|
Move
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
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 MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapContainer), { ssr: false });
|
||||||
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { 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 Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
|
||||||
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { 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 }) {
|
function MapClickHandler({ onMapClick }) {
|
||||||
const map = useMapEvents({
|
const map = useMapEvents({
|
||||||
@ -84,29 +99,27 @@ export default function AddPropertyPage() {
|
|||||||
livingRooms: 1,
|
livingRooms: 1,
|
||||||
|
|
||||||
services: {
|
services: {
|
||||||
electricity: false,
|
[PropertyService.ELECTRICITY]: false,
|
||||||
internet: false,
|
[PropertyService.INTERNET]: false,
|
||||||
heating: false,
|
[PropertyService.HEATING]: false,
|
||||||
water: false,
|
[PropertyService.WATER]: false,
|
||||||
airConditioning: false,
|
[PropertyService.CENTRAL_AIR_CONDITIONING]: false,
|
||||||
parking: false,
|
[PropertyService.PARKING]: false,
|
||||||
elevator: false
|
[PropertyService.ELEVATOR]: false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
serviceDetails: {},
|
||||||
|
|
||||||
terms: {
|
terms: {
|
||||||
noSmoking: false,
|
[PropertyTerm.NO_SMOKING]: false,
|
||||||
noPets: false,
|
[PropertyTerm.NO_ANIMALS]: false,
|
||||||
noParties: false,
|
[PropertyTerm.NO_PARTIES]: false
|
||||||
noAlcohol: false,
|
|
||||||
suitableForChildren: true,
|
|
||||||
suitableForElderly: true
|
|
||||||
},
|
},
|
||||||
|
|
||||||
offerType: 'daily',
|
offerType: 'daily',
|
||||||
|
|
||||||
dailyPrice: '',
|
dailyPrice: '',
|
||||||
monthlyPrice: '',
|
monthlyPrice: '',
|
||||||
salePrice: '',
|
|
||||||
|
|
||||||
city: '',
|
city: '',
|
||||||
district: '',
|
district: '',
|
||||||
@ -120,11 +133,15 @@ export default function AddPropertyPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [imagePreviews, setImagePreviews] = useState([]);
|
const [imagePreviews, setImagePreviews] = useState([]);
|
||||||
|
const [uploadedImagePaths, setUploadedImagePaths] = useState([]);
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||||
const [mapCenter, setMapCenter] = useState([33.5138, 36.2765]);
|
const [mapCenter, setMapCenter] = useState([33.5138, 36.2765]);
|
||||||
|
const [mapZoom, setMapZoom] = useState(13);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
|
const [currencies, setCurrencies] = useState([]);
|
||||||
|
const [selectedCurrencyId, setSelectedCurrencyId] = useState(Currency.SYP);
|
||||||
|
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
@ -140,30 +157,26 @@ export default function AddPropertyPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const serviceList = [
|
const serviceList = [
|
||||||
{ id: 'electricity', label: 'كهرباء', icon: Zap },
|
{ id: PropertyService.ELECTRICITY, label: PropertyServiceLabels[PropertyService.ELECTRICITY], icon: Zap },
|
||||||
{ id: 'internet', label: 'انترنت', icon: Wifi },
|
{ id: PropertyService.INTERNET, label: PropertyServiceLabels[PropertyService.INTERNET], icon: Wifi },
|
||||||
{ id: 'heating', label: 'تدفئة', icon: Flame },
|
{ id: PropertyService.HEATING, label: PropertyServiceLabels[PropertyService.HEATING], icon: Flame },
|
||||||
{ id: 'water', label: 'ماء', icon: Droplets },
|
{ id: PropertyService.WATER, label: PropertyServiceLabels[PropertyService.WATER], icon: Droplets },
|
||||||
{ id: 'airConditioning', label: 'تكييف', icon: Wind },
|
{ id: PropertyService.CENTRAL_AIR_CONDITIONING, label: PropertyServiceLabels[PropertyService.CENTRAL_AIR_CONDITIONING], icon: Wind },
|
||||||
{ id: 'parking', label: 'موقف سيارات', icon: Warehouse },
|
{ id: PropertyService.PARKING, label: PropertyServiceLabels[PropertyService.PARKING], icon: Warehouse },
|
||||||
{ id: 'elevator', label: 'مصعد', icon: Layers }
|
{ id: PropertyService.ELEVATOR, label: PropertyServiceLabels[PropertyService.ELEVATOR], icon: Layers },
|
||||||
];
|
];
|
||||||
|
|
||||||
const termsList = [
|
const termsList = [
|
||||||
{ id: 'noSmoking', label: 'ممنوع التدخين', icon: Cigarette },
|
{ id: PropertyTerm.NO_SMOKING, label: PropertyTermLabels[PropertyTerm.NO_SMOKING], icon: Cigarette },
|
||||||
{ id: 'noPets', label: 'ممنوع الحيوانات', icon: Dog },
|
{ id: PropertyTerm.NO_ANIMALS, label: PropertyTermLabels[PropertyTerm.NO_ANIMALS], icon: Dog },
|
||||||
{ id: 'noParties', label: 'عدم إقامة حفلات', icon: Music },
|
{ id: PropertyTerm.NO_PARTIES, label: PropertyTermLabels[PropertyTerm.NO_PARTIES], icon: Music },
|
||||||
{ id: 'noAlcohol', label: 'ممنوع الكحول', icon: X },
|
|
||||||
{ id: 'suitableForChildren', label: 'مناسب للأطفال', icon: Star },
|
|
||||||
{ id: 'suitableForElderly', label: 'مناسب لكبار السن', icon: Star }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const offerTypes = [
|
const offerTypes = [
|
||||||
{ id: 'daily', label: 'إيجار يومي', icon: Clock },
|
{ id: 'daily', label: 'إيجار يومي', icon: Clock },
|
||||||
{ id: 'monthly', label: 'إيجار شهري', icon: Calendar },
|
{ id: 'monthly', label: 'إيجار شهري', icon: Calendar },
|
||||||
{ id: 'both', label: 'إيجار يومي وشهري', icon: Calendar },
|
{ id: 'both', label: 'إيجار يومي وشهري', icon: Calendar },
|
||||||
{ id: 'sale', label: 'للبيع', icon: DollarSign }
|
].filter(Boolean);
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -176,6 +189,16 @@ export default function AddPropertyPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setMapLoaded(true);
|
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 () => {
|
const handleSearch = async () => {
|
||||||
@ -317,36 +340,48 @@ const handleMapClick = async (coords) => {
|
|||||||
toast.info('تم إلغاء تحديد الموقع');
|
toast.info('تم إلغاء تحديد الموقع');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = (files) => {
|
const handleImageUpload = async (files) => {
|
||||||
const newImages = Array.from(files);
|
const newImages = Array.from(files);
|
||||||
|
console.log('[AddProperty] handleImageUpload called with', newImages.length, 'files');
|
||||||
|
|
||||||
if (formData.images.length + newImages.length > 10) {
|
if (formData.images.length + newImages.length > 10) {
|
||||||
toast.error('يمكنك رفع 10 صور كحد أقصى');
|
toast.error('يمكنك رفع 10 صور كحد أقصى');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
newImages.forEach(file => {
|
for (const file of newImages) {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
toast.error('الرجاء اختيار صور صالحة فقط');
|
toast.error('الرجاء اختيار صور صالحة فقط');
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
setImagePreviews(prev => [...prev, reader.result]);
|
setImagePreviews(prev => [...prev, reader.result]);
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
setFormData({
|
setFormData(prev => ({
|
||||||
...formData,
|
...prev,
|
||||||
images: [...formData.images, file]
|
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) => {
|
const removeImage = (index) => {
|
||||||
@ -356,30 +391,34 @@ const handleMapClick = async (coords) => {
|
|||||||
const newPreviews = [...imagePreviews];
|
const newPreviews = [...imagePreviews];
|
||||||
newPreviews.splice(index, 1);
|
newPreviews.splice(index, 1);
|
||||||
|
|
||||||
setFormData({
|
const newPaths = [...uploadedImagePaths];
|
||||||
...formData,
|
newPaths.splice(index, 1);
|
||||||
images: newImages
|
|
||||||
});
|
setFormData(prev => ({ ...prev, images: newImages }));
|
||||||
setImagePreviews(newPreviews);
|
setImagePreviews(newPreviews);
|
||||||
|
setUploadedImagePaths(newPaths);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleService = (serviceId) => {
|
const toggleService = (serviceId) => {
|
||||||
setFormData({
|
setFormData(prev => {
|
||||||
...formData,
|
const services = { ...prev.services };
|
||||||
services: {
|
services[serviceId] = !services[serviceId];
|
||||||
...formData.services,
|
return { ...prev, services };
|
||||||
[serviceId]: !formData.services[serviceId]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateServiceDetail = (serviceId, value) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
serviceDetails: { ...prev.serviceDetails, [serviceId]: value }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTerm = (termId) => {
|
const toggleTerm = (termId) => {
|
||||||
setFormData({
|
setFormData(prev => {
|
||||||
...formData,
|
const terms = { ...prev.terms };
|
||||||
terms: {
|
terms[termId] = !terms[termId];
|
||||||
...formData.terms,
|
return { ...prev, terms };
|
||||||
[termId]: !formData.terms[termId]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -464,9 +503,6 @@ const handleMapClick = async (coords) => {
|
|||||||
if (!formData.dailyPrice) newErrors.dailyPrice = 'السعر اليومي مطلوب';
|
if (!formData.dailyPrice) newErrors.dailyPrice = 'السعر اليومي مطلوب';
|
||||||
if (!formData.monthlyPrice) newErrors.monthlyPrice = 'السعر الشهري مطلوب';
|
if (!formData.monthlyPrice) newErrors.monthlyPrice = 'السعر الشهري مطلوب';
|
||||||
}
|
}
|
||||||
if (formData.offerType === 'sale' && !formData.salePrice) {
|
|
||||||
newErrors.salePrice = 'سعر البيع مطلوب';
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 4:
|
case 4:
|
||||||
@ -499,16 +535,92 @@ const handleMapClick = async (coords) => {
|
|||||||
if (!validateStep()) return;
|
if (!validateStep()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
console.log('[AddProperty] Building RentPropertyDto payload...');
|
||||||
|
|
||||||
setTimeout(() => {
|
// Map UI property type to API BuildingType enum
|
||||||
console.log('Property Data:', formData);
|
const buildingTypeMap = { apartment: BuildingType.APARTMENT, villa: BuildingType.VILLA, suite: BuildingType.APARTMENT, room: BuildingType.APARTMENT };
|
||||||
setIsLoading(false);
|
|
||||||
|
// 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('تم إضافة العقار بنجاح!');
|
toast.success('تم إضافة العقار بنجاح!');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/owner/properties');
|
router.push('/owner/properties');
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}, 2000);
|
} catch (err) {
|
||||||
|
console.error('[AddProperty] API error:', err);
|
||||||
|
toast.error(err.message || 'فشل في إضافة العقار');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fadeInUp = {
|
const fadeInUp = {
|
||||||
@ -517,15 +629,6 @@ const handleMapClick = async (coords) => {
|
|||||||
transition: { duration: 0.5 }
|
transition: { duration: 0.5 }
|
||||||
};
|
};
|
||||||
|
|
||||||
function MapClickHandler({ onMapClick }) {
|
|
||||||
const map = useMapEvents({
|
|
||||||
dblclick: (e) => {
|
|
||||||
const { lat, lng } = e.latlng;
|
|
||||||
onMapClick([lat, lng]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
<Toaster position="top-center" reverseOrder={false} />
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
@ -752,34 +855,37 @@ function MapClickHandler({ onMapClick }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة</h3>
|
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة <span className="text-red-500">*</span></h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="space-y-3">
|
||||||
{serviceList.map((service) => {
|
{serviceList.map((service) => {
|
||||||
const Icon = service.icon;
|
const Icon = service.icon;
|
||||||
|
const isSelected = formData.services[service.id];
|
||||||
return (
|
return (
|
||||||
<label
|
<div key={service.id} className={`border rounded-xl transition-all ${isSelected ? 'border-amber-500 bg-amber-50' : 'border-gray-200'}`}>
|
||||||
key={service.id}
|
<label className="flex items-center gap-3 p-3 cursor-pointer">
|
||||||
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.services[service.id]}
|
checked={isSelected}
|
||||||
onChange={() => toggleService(service.id)}
|
onChange={() => toggleService(service.id)}
|
||||||
className="hidden"
|
className="w-4 h-4 text-amber-500 rounded"
|
||||||
/>
|
/>
|
||||||
<Icon className={`w-5 h-5 ${
|
<Icon className={`w-5 h-5 ${isSelected ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||||
formData.services[service.id] ? 'text-amber-600' : 'text-gray-400'
|
<span className={`text-sm font-medium ${isSelected ? 'text-amber-700' : 'text-gray-600'}`}>
|
||||||
}`} />
|
|
||||||
<span className={`text-sm ${
|
|
||||||
formData.services[service.id] ? 'text-amber-700' : 'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{service.label}
|
{service.label}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
@ -857,6 +963,41 @@ function MapClickHandler({ onMapClick }) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<AnimatePresence mode="wait">
|
||||||
{(formData.offerType === 'daily' || formData.offerType === 'both') && (
|
{(formData.offerType === 'daily' || formData.offerType === 'both') && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -919,37 +1060,6 @@ function MapClickHandler({ onMapClick }) {
|
|||||||
</div>
|
</div>
|
||||||
</motion.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>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</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
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import AuthService from '../../services/AuthService';
|
||||||
|
import { getMyRentListings } from '../../utils/api';
|
||||||
|
|
||||||
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
@ -692,70 +694,84 @@ export default function OwnerPropertiesPage() {
|
|||||||
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('user');
|
const authUser = AuthService.getUser();
|
||||||
if (storedUser) {
|
if (authUser && AuthService.isOwner()) {
|
||||||
const userData = JSON.parse(storedUser);
|
setUser({
|
||||||
if (userData.role !== 'owner') {
|
name: authUser.name || authUser.email,
|
||||||
router.push('/');
|
email: authUser.email,
|
||||||
} else {
|
role: 'owner',
|
||||||
setUser(userData);
|
});
|
||||||
loadProperties();
|
loadProperties();
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
router.push('/auth/choose-role');
|
router.push('/auth/choose-role');
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const loadProperties = () => {
|
|
||||||
const storedProperties = localStorage.getItem('ownerProperties');
|
|
||||||
if (storedProperties) {
|
const loadProperties = async () => {
|
||||||
setProperties(JSON.parse(storedProperties));
|
const authUser = AuthService.getUser();
|
||||||
} else {
|
const userId = authUser?.id;
|
||||||
const mockProperties = [
|
|
||||||
{
|
if (!userId) {
|
||||||
id: 1,
|
console.warn('[OwnerProperties] No user ID found');
|
||||||
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));
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePropertiesInStorage = (newProperties) => {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
app/page.js
336
app/page.js
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Lock,
|
Lock,
|
||||||
@ -28,6 +29,85 @@ import HeroSearch from './components/home/HeroSearch';
|
|||||||
import PropertyMap from './components/home/PropertyMap';
|
import PropertyMap from './components/home/PropertyMap';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
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() {
|
export default function HomePage() {
|
||||||
const mapSectionRef = useRef(null);
|
const mapSectionRef = useRef(null);
|
||||||
@ -38,12 +118,55 @@ export default function HomePage() {
|
|||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
const menuRef = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('user');
|
const authUser = AuthService.getUser();
|
||||||
if (storedUser) {
|
if (authUser) {
|
||||||
setUser(JSON.parse(storedUser));
|
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(() => {
|
useEffect(() => {
|
||||||
@ -57,173 +180,25 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('user');
|
AuthService.deleteToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowUserMenu(false);
|
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) => {
|
const applyFilters = (filters) => {
|
||||||
setSearchFilters(filters);
|
setSearchFilters(filters);
|
||||||
|
|
||||||
const filtered = allProperties.filter(property => {
|
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) {
|
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -234,7 +209,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
if (filters.priceRange && filters.priceRange !== 'all') {
|
if (filters.priceRange && filters.priceRange !== 'all') {
|
||||||
const priceUSD = property.priceUSD;
|
const priceUSD = property.priceUSD;
|
||||||
switch(filters.priceRange) {
|
switch (filters.priceRange) {
|
||||||
case '0-500': if (priceUSD > 50) return false; break;
|
case '0-500': if (priceUSD > 50) return false; break;
|
||||||
case '500-1000': if (priceUSD < 51 || priceUSD > 100) 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;
|
case '1000-2000': if (priceUSD < 101 || priceUSD > 200) return false; break;
|
||||||
@ -243,6 +218,20 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (filters.identityType && property.allowedIdentities) {
|
||||||
if (!property.allowedIdentities.includes(filters.identityType)) {
|
if (!property.allowedIdentities.includes(filters.identityType)) {
|
||||||
return false;
|
return false;
|
||||||
@ -361,7 +350,7 @@ export default function HomePage() {
|
|||||||
</motion.p>
|
</motion.p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{!isOwner && <HeroSearch onSearch={applyFilters} />}
|
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -526,6 +515,25 @@ export default function HomePage() {
|
|||||||
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
Pencil
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
import { getCustomerByUserId, getOwnerByUserId } from '../utils/api';
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -62,14 +64,51 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('user');
|
const authUser = AuthService.getUser();
|
||||||
if (storedUser) {
|
if (authUser) {
|
||||||
const userData = JSON.parse(storedUser);
|
const userData = {
|
||||||
|
id: authUser.id,
|
||||||
|
name: authUser.name || '',
|
||||||
|
email: authUser.email || '',
|
||||||
|
phone: authUser.phone || '',
|
||||||
|
role: AuthService.isOwner() ? 'owner' : 'customer',
|
||||||
|
};
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
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');
|
const savedProfile = localStorage.getItem('userProfile');
|
||||||
let profileData;
|
let profileData;
|
||||||
|
|
||||||
if (savedProfile) {
|
if (savedProfile) {
|
||||||
profileData = JSON.parse(savedProfile);
|
profileData = JSON.parse(savedProfile);
|
||||||
} else {
|
} else {
|
||||||
@ -83,16 +122,17 @@ export default function ProfilePage() {
|
|||||||
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(profileData);
|
setFormData(profileData);
|
||||||
setTempValues(profileData);
|
setTempValues(profileData);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
const savedAvatar = localStorage.getItem('userAvatar');
|
const savedAvatar = localStorage.getItem('userAvatar');
|
||||||
if (savedAvatar) {
|
if (savedAvatar) {
|
||||||
setAvatarPreview(savedAvatar);
|
setAvatarPreview(savedAvatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
fetchProfile();
|
||||||
} else {
|
} else {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
@ -167,7 +207,6 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
if (field === 'name') {
|
if (field === 'name') {
|
||||||
const updatedUser = { ...user, name: value };
|
const updatedUser = { ...user, name: value };
|
||||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
|
||||||
setUser(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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
MapPin,
|
MapPin,
|
||||||
@ -32,18 +31,98 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
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 dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
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 [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) => {
|
const formatCurrency = (amount) => {
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
return amount?.toLocaleString() + ' ل.س';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPropertyTypeIcon = (type) => {
|
const getPropertyTypeIcon = (type) => {
|
||||||
switch(type) {
|
switch (type) {
|
||||||
case 'villa': return <Home className="w-4 h-4" />;
|
case 'villa': return <Home className="w-4 h-4" />;
|
||||||
case 'apartment': return <Building2 className="w-4 h-4" />;
|
case 'apartment': return <Building2 className="w-4 h-4" />;
|
||||||
case 'house': return <Home 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) => {
|
const getPropertyTypeLabel = (type) => {
|
||||||
switch(type) {
|
switch (type) {
|
||||||
case 'villa': return 'فيلا';
|
case 'villa': return 'فيلا';
|
||||||
case 'apartment': return 'شقة';
|
case 'apartment': return 'شقة';
|
||||||
case 'house': return 'بيت';
|
case 'house': return 'بيت';
|
||||||
@ -83,26 +162,20 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => setCurrentImage(idx)}
|
onClick={() => setCurrentImage(idx)}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
className={`w-1.5 h-1.5 rounded-full transition-all ${idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'}`}
|
||||||
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<button
|
<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"
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="md:w-2/3 p-6">
|
<div className="md:w-2/3 p-6">
|
||||||
@ -113,11 +186,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
{getPropertyTypeIcon(property.type)}
|
{getPropertyTypeIcon(property.type)}
|
||||||
{getPropertyTypeLabel(property.type)}
|
{getPropertyTypeLabel(property.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
<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'
|
|
||||||
? 'bg-gray-800 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -148,22 +217,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
<p className="text-gray-600 text-sm mb-4 line-clamp-2">{property.description}</p>
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link
|
<Link
|
||||||
@ -195,32 +249,15 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
fill
|
fill
|
||||||
className="object-cover"
|
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">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<button
|
<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"
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
@ -232,9 +269,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
{getPropertyTypeLabel(property.type)}
|
{getPropertyTypeLabel(property.type)}
|
||||||
</span>
|
</span>
|
||||||
{property.status === 'available' && (
|
{property.status === 'available' && (
|
||||||
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">
|
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">متاح</span>
|
||||||
متاح
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
|
<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>
|
</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
|
<Link
|
||||||
href={`/property/${property.id}`}
|
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"
|
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: 'apartment', label: 'شقة', icon: Building2 },
|
||||||
{ id: 'villa', label: 'فيلا', icon: Home },
|
{ id: 'villa', label: 'فيلا', icon: Home },
|
||||||
{ id: 'house', label: 'بيت', icon: Home },
|
{ id: 'house', label: 'بيت', icon: Home },
|
||||||
{ id: 'studio', label: 'استوديو', icon: Building2 }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const priceRanges = [
|
const priceRanges = [
|
||||||
@ -364,11 +385,7 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
|||||||
<button
|
<button
|
||||||
key={type.id}
|
key={type.id}
|
||||||
onClick={() => onFilterChange({ ...filters, propertyType: 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 ${
|
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'}`}
|
||||||
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" />}
|
{Icon && <Icon className="w-4 h-4" />}
|
||||||
{type.label}
|
{type.label}
|
||||||
@ -439,30 +456,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex gap-3 mt-4 pt-4 border-t border-gray-100">
|
<div className="flex gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||||
@ -498,6 +491,9 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
|||||||
export default function PropertiesPage() {
|
export default function PropertiesPage() {
|
||||||
const [viewMode, setViewMode] = useState('grid');
|
const [viewMode, setViewMode] = useState('grid');
|
||||||
const [sortBy, setSortBy] = useState('newest');
|
const [sortBy, setSortBy] = useState('newest');
|
||||||
|
const [properties, setProperties] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: '',
|
search: '',
|
||||||
propertyType: 'all',
|
propertyType: 'all',
|
||||||
@ -509,94 +505,35 @@ export default function PropertiesPage() {
|
|||||||
features: []
|
features: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const [properties] = useState([
|
useEffect(() => {
|
||||||
{
|
async function fetchProperties() {
|
||||||
id: 1,
|
try {
|
||||||
title: 'فيلا فاخرة في المزة',
|
const [rentData, saleData] = await Promise.all([
|
||||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.',
|
getRentProperties().catch(() => []),
|
||||||
type: 'villa',
|
getSaleProperties().catch(() => []),
|
||||||
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
|
|
||||||
}
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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
|
const filteredProperties = properties
|
||||||
.filter(property => {
|
.filter(property => {
|
||||||
if (filters.search && !property.title.includes(filters.search) && !property.description.includes(filters.search)) {
|
if (filters.search && !property.title.includes(filters.search) && !property.description.includes(filters.search)) {
|
||||||
@ -613,8 +550,8 @@ export default function PropertiesPage() {
|
|||||||
if (max) {
|
if (max) {
|
||||||
if (property.price < parseInt(min) || property.price > parseInt(max)) return false;
|
if (property.price < parseInt(min) || property.price > parseInt(max)) return false;
|
||||||
} else if (filters.priceRange.endsWith('+')) {
|
} else if (filters.priceRange.endsWith('+')) {
|
||||||
const min = parseInt(filters.priceRange.replace('+', ''));
|
const minVal = parseInt(filters.priceRange.replace('+', ''));
|
||||||
if (property.price < min) return false;
|
if (property.price < minVal) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.bedrooms !== 'all' && property.bedrooms < parseInt(filters.bedrooms)) {
|
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.minArea && property.area < parseInt(filters.minArea)) return false;
|
||||||
if (filters.maxArea && property.area > parseInt(filters.maxArea)) 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;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
switch(sortBy) {
|
switch (sortBy) {
|
||||||
case 'price_asc': return a.price - b.price;
|
case 'price_asc': return a.price - b.price;
|
||||||
case 'price_desc': return b.price - a.price;
|
case 'price_desc': return b.price - a.price;
|
||||||
case 'rating': return b.rating - a.rating;
|
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>
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">عقارات للإيجار</h1>
|
||||||
<p className="text-gray-500">أفضل العقارات في سوريا</p>
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<FilterBar filters={filters} onFilterChange={setFilters} />
|
<FilterBar filters={filters} onFilterChange={setFilters} />
|
||||||
@ -668,19 +607,13 @@ export default function PropertiesPage() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
className={`p-2 rounded-xl transition-colors ${
|
className={`p-2 rounded-xl transition-colors ${viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
title="عرض شبكي"
|
|
||||||
>
|
>
|
||||||
<Grid3x3 className="w-5 h-5" />
|
<Grid3x3 className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
className={`p-2 rounded-xl transition-colors ${
|
className={`p-2 rounded-xl transition-colors ${viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||||
viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
title="عرض قائمة"
|
|
||||||
>
|
>
|
||||||
<List className="w-5 h-5" />
|
<List className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -693,7 +626,7 @@ export default function PropertiesPage() {
|
|||||||
: 'space-y-4'
|
: 'space-y-4'
|
||||||
}>
|
}>
|
||||||
{filteredProperties.map((property) => (
|
{filteredProperties.map((property) => (
|
||||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} />
|
<PropertyCard key={property.id} property={property} viewMode={viewMode} onLoginRequired={() => setShowLoginDialog(true)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -711,6 +644,37 @@ export default function PropertiesPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
1373
app/property/[id]/PropertyDetail.js
Normal file
1373
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';
|
// Server-side API fetch for metadata (runs at request time on server)
|
||||||
import { motion } from 'framer-motion';
|
async function fetchPropertyForMeta(id) {
|
||||||
import Image from 'next/image';
|
try {
|
||||||
import Link from 'next/link';
|
const res = await fetch(`http://45.93.137.91/api/RentProperties/GetRentProperties`, {
|
||||||
import { useParams } from 'next/navigation';
|
next: { revalidate: 60 },
|
||||||
import {
|
});
|
||||||
MapPin,
|
if (!res.ok) return null;
|
||||||
Bed,
|
const text = await res.text();
|
||||||
Bath,
|
const json = JSON.parse(text);
|
||||||
Square,
|
const items = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
||||||
DollarSign,
|
return items.find(p => p.id == id) || items[0] || null;
|
||||||
Heart,
|
} catch {
|
||||||
Share2,
|
return null;
|
||||||
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';
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const propertiesData = {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
title: 'فيلا فاخرة في المزة',
|
|
||||||
description: `تتميز هذه الفيلا الفاخرة بتصميمها العصري وموقعها المميز في أفضل أحياء دمشق. تم بناء الفيلا بأعلى المواصفات باستخدام أفضل المواد، مع مساحات واسعة وحديقة خاصة.
|
|
||||||
|
|
||||||
المميزات الرئيسية:
|
|
||||||
• موقع راقي وقريب من جميع الخدمات
|
|
||||||
• تصميم داخلي عصري مع أثاث فاخر
|
|
||||||
• إطلالة رائعة على المدينة
|
|
||||||
• خصوصية تامة وأمن على مدار الساعة
|
|
||||||
|
|
||||||
المساحات الداخلية:
|
|
||||||
• الطابق الأرضي: صالة استقبال كبيرة (80 م²)، مجلس رجال (40 م²)، مجلس نساء (35 م²)، مطبخ (25 م²)، غرفة طعام (30 م²)
|
|
||||||
• الطابق الأول: 5 غرف نوم ماستر مع حمامات خاصة (كل غرفة 35-45 م²)
|
|
||||||
• الطابق الثاني: غرفة معيشة عائلية (50 م²)، غرفة ترفيه (40 م²)، سطح مع إطلالة (100 م²)
|
|
||||||
|
|
||||||
الخدمات القريبة:
|
|
||||||
• مدارس وجامعات على بعد 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 ظهراً'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
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(() => {
|
function mapProperty(item) {
|
||||||
setLoading(true);
|
const info = item.propertyInformation || item.PropertyInformation || {};
|
||||||
setTimeout(() => {
|
let details = {};
|
||||||
setProperty(propertiesData[params.id] || propertiesData[1]);
|
try { details = JSON.parse(info.detailsJSON || info.DetailsJSON || '{}'); } catch {}
|
||||||
setLoading(false);
|
|
||||||
}, 500);
|
const price = item.monthlyRent || item.MonthlyRent || item.dailyRent || item.DailyRent || 0;
|
||||||
}, [params.id]);
|
const priceUnit = item.monthlyRent || item.MonthlyRent ? 'monthly' : 'daily';
|
||||||
|
const buildingType = info.buildingType ?? info.BuildingType ?? 0;
|
||||||
const formatCurrency = (amount) => {
|
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
|
||||||
};
|
const address = info.address || info.Address || '';
|
||||||
|
const bedrooms = info.numberOfBedRooms || info.NumberOfBedRooms || 0;
|
||||||
const calculateTotalPrice = () => {
|
const bathrooms = info.numberOfBathRooms || info.NumberOfBathRooms || 0;
|
||||||
if (!property) return 0;
|
const area = info.space || info.Space || 0;
|
||||||
const days = bookingDates.start && bookingDates.end
|
const desc = info.description || info.Description || '';
|
||||||
? Math.ceil((new Date(bookingDates.end) - new Date(bookingDates.start)) / (1000 * 60 * 60 * 24))
|
const images = info.images || info.Images || [];
|
||||||
: selectedDuration;
|
const firstImage = Array.isArray(images) && images[0] ? images[0] : '';
|
||||||
return property.price * (days > 0 ? days : 1);
|
|
||||||
};
|
return {
|
||||||
|
title: `${typeLabel} في ${address}`,
|
||||||
const handleBooking = () => {
|
description: desc || `${typeLabel} في ${address} · ${bedrooms} غرف نوم · ${bathrooms} حمامات · ${area} م²`,
|
||||||
alert('تم إرسال طلب الحجز بنجاح. سيتم التواصل معك قريباً.');
|
price,
|
||||||
};
|
priceUnit,
|
||||||
|
typeLabel,
|
||||||
if (loading) {
|
address,
|
||||||
return (
|
bedrooms,
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
bathrooms,
|
||||||
<div className="text-center">
|
area,
|
||||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-gray-800 rounded-full animate-spin mx-auto mb-4"></div>
|
image: firstImage,
|
||||||
<p className="text-gray-600">جاري تحميل تفاصيل العقار...</p>
|
};
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
);
|
export async function generateMetadata({ params }) {
|
||||||
}
|
const { id } = await params;
|
||||||
|
const raw = await fetchPropertyForMeta(id);
|
||||||
if (!property) {
|
|
||||||
return (
|
if (!raw) {
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
return {
|
||||||
<div className="text-center">
|
title: 'SweetHome - عقار',
|
||||||
<Home className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
description: 'اكتشف أفضل العقارات للإيجار',
|
||||||
<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">
|
|
||||||
العودة إلى العقارات
|
const p = mapProperty(raw);
|
||||||
</Link>
|
const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === 'daily' ? 'يوم' : 'شهر'}`;
|
||||||
</div>
|
const propertyImage = p.image
|
||||||
</div>
|
? (p.image.startsWith('http') ? p.image : `http://45.93.137.91${p.image}`)
|
||||||
);
|
: '';
|
||||||
}
|
const logoUrl = `http://45.93.137.91/logo.png`;
|
||||||
|
|
||||||
return (
|
// Use property image if available, otherwise logo
|
||||||
<div className="min-h-screen bg-gray-50">
|
const ogImages = propertyImage
|
||||||
<div className="bg-white border-b sticky top-16 z-40 shadow-sm">
|
? [{ url: propertyImage, width: 1200, height: 630 }, { url: logoUrl, width: 512, height: 512 }]
|
||||||
<div className="container mx-auto px-4">
|
: [{ url: logoUrl, width: 512, height: 512 }];
|
||||||
<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">
|
return {
|
||||||
<ArrowLeft className="w-5 h-5" />
|
title: `${p.title} - ${priceStr}`,
|
||||||
<span>العودة إلى العقارات</span>
|
description: p.description,
|
||||||
</Link>
|
openGraph: {
|
||||||
<div className="flex gap-2">
|
title: `${p.title} - ${priceStr}`,
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
description: p.description,
|
||||||
<Heart className="w-5 h-5 text-gray-600" />
|
images: ogImages,
|
||||||
</button>
|
url: `http://45.93.137.91/property/${id}`,
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
type: 'website',
|
||||||
<Share2 className="w-5 h-5 text-gray-600" />
|
siteName: 'SweetHome',
|
||||||
</button>
|
},
|
||||||
</div>
|
twitter: {
|
||||||
</div>
|
card: 'summary_large_image',
|
||||||
</div>
|
title: `${p.title} - ${priceStr}`,
|
||||||
</div>
|
description: p.description,
|
||||||
|
images: ogImages.map(i => i.url),
|
||||||
<div className="container mx-auto px-4 py-8">
|
},
|
||||||
<motion.div
|
};
|
||||||
initial={{ opacity: 0, y: 20 }}
|
}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-8"
|
export default function PropertyPage({ params }) {
|
||||||
>
|
return <PropertyDetail params={params} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useMemo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import {
|
import {
|
||||||
User,
|
User, Mail, Phone, Lock, Eye, EyeOff,
|
||||||
Mail,
|
CheckCircle, XCircle, ArrowLeft, Home, Loader2,
|
||||||
Phone,
|
Shield, KeyRound, Camera, X
|
||||||
Lock,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
Home,
|
|
||||||
Loader2
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
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() {
|
export default function TenantRegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState(1); // 1=form, 2=id images
|
||||||
|
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
whatsapp: '',
|
||||||
|
phone2: '',
|
||||||
|
nationalNumber: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
|
customerType: CustomerType.PERSONAL,
|
||||||
agreeTerms: false
|
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 [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const validateEmail = (email) => {
|
const fileInputFrontRef = useRef(null);
|
||||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const fileInputBackRef = useRef(null);
|
||||||
return re.test(email);
|
|
||||||
|
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 validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
const re = /^(09|05)[0-9]{8}$/;
|
const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
|
||||||
return re.test(phone);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateStep1 = () => {
|
||||||
const newErrors = {};
|
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) {
|
if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||||
newErrors.email = 'البريد الإلكتروني مطلوب';
|
else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||||
} else if (!validateEmail(formData.email)) {
|
|
||||||
newErrors.email = 'البريد الإلكتروني غير صالح';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.phone) {
|
if (!formData.phone) newErrors.phone = 'رقم الهاتف مطلوب';
|
||||||
newErrors.phone = 'رقم الهاتف مطلوب';
|
else if (!validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
|
||||||
} else if (!validatePhone(formData.phone)) {
|
|
||||||
newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.password) {
|
if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
|
||||||
newErrors.password = 'كلمة المرور مطلوبة';
|
else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
||||||
} else if (formData.password.length < 6) {
|
|
||||||
newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
|
||||||
newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
if (!formData.phone2 || formData.phone2.length !== 7) newErrors.phone2 = 'رقم الهاتف يجب أن يكون 7 أرقام';
|
||||||
}
|
if (!formData.nationalNumber) newErrors.nationalNumber = 'الرقم الوطني مطلوب';
|
||||||
|
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateStep2()) {
|
||||||
toast.error('يرجى تصحيح الأخطاء في النموذج');
|
toast.error('يرجى إكمال جميع الصور المطلوبة');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.agreeTerms) {
|
if (!formData.agreeTerms) {
|
||||||
toast.error('يجب الموافقة على الشروط والأحكام');
|
toast.error('يجب الموافقة على الشروط والأحكام');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
console.log('[CustomerRegister] Submitting customer registration...');
|
||||||
|
|
||||||
setTimeout(() => {
|
const payload = {
|
||||||
setIsLoading(false);
|
firstName: formData.firstName,
|
||||||
toast.success('تم إنشاء الحساب بنجاح!', {
|
lastName: formData.lastName,
|
||||||
style: { background: '#dcfce7', color: '#166534' },
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify({
|
|
||||||
name: formData.name,
|
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
role: 'tenant',
|
phoneNumber: formData.phone,
|
||||||
avatar: formData.name.charAt(0).toUpperCase()
|
whatsAppNumber: formData.whatsapp,
|
||||||
}));
|
phone: formData.phone2,
|
||||||
|
nationalNumber: formData.nationalNumber,
|
||||||
|
password: formData.password,
|
||||||
|
customerType: formData.customerType,
|
||||||
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
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('/');
|
router.push('/');
|
||||||
}, 1500);
|
}
|
||||||
}, 2000);
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── OTP verification handler ───
|
||||||
|
const handleVerifyOTP = async () => {
|
||||||
|
if (!otpCode || otpCode.length < 4) {
|
||||||
|
toast.error('يرجى إدخال رمز التحقق');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
const fadeInUp = {
|
||||||
@ -121,318 +236,379 @@ export default function TenantRegisterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const staggerContainer = {
|
const staggerContainer = {
|
||||||
animate: {
|
animate: { transition: { staggerChildren: 0.1 } }
|
||||||
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 (
|
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">
|
<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} />
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
{/* <div className="absolute inset-0 overflow-hidden">
|
||||||
{[...Array(20)].map((_, i) => (
|
{[...Array(20)].map((_, i) => (
|
||||||
<motion.div
|
<motion.div key={i} className="absolute rounded-full bg-blue-500/10"
|
||||||
key={i}
|
style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
|
||||||
className="absolute rounded-full bg-blue-500/10"
|
animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
|
||||||
style={{
|
transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
|
||||||
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>
|
</div> */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<motion.div
|
{backgroundElements}
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
</div>
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
|
||||||
transition={{ duration: 0.5 }}
|
className="relative z-10 w-full max-w-md">
|
||||||
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">
|
||||||
<motion.div
|
<Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
|
||||||
initial={{ opacity: 0, x: -20 }}
|
<motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
|
||||||
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>
|
|
||||||
<span>العودة</span>
|
<span>العودة</span>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</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-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">
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-8 text-center relative overflow-hidden">
|
||||||
<motion.div
|
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2, type: "spring" }}
|
||||||
initial={{ scale: 0 }}
|
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
|
||||||
animate={{ scale: 1 }}
|
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="relative z-10">
|
||||||
transition={{ delay: 0.2, type: "spring" }}
|
<motion.div animate={{ rotate: [0, 10, -10, 0] }} transition={{ duration: 2, repeat: Infinity }}
|
||||||
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full"
|
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={{ 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" />
|
<Home className="w-10 h-10 text-white" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">إنشاء حساب مستأجر</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
<p className="text-blue-100">انضم إلينا وابحث عن منزل أحلامك</p>
|
{step === 1 ? 'إنشاء حساب مستأجر' : 'الوثائق الرسمية'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-blue-100">
|
||||||
|
{step === 1 ? 'انضم إلينا وابحث عن منزل أحلامك' : 'يرجى رفع صور الهوية للتحقق'}
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<motion.form
|
<motion.form variants={staggerContainer} initial="initial" animate="animate"
|
||||||
variants={staggerContainer}
|
onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
|
||||||
initial="initial"
|
className="space-y-6">
|
||||||
animate="animate"
|
|
||||||
onSubmit={handleSubmit}
|
{/* ─── STEP 1: Form ─── */}
|
||||||
className="space-y-6"
|
{step === 1 && (
|
||||||
>
|
<>
|
||||||
<motion.div variants={fadeInUp}>
|
<motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<div>
|
||||||
الاسم الكامل <span className="text-red-500">*</span>
|
<label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
|
||||||
</label>
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<User className={`w-5 h-5 ${
|
<User className={`w-5 h-5 ${errors.firstName ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||||
errors.name ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" value={formData.firstName}
|
||||||
type="text"
|
onChange={(e) => { setFormData({...formData, firstName: e.target.value}); setErrors({...errors, firstName: null}); }}
|
||||||
value={formData.name}
|
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'}`}
|
||||||
onChange={(e) => {
|
placeholder="الاسم الأول" />
|
||||||
setFormData({...formData, name: e.target.value});
|
</div>
|
||||||
setErrors({...errors, name: null});
|
{errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
|
||||||
}}
|
</div>
|
||||||
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 ${
|
<div>
|
||||||
errors.name ? 'border-red-500' : 'border-gray-700'
|
<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}
|
||||||
placeholder="أدخل اسمك الكامل"
|
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>
|
</div>
|
||||||
{errors.name && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div variants={fadeInUp}>
|
<motion.div variants={fadeInUp}>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">البريد الإلكتروني <span className="text-red-500">*</span></label>
|
||||||
البريد الإلكتروني <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Mail className={`w-5 h-5 ${
|
<Mail className={`w-5 h-5 ${errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||||
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="email" value={formData.email}
|
||||||
type="email"
|
onChange={(e) => { setFormData({...formData, email: e.target.value}); setErrors({...errors, email: null}); }}
|
||||||
value={formData.email}
|
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'}`}
|
||||||
onChange={(e) => {
|
placeholder="أدخل بريدك الإلكتروني" />
|
||||||
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>
|
</div>
|
||||||
{errors.email && (
|
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div variants={fadeInUp}>
|
<motion.div variants={fadeInUp}>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف <span className="text-red-500">*</span></label>
|
||||||
رقم الهاتف <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Phone className={`w-5 h-5 ${
|
<Phone className={`w-5 h-5 ${errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||||
errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="tel" value={formData.phone}
|
||||||
type="tel"
|
onChange={(e) => { setFormData({...formData, phone: e.target.value}); setErrors({...errors, phone: null}); }}
|
||||||
value={formData.phone}
|
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'}`}
|
||||||
onChange={(e) => {
|
placeholder="أدخل رقم هاتفك" />
|
||||||
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>
|
</div>
|
||||||
{errors.phone && (
|
{errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.phone}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div variants={fadeInUp}>
|
<motion.div variants={fadeInUp}>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الواتساب <span className="text-red-500">*</span></label>
|
||||||
كلمة المرور <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Lock className={`w-5 h-5 ${
|
<Phone className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||||
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="tel" value={formData.whatsapp}
|
||||||
type={showPassword ? "text" : "password"}
|
onChange={(e) => { setFormData({...formData, whatsapp: e.target.value}); setErrors({...errors, whatsapp: null}); }}
|
||||||
value={formData.password}
|
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'}`}
|
||||||
onChange={(e) => {
|
placeholder="أدخل رقم الواتساب" />
|
||||||
setFormData({...formData, password: e.target.value});
|
</div>
|
||||||
setErrors({...errors, password: null});
|
{errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
|
||||||
}}
|
</motion.div>
|
||||||
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'
|
<motion.div variants={fadeInUp}>
|
||||||
}`}
|
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
|
||||||
placeholder="أدخل كلمة المرور"
|
<div className="relative group">
|
||||||
/>
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<button
|
<Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<input type="tel" value={formData.phone2}
|
||||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
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'}`}
|
||||||
{showPassword ? (
|
placeholder="أدخل رقم الهاتف" maxLength={7} />
|
||||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
</div>
|
||||||
) : (
|
{errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
|
||||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && (
|
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div variants={fadeInUp}>
|
<motion.div variants={fadeInUp}>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">تأكيد كلمة المرور <span className="text-red-500">*</span></label>
|
||||||
تأكيد كلمة المرور <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Lock className={`w-5 h-5 ${
|
<Lock className={`w-5 h-5 ${errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||||
errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword}
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: null}); }}
|
||||||
value={formData.confirmPassword}
|
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'}`}
|
||||||
onChange={(e) => {
|
placeholder="أعد إدخال كلمة المرور" />
|
||||||
setFormData({...formData, confirmPassword: e.target.value});
|
<button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
setErrors({...errors, confirmPassword: null});
|
{showConfirmPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||||
}}
|
|
||||||
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>
|
</button>
|
||||||
{formData.confirmPassword && (
|
{formData.confirmPassword && (
|
||||||
<div className="absolute inset-y-0 left-12 flex items-center">
|
<div className="absolute inset-y-0 left-12 flex items-center">
|
||||||
{formData.password === formData.confirmPassword ? (
|
{formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
|
||||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="w-5 h-5 text-red-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.confirmPassword && (
|
{errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>}
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
</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.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>
|
||||||
|
|
||||||
<motion.div variants={fadeInUp} className="flex items-center gap-2">
|
<motion.div variants={fadeInUp} className="flex items-center gap-2">
|
||||||
<input
|
<input type="checkbox" id="terms" checked={formData.agreeTerms}
|
||||||
type="checkbox"
|
|
||||||
id="terms"
|
|
||||||
checked={formData.agreeTerms}
|
|
||||||
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
|
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"
|
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500" required />
|
||||||
required
|
|
||||||
/>
|
|
||||||
<label htmlFor="terms" className="text-sm text-gray-300">
|
<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>
|
||||||
<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>
|
</label>
|
||||||
</motion.div>
|
</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.button>
|
|
||||||
|
|
||||||
|
{/* ─── 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.div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
|
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
|
||||||
لديك حساب بالفعل؟{' '}
|
لديك حساب بالفعل؟{' '}
|
||||||
<Link
|
<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">تسجيل الدخول</Link>
|
||||||
href="/login"
|
|
||||||
className="text-blue-400 hover:text-blue-300 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
تسجيل الدخول
|
|
||||||
</Link>
|
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
)}
|
||||||
</motion.form>
|
</motion.form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
</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',
|
* Constants — re-exports from enums for backward compatibility
|
||||||
BOOKED: 'booked',
|
*
|
||||||
MAINTENANCE: 'maintenance'
|
* 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 = {
|
// Re-export all enums
|
||||||
PENDING: 'pending',
|
export {
|
||||||
OWNER_APPROVED: 'owner_approved',
|
BuildingType,
|
||||||
ADMIN_APPROVED: 'admin_approved',
|
BuildingTypeLabels,
|
||||||
REJECTED: 'rejected',
|
BuildingTypeKeys,
|
||||||
ACTIVE: 'active',
|
BuildingTypeByKey,
|
||||||
COMPLETED: 'completed',
|
} from '../enums/BuildingType';
|
||||||
CANCELLED: 'cancelled'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const COMMISSION_TYPE = {
|
export {
|
||||||
FROM_OWNER: 'from_owner',
|
PropertyStatus,
|
||||||
FROM_TENANT: 'from_tenant',
|
PropertyStatusLabels,
|
||||||
FROM_BOTH: 'from_both'
|
PropertyStatusKeys,
|
||||||
};
|
PropertyStatusByKey,
|
||||||
|
} from '../enums/PropertyStatus';
|
||||||
|
|
||||||
export const IDENTITY_TYPE = {
|
export {
|
||||||
SYRIAN: 'syrian',
|
BookingStatus,
|
||||||
PASSPORT: 'passport'
|
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',
|
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 };
|
||||||
292
app/utils/ratings.js
Normal file
292
app/utils/ratings.js
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
// // 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;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// utils/ratings.js
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||||
|
async function apiFetch(endpoint, options = {}) {
|
||||||
|
const token = AuthService.getToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`API Error ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return json && typeof json === 'object' && 'data' in json ? json.data : json;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /Rating/AddPropertyRating
|
||||||
|
* @param {Object} data - { reservationId, cleanRating, servicesRating, ownerBehaviorRating, experienceRating, comment? }
|
||||||
|
*/
|
||||||
|
export async function addPropertyRating(data) {
|
||||||
|
return apiFetch('/Rating/AddPropertyRating', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /Rating/AddCustomerRating
|
||||||
|
* @param {Object} data - { reservationId, furnitureIntegrityRating, termsComplianceRating, renterBehaviorRating, comment? }
|
||||||
|
*/
|
||||||
|
export async function addCustomerRating(data) {
|
||||||
|
return apiFetch('/Rating/AddCustomerRating', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Rating/GetPropertyRatings
|
||||||
|
* @param {number} propertyId
|
||||||
|
* @param {number} page - default 1
|
||||||
|
* @param {number} pageSize - default 10
|
||||||
|
* @returns {Promise<{ items: Array, totalPages: number, currentPage: number }>}
|
||||||
|
*/
|
||||||
|
export async function getPropertyRatings(propertyId, page = 1, pageSize = 10) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
propertyId: String(propertyId),
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
}).toString();
|
||||||
|
return apiFetch(`/Rating/GetPropertyRatings?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Rating/GetCustomerRatings
|
||||||
|
* @param {number} renterId
|
||||||
|
* @param {number} page
|
||||||
|
* @param {number} pageSize
|
||||||
|
*/
|
||||||
|
export async function getCustomerRatings(renterId, page = 1, pageSize = 10) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
renterId: String(renterId),
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
}).toString();
|
||||||
|
return apiFetch(`/Rating/GetCustomerRatings?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Rating/GetPropertyAverage
|
||||||
|
* @param {number} propertyId
|
||||||
|
* @returns {Promise<number>} average rating (0 if none)
|
||||||
|
*/
|
||||||
|
export async function getPropertyAverageRating(propertyId) {
|
||||||
|
const result = await apiFetch(`/Rating/GetPropertyAverage?propertyId=${propertyId}`);
|
||||||
|
if (typeof result === 'number') return result;
|
||||||
|
if (result && typeof result.average === 'number') return result.average;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@ -2,6 +2,20 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
reactCompiler: true,
|
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",
|
// basePath: "/sweetHome",
|
||||||
// assetPrefix: "/sweetHome/",
|
// assetPrefix: "/sweetHome/",
|
||||||
};
|
};
|
||||||
|
|||||||
1057
package-lock.json
generated
1057
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pbe/react-yandex-maps": "^1.2.5",
|
"@pbe/react-yandex-maps": "^1.2.5",
|
||||||
|
"firebase": "^12.11.0",
|
||||||
"flowbite": "^4.0.1",
|
"flowbite": "^4.0.1",
|
||||||
"flowbite-react": "^0.12.16",
|
"flowbite-react": "^0.12.16",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
|
|||||||
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.
BIN
public/fonts/Madani Arabic Extra Light.ttf
Normal file
BIN
public/fonts/Madani Arabic Extra Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Extra Light.woff2
Normal file
BIN
public/fonts/Madani Arabic Extra Light.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Light.ttf
Normal file
BIN
public/fonts/Madani Arabic Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Light.woff2
Normal file
BIN
public/fonts/Madani Arabic Light.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Madani Arabic Medium.ttf
Normal file
BIN
public/fonts/Madani Arabic Medium.ttf
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