Compare commits
144 Commits
1c8e888ea3
...
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 | |||
| 082f20da40 | |||
| 157188d2e6 | |||
| ac1241583b | |||
| 552bbdd269 | |||
| 485e4c2630 | |||
| 45e46afe21 | |||
| e961288b04 | |||
| f6c6119c18 | |||
| d86cb9e9a1 | |||
| 6aab85e99f | |||
| 2ea48df8a8 | |||
| 7fe8903701 | |||
| 85252c9b76 | |||
| 73163be0c3 | |||
| a2139cf112 | |||
| 73b0b537ec | |||
| 9ea90a3f04 | |||
| 45dcf54187 | |||
| f56f417ca6 | |||
| cfe30c881f | |||
| 64a8df5efb | |||
| 5574dd01db |
40
.gitea/workflows/deployer.yaml
Normal file
40
.gitea/workflows/deployer.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
name: Build frontend
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: Rahaf/SweetHome
|
||||
github-server-url: http://45.93.137.91:3000
|
||||
|
||||
- name: Stopping server
|
||||
run: sudo systemctl stop sweetHome
|
||||
|
||||
- name: Copy repository to output file
|
||||
run: |
|
||||
sudo cp -r $GITHUB_WORKSPACE/* /opt/sweetHome/
|
||||
sudo chown -R $(whoami) /opt/sweetHome
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: /opt/sweetHome
|
||||
run: npm install
|
||||
|
||||
- name: Build next project
|
||||
working-directory: /opt/sweetHome
|
||||
run: npm run build
|
||||
|
||||
- name: Starting the server
|
||||
run: |
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start sweetHome
|
||||
@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
|
||||
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
|
||||
import FloatingSidebar from '@/app/components/FloatingSidebar';
|
||||
import {
|
||||
Globe,
|
||||
LogIn,
|
||||
@ -32,16 +35,19 @@ import {
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Users,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import './i18n/config';
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AuthService from "./services/AuthService";
|
||||
import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||
import "./i18n/config";
|
||||
import NotificationHandler from "./components/NotificationHandler";
|
||||
|
||||
export default function ClientLayout({ children }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const [currentLanguage, setCurrentLanguage] = useState('en');
|
||||
const [currentLanguage, setCurrentLanguage] = useState("en");
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
@ -51,47 +57,57 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const savedLanguage = localStorage.getItem('language') || 'en';
|
||||
const savedLanguage = localStorage.getItem("language") || "ar";
|
||||
setCurrentLanguage(savedLanguage);
|
||||
i18n.changeLanguage(savedLanguage);
|
||||
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
console.log('User data loaded:', userData);
|
||||
setUser(userData);
|
||||
}
|
||||
|
||||
if (savedLanguage === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = 'ar';
|
||||
if (savedLanguage === "ar") {
|
||||
document.documentElement.dir = "rtl";
|
||||
document.documentElement.lang = "ar";
|
||||
} else {
|
||||
document.documentElement.dir = 'ltr';
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dir = "ltr";
|
||||
document.documentElement.lang = "en";
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
// Re-read user from JWT on every route change (handles post-login)
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
phone: authUser.phone,
|
||||
role: AuthService.isAdmin() ? UserRole.ADMIN
|
||||
: AuthService.isOwner() ? UserRole.OWNER
|
||||
: UserRole.CUSTOMER,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const changeLanguage = (lng) => {
|
||||
i18n.changeLanguage(lng);
|
||||
setCurrentLanguage(lng);
|
||||
localStorage.setItem('language', lng);
|
||||
localStorage.setItem("language", lng);
|
||||
|
||||
if (lng === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = 'ar';
|
||||
if (lng === "ar") {
|
||||
document.documentElement.dir = "rtl";
|
||||
document.documentElement.lang = "ar";
|
||||
} else {
|
||||
document.documentElement.dir = 'ltr';
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dir = "ltr";
|
||||
document.documentElement.lang = "en";
|
||||
}
|
||||
};
|
||||
|
||||
@ -104,21 +120,25 @@ export default function ClientLayout({ children }) {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('user');
|
||||
AuthService.deleteToken();
|
||||
setUser(null);
|
||||
setShowUserMenu(false);
|
||||
window.location.href = '/';
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const isAuthPage = ['/login', '/register', '/forgot-password', '/auth/choose-role'].includes(pathname);
|
||||
const isAuthPage = [
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/auth/choose-role",
|
||||
].includes(pathname);
|
||||
|
||||
const isProfilePage = pathname === '/profile';
|
||||
const isProfilePage = pathname === "/profile";
|
||||
|
||||
const isOwner = user?.role === 'owner';
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
console.log('User role:', user?.role);
|
||||
console.log('Is Admin:', isAdmin);
|
||||
const isOwner = user?.role === UserRole.OWNER;
|
||||
const isAdmin = user?.role === UserRole.ADMIN;
|
||||
const isCustomer = user?.role === UserRole.CUSTOMER;
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
const getUserInitial = () => {
|
||||
if (user?.name) {
|
||||
@ -143,34 +163,74 @@ export default function ClientLayout({ children }) {
|
||||
{!isAuthPage && (
|
||||
<nav className="fixed top-0 left-0 right-0 bg-white/95 backdrop-blur-sm border-b border-gray-200 z-50 transition-all duration-300 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className={`flex justify-between items-center h-20 ${currentLanguage === 'ar' ? 'flex-row-reverse' : ''}`}>
|
||||
|
||||
<div
|
||||
className={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<div className="relative w-10 h-10">
|
||||
<div className="relative w-[150px] h-[60px]">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={t("logoAlt")}
|
||||
fill
|
||||
className="object-contain group-hover:scale-105 transition-transform duration-300"
|
||||
className="object-contain"
|
||||
priority
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-3xl font-bold text-gray-800 hidden md:block">
|
||||
{t("brandNamePart1")}<span className="text-amber-600">{t("brandNamePart2")}</span>
|
||||
{t("brandNamePart1")}
|
||||
<span className="text-amber-600">
|
||||
{t("brandNamePart2")}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<div className={`flex items-center space-x-1 ${currentLanguage === 'ar' ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||
<NavLink href="/">
|
||||
{t("home")}
|
||||
</NavLink>
|
||||
<NavLink href="/properties">
|
||||
{t("ourProducts")}
|
||||
</NavLink>
|
||||
<div
|
||||
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
|
||||
>
|
||||
{/* Download App Dropdown */}
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-3 py-2 text-gray-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z"/>
|
||||
<path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z"/>
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">تحميل التطبيق</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 translate-y-2 group-hover:translate-y-0">
|
||||
<div className="p-2">
|
||||
<a href="/files/SweetHome.apk" download
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="#16a34a" viewBox="0 0 16 16">
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">Android</p>
|
||||
<p className="text-xs text-green-600">تحميل APK</p>
|
||||
</div>
|
||||
</a>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg opacity-50 cursor-not-allowed">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#6b7280" viewBox="0 0 16 16">
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-400 text-sm">iOS</p>
|
||||
<p className="text-xs text-gray-400">قريباً</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavLink href="/">الرئيسية</NavLink>
|
||||
<NavLink href="/properties">عقاراتنا</NavLink>
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink href="/admin">
|
||||
@ -189,18 +249,18 @@ export default function ClientLayout({ children }) {
|
||||
عقاراتي
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/bookings">
|
||||
<NavLink href="/owner/reservations">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
الحجوزات
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/calendar">
|
||||
{/* <NavLink href="/owner/calendar">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
التقويم
|
||||
</span>
|
||||
</NavLink>
|
||||
</NavLink> */}
|
||||
<NavLink href="/owner/profits">
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
@ -228,14 +288,16 @@ export default function ClientLayout({ children }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
{/* <motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 360 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => changeLanguage(currentLanguage === 'en' ? 'ar' : 'en')}
|
||||
onClick={() =>
|
||||
changeLanguage(currentLanguage === "en" ? "ar" : "en")
|
||||
}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-all duration-200 ml-4"
|
||||
>
|
||||
<Globe className="w-5 h-5 text-gray-700" />
|
||||
</motion.button>
|
||||
</motion.button> */}
|
||||
|
||||
{user && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
@ -262,10 +324,14 @@ export default function ClientLayout({ children }) {
|
||||
{getUserInitial()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold">{user?.name || 'مستخدم'}</p>
|
||||
<p className="text-xs text-amber-100">{user?.email || ''}</p>
|
||||
<p className="font-bold">
|
||||
{user?.name || "مستخدم"}
|
||||
</p>
|
||||
<p className="text-xs text-amber-100">
|
||||
{user?.email || ""}
|
||||
</p>
|
||||
<p className="text-xs text-amber-100 mt-1">
|
||||
{isOwner ? 'مالك عقار' : isAdmin ? 'مدير النظام' : 'مستأجر'}
|
||||
{UserRoleLabels[user?.role] || 'زائر'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -280,7 +346,9 @@ export default function ClientLayout({ children }) {
|
||||
<UserCircle className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الملف الشخصي</p>
|
||||
<p className="text-xs text-gray-500">عرض وتعديل معلوماتك</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
عرض وتعديل معلوماتك
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -292,7 +360,9 @@ export default function ClientLayout({ children }) {
|
||||
<Heart className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">المفضلة</p>
|
||||
<p className="text-xs text-gray-500">العقارات المحفوظة</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
العقارات المحفوظة
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -308,7 +378,9 @@ export default function ClientLayout({ children }) {
|
||||
<Building className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">عقاراتي</p>
|
||||
<p className="text-xs text-gray-500">إدارة عقاراتك</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة عقاراتك
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -320,19 +392,23 @@ export default function ClientLayout({ children }) {
|
||||
<PlusCircle className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">إضافة عقار</p>
|
||||
<p className="text-xs text-gray-500">أضف عقاراً جديداً</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
أضف عقاراً جديداً
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/owner/bookings"
|
||||
href="/owner/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الحجوزات</p>
|
||||
<p className="text-xs text-gray-500">إدارة حجوزاتك</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة حجوزاتك
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -344,7 +420,9 @@ export default function ClientLayout({ children }) {
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">التقويم</p>
|
||||
<p className="text-xs text-gray-500">جدول توفر العقارات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
جدول توفر العقارات
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -356,7 +434,9 @@ export default function ClientLayout({ children }) {
|
||||
<TrendingUp className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الأرباح</p>
|
||||
<p className="text-xs text-gray-500">إحصائيات وأرباح</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إحصائيات وأرباح
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -374,7 +454,9 @@ export default function ClientLayout({ children }) {
|
||||
<Shield className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">لوحة التحكم</p>
|
||||
<p className="text-xs text-gray-500">إدارة المنصة</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة المنصة
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -386,7 +468,9 @@ export default function ClientLayout({ children }) {
|
||||
<Users className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">المستخدمين</p>
|
||||
<p className="text-xs text-gray-500">إدارة المستخدمين</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة المستخدمين
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -398,7 +482,9 @@ export default function ClientLayout({ children }) {
|
||||
<Building className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">العقارات</p>
|
||||
<p className="text-xs text-gray-500">إدارة جميع العقارات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة جميع العقارات
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -410,7 +496,9 @@ export default function ClientLayout({ children }) {
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الحجوزات</p>
|
||||
<p className="text-xs text-gray-500">إدارة الحجوزات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة الحجوزات
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -422,25 +510,29 @@ export default function ClientLayout({ children }) {
|
||||
<DollarSign className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">دفتر الحسابات</p>
|
||||
<p className="text-xs text-gray-500">إدارة المعاملات المالية</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة المعاملات المالية
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isOwner && !isAdmin && user && (
|
||||
{isCustomer && (
|
||||
<>
|
||||
<div className="border-t border-gray-100 my-2"></div>
|
||||
|
||||
<Link
|
||||
href="/tenant/bookings"
|
||||
href="/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">حجوزاتي</p>
|
||||
<p className="text-xs text-gray-500">عرض حجوزاتك</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
عرض حجوزاتك
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -455,7 +547,9 @@ export default function ClientLayout({ children }) {
|
||||
<LogOut className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="font-medium">تسجيل الخروج</p>
|
||||
<p className="text-xs text-red-400">إنهاء الجلسة الحالية</p>
|
||||
<p className="text-xs text-red-400">
|
||||
إنهاء الجلسة الحالية
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -470,7 +564,9 @@ export default function ClientLayout({ children }) {
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 360 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => changeLanguage(currentLanguage === 'en' ? 'ar' : 'en')}
|
||||
onClick={() =>
|
||||
changeLanguage(currentLanguage === "en" ? "ar" : "en")
|
||||
}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<Globe className="w-5 h-5 text-gray-700" />
|
||||
@ -516,6 +612,24 @@ export default function ClientLayout({ children }) {
|
||||
{t("ourProducts")}
|
||||
</MobileNavLink>
|
||||
|
||||
{/* Download App - Mobile */}
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">تحميل التطبيق</p>
|
||||
<a href="/files/SweetHome.apk" download onClick={closeMobileMenu}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-green-600 hover:bg-green-50 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Android - تحميل APK</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md text-gray-400 cursor-not-allowed opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
|
||||
</svg>
|
||||
<span className="text-sm">iOS - قريباً</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
|
||||
{isAdmin && (
|
||||
<MobileNavLink href="/admin" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
@ -527,25 +641,37 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<MobileNavLink href="/owner/properties" onClick={closeMobileMenu}>
|
||||
<MobileNavLink
|
||||
href="/owner/properties"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Building className="w-4 h-4" />
|
||||
عقاراتي
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink href="/owner/bookings" onClick={closeMobileMenu}>
|
||||
<MobileNavLink
|
||||
href="/owner/reservations"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
الحجوزات
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink href="/owner/calendar" onClick={closeMobileMenu}>
|
||||
<MobileNavLink
|
||||
href="/owner/calendar"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
التقويم
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink href="/owner/profits" onClick={closeMobileMenu}>
|
||||
<MobileNavLink
|
||||
href="/owner/profits"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
الأرباح
|
||||
@ -563,7 +689,10 @@ export default function ClientLayout({ children }) {
|
||||
تسجيل الدخول
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink href="/auth/choose-role" onClick={closeMobileMenu}>
|
||||
<MobileNavLink
|
||||
href="/auth/choose-role"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
إنشاء حساب
|
||||
@ -577,16 +706,27 @@ export default function ClientLayout({ children }) {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<main className={`${!isAuthPage && !isProfilePage ? 'pt-20' : ''} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === 'ar' ? 'text-right' : 'text-left'}`}>
|
||||
<main
|
||||
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}
|
||||
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
|
||||
</FavoritesProvider>
|
||||
</NotificationsProvider>
|
||||
</main>
|
||||
|
||||
{!isAuthPage && !isProfilePage && (
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className={`grid grid-cols-1 md:grid-cols-4 gap-8 ${currentLanguage === 'ar' ? 'text-right' : 'text-left'}`}>
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-4 gap-8 ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className={`flex items-center ${currentLanguage === 'ar' ? 'flex-row-reverse' : 'space-x-3'}`}>
|
||||
<div
|
||||
className={`flex items-center ${currentLanguage === "ar" ? "flex-row-reverse" : "space-x-3"}`}
|
||||
>
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
@ -597,7 +737,10 @@ export default function ClientLayout({ children }) {
|
||||
/>
|
||||
</div>
|
||||
<span className="text-3xl font-bold">
|
||||
{t("brandNamePart1")}<span className="text-amber-400">{t("brandNamePart2")}</span>
|
||||
{t("brandNamePart1")}
|
||||
<span className="text-amber-400">
|
||||
{t("brandNamePart2")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
@ -605,21 +748,32 @@ export default function ClientLayout({ children }) {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("quickLinks")}</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("quickLinks")}
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link href="/" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
||||
>
|
||||
{t("home")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/properties" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||
<Link
|
||||
href="/properties"
|
||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
||||
>
|
||||
{t("ourProducts")}
|
||||
</Link>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<Link href="/admin" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
||||
>
|
||||
الإدارة
|
||||
</Link>
|
||||
</li>
|
||||
@ -631,7 +785,7 @@ export default function ClientLayout({ children }) {
|
||||
<ul className="space-y-3 text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="w-5 h-5" />
|
||||
<span>{t("phone")}</span>
|
||||
<span dir="ltr" className="text-right">{t("phone")}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
@ -641,11 +795,14 @@ export default function ClientLayout({ children }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t border-gray-800 text-center text-gray-400 text-sm">
|
||||
<p>© {currentYear} {t("copyright")}. {t("allRightsReserved")}</p>
|
||||
<p>
|
||||
© {currentYear} {t("copyright")}. {t("allRightsReserved")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
<NotificationHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
app/admin/add-admin/page.js
Normal file
113
app/admin/add-admin/page.js
Normal file
@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AddAdminPage() {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [formState, setFormState] = useState({ fullName: '', email: '', password: '' });
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleChange = (field) => (event) => {
|
||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
setSaved(true);
|
||||
console.log('Add admin payload', formState);
|
||||
};
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mt-3">إضافة مدير جديد</h1>
|
||||
<p className="text-slate-500 mt-2">انشئ حساب مسؤول جديد مع صلاحيات الإدارة.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[1.5fr_0.8fr]">
|
||||
<section className="rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<h2 className="text-xl font-semibold mb-6">بيانات المدير</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">الاسم الكامل</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.fullName}
|
||||
onChange={handleChange('fullName')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="مثال: محمد الأحمد"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">البريد الإلكتروني</span>
|
||||
<input
|
||||
type="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange('email')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">كلمة المرور</span>
|
||||
<input
|
||||
type="password"
|
||||
value={formState.password}
|
||||
onChange={handleChange('password')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="inline-flex items-center justify-center rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||
حفظ المدير الجديد
|
||||
</button>
|
||||
</form>
|
||||
{saved && (
|
||||
<div className="mt-6 rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||
تم حفظ بيانات المدير بنجاح
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
app/admin/error.js
Normal file
27
app/admin/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/admin/loading.js
Normal file
14
app/admin/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,17 @@
|
||||
// app/admin/page.js (محدث)
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Home,
|
||||
Calendar,
|
||||
Users,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Bell
|
||||
Bell,
|
||||
Frown
|
||||
} from 'lucide-react';
|
||||
import DashboardStats from '../components/admin/DashboardStats';
|
||||
import PropertiesTable from '../components/admin/PropertiesTable';
|
||||
@ -20,6 +20,7 @@ import UsersList from '../components/admin/UsersList';
|
||||
import LedgerBook from '../components/admin/LedgerBook';
|
||||
import AddPropertyForm from '../components/admin/AddPropertyForm';
|
||||
import { PropertyProvider } from '../contexts/PropertyContext';
|
||||
import AuthService from '../services/AuthService';
|
||||
import '../i18n/config';
|
||||
|
||||
export default function AdminPage() {
|
||||
@ -27,6 +28,54 @@ export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [showAddProperty, setShowAddProperty] = useState(false);
|
||||
const [notifications, setNotifications] = useState(3);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
// ─── 404 for non-admins ───
|
||||
if (checked && !isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<svg viewBox="0 0 200 180" className="w-72 h-52 mx-auto">
|
||||
<circle cx="100" cy="70" r="60" fill="#fef3c7" />
|
||||
<circle cx="80" cy="60" r="8" fill="#92400e" />
|
||||
<circle cx="120" cy="60" r="8" fill="#92400e" />
|
||||
<path d="M80 85 Q100 75 120 85" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
<text x="100" y="140" textAnchor="middle" fontSize="16" fontWeight="bold" fill="#6b7280">عذراً!</text>
|
||||
<text x="100" y="160" textAnchor="middle" fontSize="12" fill="#9ca3af">الصفحة غير موجودة</text>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">404 - الصفحة غير موجودة</h2>
|
||||
<p className="text-gray-500 mb-8">عذراً، لا يمكنك الوصول إلى هذه الصفحة</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home },
|
||||
@ -34,7 +83,7 @@ export default function AdminPage() {
|
||||
{ id: 'bookings', label: 'طلبات الحجز', icon: Calendar, badge: notifications },
|
||||
{ id: 'users', label: 'المستخدمين', icon: Users },
|
||||
{ id: 'ledger', label: 'دفتر الحسابات', icon: DollarSign },
|
||||
{ id: 'reports', label: 'التقارير', icon: TrendingUp }
|
||||
// { id: 'reports', label: 'التقارير', icon: TrendingUp }
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
85
app/admin/privacy/page.js
Normal file
85
app/admin/privacy/page.js
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
const initialPolicy = `1. نحترم خصوصيتك ونلتزم بحماية بياناتك الشخصية.
|
||||
2. يتم استخدام المعلومات لتحسين تجربة المستخدم وتأمين الخدمة.
|
||||
3. لا نشارك البيانات مع أطراف خارجية بدون موافقتك.
|
||||
4. يمكنك طلب حذف بياناتك من النظام في أي وقت.`;
|
||||
|
||||
export default function PrivacyPolicyAdminPage() {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [policyText, setPolicyText] = useState(initialPolicy);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = (event) => {
|
||||
event.preventDefault();
|
||||
setSaved(true);
|
||||
console.log('Privacy policy updated:', policyText);
|
||||
};
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||
<p className="text-gray-600 mb-6">هذه الصفحة لتحرير سياسة الخصوصية ولا يمكن الوصول إليها إلا للمدير.</p>
|
||||
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||
<p className="text-slate-500 mt-2">قم بتحديث نص سياسة الخصوصية</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-6 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">نص سياسة الخصوصية</label>
|
||||
<textarea
|
||||
value={policyText}
|
||||
onChange={(e) => setPolicyText(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4 text-slate-700 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<button type="submit" className="rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||
حفظ السياسة
|
||||
</button>
|
||||
</div>
|
||||
{saved && (
|
||||
<div className="rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||
تمت حفظ سياسة الخصوصية بنجاح
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
app/auth/choose-role/error.js
Normal file
27
app/auth/choose-role/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/auth/choose-role/loading.js
Normal file
14
app/auth/choose-role/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
app/components/FloatingSidebar.js
Normal file
190
app/components/FloatingSidebar.js
Normal file
@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { Heart, Bell, CreditCard, Shield, UserPlus } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
export default function FloatingSidebar({ isRTL, isAdmin }) {
|
||||
const { favorites } = useFavorites();
|
||||
const { unreadCount } = useNotifications();
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
let timeoutId = null;
|
||||
|
||||
const showTooltip = (id) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
setTooltip(id);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(timeoutId);
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const side = isRTL ? 'left' : 'right';
|
||||
const positionStyle = {
|
||||
[side]: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
initial: { opacity: 0, x: isRTL ? -20 : 20 },
|
||||
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: 'easeOut' } },
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
rest: { scale: 1, backgroundColor: 'rgba(255,255,255,0)' },
|
||||
hover: { scale: 1.05, backgroundColor: 'rgba(245,158,11,0.1)', transition: { duration: 0.2 } },
|
||||
tap: { scale: 0.95 },
|
||||
};
|
||||
|
||||
const renderTooltip = (id, label) => {
|
||||
if (tooltip !== id) return null;
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||
>
|
||||
<span className="relative">
|
||||
{label}
|
||||
<span
|
||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed z-50"
|
||||
style={positionStyle}
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/60 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/95">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('addAdmin')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/admin/add-admin"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl bg-amber-50 border border-amber-200 text-amber-600 hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-6 h-6" />
|
||||
</Link>
|
||||
{renderTooltip('addAdmin', 'إضافة أدمن')}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('editPrivacy')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/admin/privacy"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Shield className="w-6 h-6" />
|
||||
</Link>
|
||||
{renderTooltip('editPrivacy', 'تعديل سياسة الخصوصية')}
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('favorites')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/favorites"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
{favorites.length > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
{favorites.length}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('favorites', 'المفضلة')}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('notifications')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
{unreadCount}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('notifications', 'الإشعارات')}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('payments')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/payments"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
</Link>
|
||||
{renderTooltip('payments', 'المدفوعات')}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
165
app/components/NotificationHandler.js
Normal file
165
app/components/NotificationHandler.js
Normal file
@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { initializeApp, getApps } from "firebase/app";
|
||||
import { getMessaging, getToken, onMessage } from "firebase/messaging";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
export default function NotificationHandler() {
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
function checkAuth() {
|
||||
if (initialized.current) return;
|
||||
|
||||
if (!AuthService.getToken()) return;
|
||||
initialized.current = true;
|
||||
|
||||
if ("Notification" in window) {
|
||||
if (Notification.permission === "default") {
|
||||
setShowPrompt(true);
|
||||
} else if (Notification.permission === "granted") {
|
||||
setupFCM();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Also check when auth token changes (login via client-side navigation)
|
||||
const interval = setInterval(() => {
|
||||
if (!initialized.current && AuthService.getToken()) {
|
||||
checkAuth();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Check on route change (visibility)
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") checkAuth();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function setupFCM() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
|
||||
const messaging = getMessaging(app);
|
||||
|
||||
const fcmToken = await getToken(messaging, {
|
||||
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
|
||||
if (fcmToken) {
|
||||
console.log("[FCM] Token:", fcmToken.substring(0, 20) + "...");
|
||||
|
||||
const authToken = AuthService.getToken();
|
||||
if (authToken) {
|
||||
const apiBase = "https://45.93.137.91.nip.io/api";
|
||||
await fetch(`${apiBase}/User/SetFCMToken`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token: fcmToken, deviceType: 2 }),
|
||||
});
|
||||
console.log("[FCM] Token sent to backend");
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(messaging, (payload) => {
|
||||
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
|
||||
const body = payload.notification?.body || payload.data?.body || "";
|
||||
setNotification({ title, body });
|
||||
setTimeout(() => setNotification(null), 5000);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[FCM] Setup error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable() {
|
||||
setShowPrompt(false);
|
||||
|
||||
// This MUST be synchronous from a user gesture
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log("[FCM] Permission result:", permission);
|
||||
|
||||
if (permission === "granted") {
|
||||
await setupFCM();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPrompt && (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">🔔</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-gray-900 text-sm">تفعيل الإشعارات</p>
|
||||
<p className="text-gray-600 text-sm mt-0.5">اسمح بالإشعارات للبقاء على اطلاع بحجوزاتك وعروضنا.</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تفعيل
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPrompt(false)}
|
||||
className="px-4 py-1.5 text-gray-500 text-sm hover:text-gray-700 transition-colors"
|
||||
>
|
||||
لاحقاً
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification && (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999] animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">🏠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-gray-900 text-sm">{notification.title}</p>
|
||||
<p className="text-gray-600 text-sm mt-0.5">{notification.body}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className="text-gray-400 hover:text-gray-600 flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
||||
import { COMMISSION_TYPE, CITIES } from '@/app/utils/constants';
|
||||
import { CommissionType, CitiesList } from '@/app/enums';
|
||||
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
||||
|
||||
export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
@ -25,7 +25,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
|
||||
dailyPrice: 0,
|
||||
commissionRate: 5,
|
||||
commissionType: COMMISSION_TYPE.FROM_OWNER,
|
||||
commissionType: CommissionType.FROM_OWNER,
|
||||
|
||||
securityDeposit: 0,
|
||||
|
||||
@ -38,10 +38,20 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
const [selectedFeatures, setSelectedFeatures] = useState([]);
|
||||
|
||||
const featuresList = [
|
||||
'swimmingPool', 'privateGarden', 'parking', 'superLuxFinish',
|
||||
'equippedKitchen', 'centralHeating', 'balcony', 'securitySystem',
|
||||
'largeGarden', 'receptionHall', 'maidRoom', 'garage',
|
||||
'seaView', 'centralAC', 'fruitGarden', 'storage'
|
||||
'مسبح',
|
||||
'حديقة خاصة',
|
||||
'موقف سيارات',
|
||||
'مطبخ مجهز',
|
||||
'تدفئة مركزية',
|
||||
'بلكونة',
|
||||
'نظام أمني',
|
||||
'حديقة كبيرة',
|
||||
'صالة استقبال',
|
||||
'غرفة خادمة',
|
||||
'كراج',
|
||||
'إطلالة بحرية',
|
||||
'تكييف مركزي',
|
||||
'مخزن'
|
||||
];
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@ -76,11 +86,11 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
const commission = (dailyPrice * commissionRate) / 100;
|
||||
|
||||
switch(commissionType) {
|
||||
case COMMISSION_TYPE.FROM_TENANT:
|
||||
case CommissionType.FROM_TENANT:
|
||||
return dailyPrice + commission;
|
||||
case COMMISSION_TYPE.FROM_OWNER:
|
||||
case CommissionType.FROM_OWNER:
|
||||
return dailyPrice;
|
||||
case COMMISSION_TYPE.FROM_BOTH:
|
||||
case CommissionType.FROM_BOTH:
|
||||
return dailyPrice + (commission / 2);
|
||||
default:
|
||||
return dailyPrice;
|
||||
@ -121,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
required
|
||||
>
|
||||
<option value="">اختر المدينة</option>
|
||||
{Object.values(CITIES).map(city => (
|
||||
{CitiesList.map(city => (
|
||||
<option key={city} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
@ -222,8 +232,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
<input
|
||||
type="radio"
|
||||
name="commissionType"
|
||||
value={COMMISSION_TYPE.FROM_OWNER}
|
||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_OWNER}
|
||||
value={CommissionType.FROM_OWNER}
|
||||
checked={formData.commissionType === CommissionType.FROM_OWNER}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
/>
|
||||
<span>من المالك</span>
|
||||
@ -232,8 +242,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
<input
|
||||
type="radio"
|
||||
name="commissionType"
|
||||
value={COMMISSION_TYPE.FROM_TENANT}
|
||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_TENANT}
|
||||
value={CommissionType.FROM_TENANT}
|
||||
checked={formData.commissionType === CommissionType.FROM_TENANT}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
/>
|
||||
<span>من المستأجر</span>
|
||||
@ -242,8 +252,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
<input
|
||||
type="radio"
|
||||
name="commissionType"
|
||||
value={COMMISSION_TYPE.FROM_BOTH}
|
||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_BOTH}
|
||||
value={CommissionType.FROM_BOTH}
|
||||
checked={formData.commissionType === CommissionType.FROM_BOTH}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
/>
|
||||
<span>من الاثنين</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,9 +13,15 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
Shield
|
||||
Shield,
|
||||
FileText,
|
||||
Printer,
|
||||
X,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { formatCurrency } from '@/app/utils/calculations';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export default function LedgerBook({ userType = 'admin' }) {
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
@ -28,6 +34,7 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
securityDeposits: 0,
|
||||
commissionEarned: 0
|
||||
});
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions();
|
||||
@ -144,30 +151,239 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const exportToExcel = () => {
|
||||
const csvContent = [
|
||||
['التاريخ', 'الوصف', 'من', 'إلى', 'المبلغ', 'العمولة', 'الحالة'],
|
||||
...filteredTransactions.map(t => [
|
||||
t.date,
|
||||
t.description,
|
||||
t.fromUser,
|
||||
t.toUser,
|
||||
t.amount,
|
||||
t.commission,
|
||||
t.status
|
||||
])
|
||||
].map(row => row.join(',')).join('\n');
|
||||
const exportToExcel = async () => {
|
||||
if (filteredTransactions.length === 0) {
|
||||
toast.error('لا توجد معاملات للتصدير');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ledger_${new Date().toISOString()}.csv`;
|
||||
a.click();
|
||||
setIsExporting(true);
|
||||
toast.loading('جاري تصدير البيانات...', { id: 'export' });
|
||||
|
||||
try {
|
||||
const exportData = filteredTransactions.map(t => ({
|
||||
'رقم العملية': t.id,
|
||||
'التاريخ': t.date,
|
||||
'نوع العملية': t.type === 'rent_payment' ? 'دفعة إيجار' :
|
||||
t.type === 'security_deposit' ? 'سلفة ضمان' :
|
||||
t.type === 'commission' ? 'عمولة' : 'أخرى',
|
||||
'الوصف': t.description,
|
||||
'من': t.fromUser,
|
||||
'إلى': t.toUser,
|
||||
'المبلغ (ل.س)': t.amount,
|
||||
'العمولة (ل.س)': t.commission || 0,
|
||||
'الحالة': t.status === 'completed' ? 'مكتمل' :
|
||||
t.status === 'pending' ? 'معلق' :
|
||||
t.status === 'pending_refund' ? 'بإنتظار الاسترداد' : 'مؤكد',
|
||||
}));
|
||||
|
||||
const summaryRow = {
|
||||
'رقم العملية': '',
|
||||
'التاريخ': '',
|
||||
'نوع العملية': '',
|
||||
'الوصف': '',
|
||||
'من': '',
|
||||
'إلى': '',
|
||||
'المبلغ (ل.س)': summary.totalRevenue,
|
||||
'العمولة (ل.س)': summary.commissionEarned,
|
||||
'الحالة': ''
|
||||
};
|
||||
|
||||
exportData.push(summaryRow);
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
|
||||
const columnWidths = [
|
||||
{ wch: 12 }, // رقم العملية
|
||||
{ wch: 12 }, // التاريخ
|
||||
{ wch: 12 }, // نوع العملية
|
||||
{ wch: 30 }, // الوصف
|
||||
{ wch: 20 }, // من
|
||||
{ wch: 20 }, // إلى
|
||||
{ wch: 15 }, // المبلغ
|
||||
{ wch: 15 }, // العمولة
|
||||
{ wch: 12 }, // الحالة
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref']);
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const address = XLSX.utils.encode_col(C) + '1';
|
||||
if (!worksheet[address]) continue;
|
||||
worksheet[address].s = {
|
||||
font: { bold: true, sz: 12 },
|
||||
fill: { fgColor: { rgb: "F59E0B" } },
|
||||
alignment: { horizontal: "center", vertical: "center" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'دفتر الحسابات');
|
||||
|
||||
const fileName = `دفتر_الحسابات_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
|
||||
toast.success(`تم تصدير ${filteredTransactions.length} معاملة بنجاح!`, { id: 'export' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting to Excel:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير البيانات', { id: 'export' });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const printReport = () => {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>تقرير دفتر الحسابات</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Cairo', Arial, sans-serif;
|
||||
padding: 20px;
|
||||
direction: rtl;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #f59e0b;
|
||||
}
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.summary-card {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #f59e0b;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
th {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="title">تقرير دفتر الحسابات</div>
|
||||
<div class="subtitle">الفترة: ${dateRange.start || 'بداية السجلات'} - ${dateRange.end || 'حتى الآن'}</div>
|
||||
<div class="subtitle">تاريخ التقرير: ${new Date().toLocaleDateString('ar-SA')}</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div>إجمالي الإيرادات</div>
|
||||
<div class="summary-value">${formatCurrency(summary.totalRevenue)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>أرباح المنصة</div>
|
||||
<div class="summary-value">${formatCurrency(summary.commissionEarned)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>سلف الضمان</div>
|
||||
<div class="summary-value">${formatCurrency(summary.securityDeposits)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>المدفوعات المعلقة</div>
|
||||
<div class="summary-value">${formatCurrency(summary.pendingPayments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>التاريخ</th>
|
||||
<th>الوصف</th>
|
||||
<th>من</th>
|
||||
<th>إلى</th>
|
||||
<th>المبلغ</th>
|
||||
<th>العمولة</th>
|
||||
<th>الحالة</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${filteredTransactions.map(t => `
|
||||
<tr>
|
||||
<td>${t.date}</td>
|
||||
<td>${t.description}</td>
|
||||
<td>${t.fromUser}</td>
|
||||
<td>${t.toUser}</td>
|
||||
<td>${formatCurrency(t.amount)}</td>
|
||||
<td>${t.commission ? formatCurrency(t.commission) : '-'}</td>
|
||||
<td>${t.status === 'completed' ? 'مكتمل' : t.status === 'pending' ? 'معلق' : 'بإنتظار الرد'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<p>تقرير صادر عن نظام SweetHome لإدارة العقارات</p>
|
||||
<p>جميع الحقوق محفوظة © ${new Date().getFullYear()}</p>
|
||||
</div>
|
||||
|
||||
<div class="no-print" style="text-align: center; margin-top: 20px;">
|
||||
<button onclick="window.print()" style="padding: 10px 20px; background: #f59e0b; color: white; border: none; border-radius: 8px; cursor: pointer;">
|
||||
طباعة التقرير
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -224,13 +440,13 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
<div className="bg-white rounded-xl p-5 shadow-sm border">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث في المعاملات..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -239,27 +455,65 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-500 self-center">إلى</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
|
||||
className="px-3 py-2 border rounded-lg"
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg flex items-center gap-2 hover:bg-green-700"
|
||||
disabled={isExporting || filteredTransactions.length === 0}
|
||||
className="px-5 py-3 bg-green-600 text-white rounded-xl flex items-center gap-2 hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
تصدير
|
||||
{isExporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
جاري التصدير...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير Excel
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={printReport}
|
||||
disabled={filteredTransactions.length === 0}
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-xl flex items-center gap-2 hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
طباعة
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(dateRange.start || dateRange.end || searchTerm) && (
|
||||
<div className="mt-4 pt-4 border-t flex justify-between items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="font-medium">{filteredTransactions.length}</span> معاملة من إجمالي <span className="font-medium">{transactions.length}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDateRange({ start: '', end: '' });
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className="text-sm text-red-500 hover:text-red-600 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
إلغاء الفلترة
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@ -307,14 +561,14 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
<span className="text-sm">{transaction.toUser}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-bold">
|
||||
<td className="px-6 py-4 text-sm font-bold text-green-600">
|
||||
{formatCurrency(transaction.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-amber-600">
|
||||
{transaction.commission ? formatCurrency(transaction.commission) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
transaction.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
transaction.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
@ -344,6 +598,7 @@ export default function LedgerBook({ userType = 'admin' }) {
|
||||
أرصدة المستأجرين
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-500 text-sm">لا توجد أرصدة حالياً</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@ -12,14 +12,404 @@ import {
|
||||
Square,
|
||||
DollarSign,
|
||||
Percent,
|
||||
MoreVertical
|
||||
MoreVertical,
|
||||
X,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
User,
|
||||
Home,
|
||||
Building,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">تأكيد الحذف</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
هل أنت متأكد من حذف العقار: <span className="font-bold text-gray-700">"{propertyTitle}"</span>؟
|
||||
</p>
|
||||
<p className="text-xs text-red-500 mt-1">هذا الإجراء لا يمكن التراجع عنه</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
نعم، احذف
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyViewModal = ({ property, isOpen, onClose }) => {
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{property.title}</h2>
|
||||
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Home className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.type === 'villa' ? 'فيلا' : property.type === 'apartment' ? 'شقة' : 'بيت'}</div>
|
||||
<div className="text-xs text-gray-500">نوع العقار</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">السعر اليومي</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Percent className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.commission}%</div>
|
||||
<div className="text-xs text-gray-500">نسبة العمولة</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Calendar className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bookings || 0}</div>
|
||||
<div className="text-xs text-gray-500">عدد الحجوزات</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-amber-500" />
|
||||
الموقع
|
||||
</h3>
|
||||
<p className="text-gray-700">{property.location}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">المواصفات</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.bedrooms}</div>
|
||||
<div className="text-xs text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.bathrooms}</div>
|
||||
<div className="text-xs text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.area}</div>
|
||||
<div className="text-xs text-gray-500">م²</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2">
|
||||
<Percent className="w-5 h-5" />
|
||||
معلومات العمولة
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">نسبة العمولة</label>
|
||||
<div className="font-bold text-amber-600">{property.commission}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">مصدر العمولة</label>
|
||||
<div className="font-bold text-amber-600">{property.commissionType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">قيمة العمولة</label>
|
||||
<div className="font-bold text-amber-600">
|
||||
{formatCurrency((property.price * property.commission) / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">حالة العقار</label>
|
||||
<div className={`inline-block px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
property.status === 'available'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyEditModal = ({ property, isOpen, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({ ...property });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
onSave(formData);
|
||||
setIsSaving(false);
|
||||
onClose();
|
||||
toast.success('تم تحديث العقار بنجاح');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">تعديل العقار</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-amber-100 text-sm mt-1">يمكنك تعديل معلومات العقار</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
اسم العقار
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع العقار
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({...formData, type: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="villa">فيلا</option>
|
||||
<option value="apartment">شقة</option>
|
||||
<option value="house">بيت</option>
|
||||
<option value="studio">استوديو</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
الموقع
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({...formData, location: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
السعر اليومي (ل.س)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({...formData, price: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نسبة العمولة (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.commission}
|
||||
onChange={(e) => setFormData({...formData, commission: parseFloat(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
مصدر العمولة
|
||||
</label>
|
||||
<select
|
||||
value={formData.commissionType}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="من المالك">من المالك</option>
|
||||
<option value="من المستأجر">من المستأجر</option>
|
||||
<option value="من الاثنين">من الاثنين</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الغرف
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.bedrooms}
|
||||
onChange={(e) => setFormData({...formData, bedrooms: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الحمامات
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.bathrooms}
|
||||
onChange={(e) => setFormData({...formData, bathrooms: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
المساحة (م²)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.area}
|
||||
onChange={(e) => setFormData({...formData, area: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حالة العقار
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="available">متاح</option>
|
||||
<option value="booked">محجوز</option>
|
||||
<option value="maintenance">صيانة</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex-1 bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MoreActionsMenu = ({ property, isOpen, onClose, onViewBookings, onViewReports }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="absolute left-0 mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50"
|
||||
>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function PropertiesTable() {
|
||||
const [properties, setProperties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'luxuryVillaDamascus',
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
type: 'villa',
|
||||
location: 'دمشق, المزة',
|
||||
price: 500000,
|
||||
@ -33,7 +423,7 @@ export default function PropertiesTable() {
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'modernApartmentAleppo',
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
type: 'apartment',
|
||||
location: 'حلب, الشهباء',
|
||||
price: 250000,
|
||||
@ -47,6 +437,11 @@ export default function PropertiesTable() {
|
||||
}
|
||||
]);
|
||||
|
||||
const [viewModal, setViewModal] = useState({ isOpen: false, property: null });
|
||||
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, property: null });
|
||||
const [moreMenu, setMoreMenu] = useState({ isOpen: false, property: null, anchorEl: null });
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
||||
};
|
||||
@ -71,8 +466,50 @@ export default function PropertiesTable() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleView = (property) => {
|
||||
setViewModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const handleEdit = (property) => {
|
||||
setEditModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const handleDelete = (property) => {
|
||||
setDeleteModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteModal.property) {
|
||||
setProperties(prev => prev.filter(p => p.id !== deleteModal.property.id));
|
||||
setDeleteModal({ isOpen: false, property: null });
|
||||
toast.success('تم حذف العقار بنجاح');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = (updatedProperty) => {
|
||||
setProperties(prev => prev.map(p =>
|
||||
p.id === updatedProperty.id ? updatedProperty : p
|
||||
));
|
||||
toast.success('تم تحديث العقار بنجاح');
|
||||
};
|
||||
|
||||
const handleMoreClick = (event, property) => {
|
||||
event.stopPropagation();
|
||||
setMoreMenu({ isOpen: true, property, anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
const handleViewBookings = (property) => {
|
||||
toast.success(`جاري عرض حجوزات ${property.title}`);
|
||||
};
|
||||
|
||||
const handleViewReports = (property) => {
|
||||
toast.success(`جاري عرض تقرير أرباح ${property.title}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
@ -97,7 +534,11 @@ export default function PropertiesTable() {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{property.title}</div>
|
||||
<div className="text-xs text-gray-500">{property.type}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{property.type === 'villa' ? 'فيلا' :
|
||||
property.type === 'apartment' ? 'شقة' :
|
||||
property.type === 'house' ? 'بيت' : 'استوديو'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
@ -125,20 +566,38 @@ export default function PropertiesTable() {
|
||||
<td className="px-4 py-3">
|
||||
{getStatusBadge(property.status)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3 relative">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button className="p-1 hover:bg-blue-100 rounded text-blue-600">
|
||||
<button
|
||||
onClick={() => handleView(property)}
|
||||
className="p-1 hover:bg-blue-100 rounded text-blue-600 transition-colors"
|
||||
title="عرض التفاصيل"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-amber-100 rounded text-amber-600">
|
||||
<button
|
||||
onClick={() => handleEdit(property)}
|
||||
className="p-1 hover:bg-amber-100 rounded text-amber-600 transition-colors"
|
||||
title="تعديل العقار"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-red-100 rounded text-red-600">
|
||||
<button
|
||||
onClick={() => handleDelete(property)}
|
||||
className="p-1 hover:bg-red-100 rounded text-red-600 transition-colors"
|
||||
title="حذف العقار"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-gray-100 rounded">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
{moreMenu.isOpen && moreMenu.property?.id === property.id && (
|
||||
<MoreActionsMenu
|
||||
property={property}
|
||||
isOpen={moreMenu.isOpen}
|
||||
onClose={() => setMoreMenu({ isOpen: false, property: null, anchorEl: null })}
|
||||
onViewBookings={handleViewBookings}
|
||||
onViewReports={handleViewReports}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
@ -152,6 +611,26 @@ export default function PropertiesTable() {
|
||||
<p className="text-gray-500">لا توجد عقارات مضافة بعد</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PropertyViewModal
|
||||
property={viewModal.property}
|
||||
isOpen={viewModal.isOpen}
|
||||
onClose={() => setViewModal({ isOpen: false, property: null })}
|
||||
/>
|
||||
|
||||
<PropertyEditModal
|
||||
property={editModal.property}
|
||||
isOpen={editModal.isOpen}
|
||||
onClose={() => setEditModal({ isOpen: false, property: null })}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, property: null })}
|
||||
onConfirm={confirmDelete}
|
||||
propertyTitle={deleteModal.property?.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
@ -11,8 +11,445 @@ import {
|
||||
DollarSign,
|
||||
Search,
|
||||
Filter,
|
||||
Eye
|
||||
Eye,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
Users,
|
||||
Award,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const FilterDialog = ({ isOpen, onClose, filters, onApplyFilters, onResetFilters }) => {
|
||||
const [localFilters, setLocalFilters] = useState({ ...filters });
|
||||
|
||||
const identityTypes = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'syrian', label: 'هوية سورية' },
|
||||
{ id: 'passport', label: 'جواز سفر' }
|
||||
];
|
||||
|
||||
const bookingRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: '0-5', label: '0 - 5 حجوزات' },
|
||||
{ id: '5-10', label: '5 - 10 حجوزات' },
|
||||
{ id: '10-20', label: '10 - 20 حجوزات' },
|
||||
{ id: '20+', label: 'أكثر من 20 حجز' }
|
||||
];
|
||||
|
||||
const spendingRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: '0-500000', label: 'أقل من 500,000 ل.س' },
|
||||
{ id: '500000-1000000', label: '500,000 - 1,000,000 ل.س' },
|
||||
{ id: '1000000-5000000', label: '1,000,000 - 5,000,000 ل.س' },
|
||||
{ id: '5000000+', label: 'أكثر من 5,000,000 ل.س' }
|
||||
];
|
||||
|
||||
const dateRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'today', label: 'اليوم' },
|
||||
{ id: 'week', label: 'آخر 7 أيام' },
|
||||
{ id: 'month', label: 'آخر 30 يوم' },
|
||||
{ id: 'year', label: 'آخر 12 شهر' }
|
||||
];
|
||||
|
||||
const applyFilters = () => {
|
||||
onApplyFilters(localFilters);
|
||||
onClose();
|
||||
toast.success('تم تطبيق الفلاتر بنجاح');
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
const resetData = {
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
};
|
||||
setLocalFilters(resetData);
|
||||
onResetFilters();
|
||||
onClose();
|
||||
toast.success('تم إعادة تعيين الفلاتر');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
تصفية متقدمة
|
||||
</h2>
|
||||
<p className="text-blue-100 text-sm mt-1">حدد معايير التصفية المطلوبة</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع الهوية
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{identityTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setLocalFilters({...localFilters, identityType: type.id})}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
localFilters.identityType === type.id
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الحجوزات
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="من"
|
||||
value={localFilters.minBookings}
|
||||
onChange={(e) => setLocalFilters({...localFilters, minBookings: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="إلى"
|
||||
value={localFilters.maxBookings}
|
||||
onChange={(e) => setLocalFilters({...localFilters, maxBookings: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bookingRanges.slice(1).map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => {
|
||||
const [min, max] = range.id.split('-');
|
||||
setLocalFilters({
|
||||
...localFilters,
|
||||
minBookings: min,
|
||||
maxBookings: max === '5' ? '5' : max === '10' ? '10' : max === '20' ? '20' : '1000'
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
إجمالي الإنفاق (ل.س)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="من"
|
||||
value={localFilters.minSpending}
|
||||
onChange={(e) => setLocalFilters({...localFilters, minSpending: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="إلى"
|
||||
value={localFilters.maxSpending}
|
||||
onChange={(e) => setLocalFilters({...localFilters, maxSpending: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{spendingRanges.slice(1).map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => {
|
||||
const [min, max] = range.id.split('-');
|
||||
setLocalFilters({
|
||||
...localFilters,
|
||||
minSpending: min,
|
||||
maxSpending: max === '500000' ? '500000' : max === '1000000' ? '1000000' : max === '5000000' ? '5000000' : '999999999'
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
فترة التسجيل
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{dateRanges.map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => setLocalFilters({...localFilters, dateRange: range.id})}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
localFilters.dateRange === range.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localFilters.activeOnly}
|
||||
onChange={(e) => setLocalFilters({...localFilters, activeOnly: e.target.checked, inactiveOnly: false})}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">مستخدمون لديهم حجوزات نشطة فقط</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localFilters.inactiveOnly}
|
||||
onChange={(e) => setLocalFilters({...localFilters, inactiveOnly: e.target.checked, activeOnly: false})}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">مستخدمون بدون حجوزات نشطة</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إعادة تعيين
|
||||
</button>
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
تطبيق الفلاتر
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserDetailsModal = ({ user, isOpen, onClose }) => {
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const userBookings = [
|
||||
{
|
||||
id: 'BK001',
|
||||
property: 'فيلا فاخرة في المزة',
|
||||
startDate: '2024-03-10',
|
||||
endDate: '2024-03-15',
|
||||
amount: 2500000,
|
||||
status: 'completed'
|
||||
},
|
||||
{
|
||||
id: 'BK002',
|
||||
property: 'شقة حديثة في الشهباء',
|
||||
startDate: '2024-02-20',
|
||||
endDate: '2024-02-25',
|
||||
amount: 1250000,
|
||||
status: 'completed'
|
||||
},
|
||||
{
|
||||
id: 'BK003',
|
||||
property: 'بيت عائلي في بابا عمرو',
|
||||
startDate: '2024-04-01',
|
||||
endDate: '2024-04-10',
|
||||
amount: 3500000,
|
||||
status: 'confirmed'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
تفاصيل المستخدم
|
||||
</h2>
|
||||
<p className="text-blue-100 text-sm mt-1">{user.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-blue-500" />
|
||||
معلومات شخصية
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">الاسم الكامل:</span>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">البريد الإلكتروني:</span>
|
||||
<span className="font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">رقم الهاتف:</span>
|
||||
<span className="font-medium">{user.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">تاريخ التسجيل:</span>
|
||||
<span className="font-medium">{user.joinDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-500" />
|
||||
معلومات الهوية
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">نوع الهوية:</span>
|
||||
<span className="font-medium">
|
||||
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">رقم الهوية:</span>
|
||||
<span className="font-medium">{user.identityNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي الحجوزات</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{user.activeBookings}</div>
|
||||
<div className="text-sm text-gray-600">حجوزات نشطة</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-amber-600">{formatCurrency(user.totalSpent)}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي المنصرف</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-blue-500" />
|
||||
سجل الحجوزات
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{userBookings.map((booking) => (
|
||||
<div key={booking.id} className="bg-gray-50 p-4 rounded-xl flex flex-col md:flex-row justify-between items-start md:items-center gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.property}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mt-1">
|
||||
<CalendarDays className="w-3 h-3" />
|
||||
{booking.startDate} - {booking.endDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.amount)}</div>
|
||||
<div className="text-xs text-gray-500">المبلغ الإجمالي</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
booking.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{booking.status === 'completed' ? 'مكتمل' : 'مؤكد'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{userBookings.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
||||
<p>لا توجد حجوزات سابقة</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function UsersList() {
|
||||
const [users, setUsers] = useState([
|
||||
@ -44,30 +481,194 @@ export default function UsersList() {
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showFilterDialog, setShowFilterDialog] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
});
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.name.includes(searchTerm) ||
|
||||
user.email.includes(searchTerm) ||
|
||||
user.phone.includes(searchTerm)
|
||||
);
|
||||
const applyFilters = (newFilters) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
});
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user => {
|
||||
if (searchTerm && !user.name.includes(searchTerm) && !user.email.includes(searchTerm) && !user.phone.includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.identityType !== 'all' && user.identityType !== filters.identityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.minBookings && user.totalBookings < parseInt(filters.minBookings)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxBookings && user.totalBookings > parseInt(filters.maxBookings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.minSpending && user.totalSpent < parseInt(filters.minSpending)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxSpending && user.totalSpent > parseInt(filters.maxSpending)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.activeOnly && user.activeBookings === 0) {
|
||||
return false;
|
||||
}
|
||||
if (filters.inactiveOnly && user.activeBookings > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.dateRange !== 'all') {
|
||||
const joinDate = new Date(user.joinDate);
|
||||
const today = new Date();
|
||||
const diffDays = Math.floor((today - joinDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
switch(filters.dateRange) {
|
||||
case 'today':
|
||||
if (joinDate.toDateString() !== today.toDateString()) return false;
|
||||
break;
|
||||
case 'week':
|
||||
if (diffDays > 7) return false;
|
||||
break;
|
||||
case 'month':
|
||||
if (diffDays > 30) return false;
|
||||
break;
|
||||
case 'year':
|
||||
if (diffDays > 365) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const filterStats = {
|
||||
total: filteredUsers.length,
|
||||
filtered: filteredUsers.length !== users.length
|
||||
};
|
||||
|
||||
const getActiveFiltersCount = () => {
|
||||
let count = 0;
|
||||
if (filters.identityType !== 'all') count++;
|
||||
if (filters.minBookings || filters.maxBookings) count++;
|
||||
if (filters.minSpending || filters.maxSpending) count++;
|
||||
if (filters.dateRange !== 'all') count++;
|
||||
if (filters.activeOnly || filters.inactiveOnly) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث عن مستخدم..."
|
||||
placeholder="بحث عن مستخدم بالاسم أو البريد أو الهاتف..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pr-10 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full pr-12 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button className="px-4 py-2 border rounded-lg flex items-center gap-2 hover:bg-gray-50">
|
||||
<Filter className="w-4 h-4" />
|
||||
تصفية
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilterDialog(true)}
|
||||
className={`px-5 py-3 rounded-xl font-medium flex items-center gap-2 transition-all ${
|
||||
getActiveFiltersCount() > 0
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
تصفية متقدمة
|
||||
{getActiveFiltersCount() > 0 && (
|
||||
<span className="ml-1 bg-white text-blue-600 rounded-full w-5 h-5 text-xs flex items-center justify-center">
|
||||
{getActiveFiltersCount()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{filterStats.filtered && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="px-5 py-3 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
إعادة تعيين
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getActiveFiltersCount() > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-blue-50 rounded-xl">
|
||||
<span className="text-sm text-blue-800 font-medium">الفلاتر النشطة:</span>
|
||||
{filters.identityType !== 'all' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
{filters.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</span>
|
||||
)}
|
||||
{(filters.minBookings || filters.maxBookings) && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
الحجوزات: {filters.minBookings || '0'} - {filters.maxBookings || '∞'}
|
||||
</span>
|
||||
)}
|
||||
{(filters.minSpending || filters.maxSpending) && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
الإنفاق: {parseInt(filters.minSpending || 0).toLocaleString()} - {parseInt(filters.maxSpending || '∞').toLocaleString()} ل.س
|
||||
</span>
|
||||
)}
|
||||
{filters.dateRange !== 'all' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
{filters.dateRange === 'today' ? 'اليوم' :
|
||||
filters.dateRange === 'week' ? 'آخر 7 أيام' :
|
||||
filters.dateRange === 'month' ? 'آخر 30 يوم' : 'آخر 12 شهر'}
|
||||
</span>
|
||||
)}
|
||||
{filters.activeOnly && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-lg text-xs">
|
||||
لديهم حجوزات نشطة
|
||||
</span>
|
||||
)}
|
||||
{filters.inactiveOnly && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
|
||||
بدون حجوزات نشطة
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
عرض <span className="font-bold text-gray-900">{filteredUsers.length}</span> مستخدم
|
||||
{filterStats.filtered && (
|
||||
<span className="text-gray-500 mr-1">(من {users.length})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@ -76,40 +677,46 @@ export default function UsersList() {
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-blue-600" />
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white text-xl font-bold shadow-lg">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold">{user.name}</h3>
|
||||
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-600">
|
||||
<h3 className="font-bold text-gray-900 text-lg">{user.name}</h3>
|
||||
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
<Mail className="w-4 h-4" />
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
<Phone className="w-4 h-4" />
|
||||
{user.phone}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="text-center min-w-[80px]">
|
||||
<div className="text-xl font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="text-xs text-gray-500">إجمالي الحجوزات</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-600">{user.activeBookings}</div>
|
||||
<div className="text-center min-w-[80px]">
|
||||
<div className={`text-xl font-bold ${user.activeBookings > 0 ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{user.activeBookings}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">حجوزات نشطة</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-amber-600">
|
||||
<div className="text-center min-w-[100px]">
|
||||
<div className="text-xl font-bold text-amber-600">
|
||||
{user.totalSpent.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">إجمالي المنصرف</div>
|
||||
@ -118,7 +725,7 @@ export default function UsersList() {
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm flex items-center gap-1 hover:bg-blue-700"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
عرض التفاصيل
|
||||
@ -128,63 +735,39 @@ export default function UsersList() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
{filteredUsers.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white rounded-xl w-full max-w-2xl p-6"
|
||||
className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">تفاصيل المستخدم</h2>
|
||||
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد نتائج</h3>
|
||||
<p className="text-gray-500">لا يوجد مستخدمون يطابقون معايير البحث</p>
|
||||
{(searchTerm || getActiveFiltersCount() > 0) && (
|
||||
<button
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
onClick={resetFilters}
|
||||
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
✕
|
||||
إعادة تعيين الفلاتر
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">الاسم</label>
|
||||
<div className="font-medium">{selectedUser.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">البريد الإلكتروني</label>
|
||||
<div className="font-medium">{selectedUser.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">رقم الهاتف</label>
|
||||
<div className="font-medium">{selectedUser.phone}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">نوع الهوية</label>
|
||||
<div className="font-medium">
|
||||
{selectedUser.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">رقم الهوية</label>
|
||||
<div className="font-medium">{selectedUser.identityNumber}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">تاريخ التسجيل</label>
|
||||
<div className="font-medium">{selectedUser.joinDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-bold mb-3">سجل الحجوزات</h3>
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
لا توجد حجوزات سابقة
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<FilterDialog
|
||||
isOpen={showFilterDialog}
|
||||
onClose={() => setShowFilterDialog(false)}
|
||||
filters={filters}
|
||||
onApplyFilters={applyFilters}
|
||||
onResetFilters={resetFilters}
|
||||
/>
|
||||
|
||||
<UserDetailsModal
|
||||
user={selectedUser}
|
||||
isOpen={!!selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
|
||||
import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
|
||||
|
||||
export default function HeroSearch({ onSearch }) {
|
||||
export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('rent');
|
||||
const [activeTab, setActiveTab] = useState('buy');
|
||||
const [filters, setFilters] = useState({
|
||||
city: '',
|
||||
propertyType: '',
|
||||
priceRange: '',
|
||||
identityType: 'syrian'
|
||||
city: 'all',
|
||||
propertyType: 'all',
|
||||
priceRange: 'all',
|
||||
identityType: 'syrian',
|
||||
ownerSource: 'all',
|
||||
rentPeriod: 'all',
|
||||
availableToday: false
|
||||
});
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
|
||||
const cities = [
|
||||
{ id: 'all', label: 'جميع المدن' },
|
||||
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
|
||||
|
||||
const propertyTypes = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'apartment', label: 'شقة' },
|
||||
{ id: 'villa', label: 'فيلا' },
|
||||
{ id: 'house', label: 'بيت' },
|
||||
{ id: 'studio', label: 'استوديو' }
|
||||
{ id: 'apartment', label: 'شقق سكنية' },
|
||||
{ id: 'studio', label: 'استوديو' },
|
||||
{ id: 'commercial', label: 'عقار تجاري' },
|
||||
{ id: 'villa', label: 'فيلا / مزرعة' }
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
@ -46,16 +51,44 @@ export default function HeroSearch({ onSearch }) {
|
||||
{ id: 'passport', label: 'جواز سفر' }
|
||||
];
|
||||
|
||||
const ownerSources = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'owner', label: 'من المالك' },
|
||||
{ id: 'agency', label: 'من مكتب عقاري' }
|
||||
];
|
||||
|
||||
const rentPeriods = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'daily', label: 'إيجار يومي' },
|
||||
{ id: 'monthly', label: 'إيجار شهري' }
|
||||
];
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch({
|
||||
...filters,
|
||||
propertyType: filters.propertyType || 'all',
|
||||
mode: activeTab,
|
||||
city: filters.city || 'all',
|
||||
priceRange: filters.priceRange || 'all'
|
||||
propertyType: filters.propertyType || 'all',
|
||||
priceRange: filters.priceRange || 'all',
|
||||
ownerSource: filters.ownerSource || 'all',
|
||||
rentPeriod: filters.rentPeriod || 'all'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
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 }}
|
||||
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
|
||||
{['rent', 'buy', 'sell'].map((tab) => (
|
||||
<motion.button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||
activeTab === tab
|
||||
? 'bg-amber-500 text-white'
|
||||
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
|
||||
<select
|
||||
value={filters.ownerSource}
|
||||
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{ownerSources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
|
||||
<select
|
||||
value={filters.rentPeriod}
|
||||
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{rentPeriods.map((period) => (
|
||||
<option key={period.id} value={period.id}>
|
||||
{period.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
|
||||
<label className="mt-4 flex items-center gap-3 text-white text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.availableToday}
|
||||
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||
/>
|
||||
<span className="font-medium">عرض فقط العقارات المتاحة من اليوم</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
onClick={handleSearch}
|
||||
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showLoginDialog && !isAuthenticated && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<ShieldCheck className="w-7 h-7 text-amber-500" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
|
||||
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
|
||||
const PropertyContext = createContext();
|
||||
|
||||
export const useProperties = () => {
|
||||
const context = useContext(PropertyContext);
|
||||
if (!context) {
|
||||
throw new Error('useProperties must be used within PropertyProvider');
|
||||
throw new Error("useProperties must be used within PropertyProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -20,7 +20,7 @@ export const PropertyProvider = ({ children }) => {
|
||||
const addProperty = useCallback(async (propertyData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const newProperty = {
|
||||
id: Date.now().toString(),
|
||||
@ -28,10 +28,10 @@ export const PropertyProvider = ({ children }) => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
bookings: [],
|
||||
status: propertyData.status || 'available'
|
||||
status: propertyData.status || "available",
|
||||
};
|
||||
|
||||
setProperties(prev => [...prev, newProperty]);
|
||||
setProperties((prev) => [...prev, newProperty]);
|
||||
return newProperty;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@ -44,13 +44,14 @@ export const PropertyProvider = ({ children }) => {
|
||||
const updateProperty = useCallback(async (id, updates) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setProperties(prev =>
|
||||
prev.map(p => p.id === id
|
||||
setProperties((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id
|
||||
? { ...p, ...updates, updatedAt: new Date().toISOString() }
|
||||
: p
|
||||
)
|
||||
: p,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@ -63,8 +64,8 @@ export const PropertyProvider = ({ children }) => {
|
||||
const deleteProperty = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setProperties(prev => prev.filter(p => p.id !== id));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setProperties((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
throw err;
|
||||
@ -73,19 +74,23 @@ export const PropertyProvider = ({ children }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getProperty = useCallback((id) => {
|
||||
return properties.find(p => p.id === id);
|
||||
}, [properties]);
|
||||
const getProperty = useCallback(
|
||||
(id) => {
|
||||
return properties.find((p) => p.id === id);
|
||||
},
|
||||
[properties],
|
||||
);
|
||||
|
||||
const checkAvailability = useCallback((propertyId, startDate, endDate) => {
|
||||
const property = properties.find(p => p.id === propertyId);
|
||||
const checkAvailability = useCallback(
|
||||
(propertyId, startDate, endDate) => {
|
||||
const property = properties.find((p) => p.id === propertyId);
|
||||
if (!property) return false;
|
||||
|
||||
const checkStart = new Date(startDate);
|
||||
const checkEnd = new Date(endDate);
|
||||
|
||||
return !property.bookings?.some(booking => {
|
||||
if (booking.status === 'cancelled' || booking.status === 'rejected') {
|
||||
return !property.bookings?.some((booking) => {
|
||||
if (booking.status === "cancelled" || booking.status === "rejected") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -98,30 +103,42 @@ export const PropertyProvider = ({ children }) => {
|
||||
(checkStart <= bookingStart && checkEnd >= bookingEnd)
|
||||
);
|
||||
});
|
||||
}, [properties]);
|
||||
},
|
||||
[properties],
|
||||
);
|
||||
|
||||
const getPropertiesByOwner = useCallback((ownerId) => {
|
||||
return properties.filter(p => p.ownerId === ownerId);
|
||||
}, [properties]);
|
||||
const getPropertiesByOwner = useCallback(
|
||||
(ownerId) => {
|
||||
return properties.filter((p) => p.ownerId === ownerId);
|
||||
},
|
||||
[properties],
|
||||
);
|
||||
|
||||
const getAvailableProperties = useCallback(() => {
|
||||
return properties.filter(p => p.status === 'available');
|
||||
return properties.filter((p) => p.status === "available");
|
||||
}, [properties]);
|
||||
|
||||
const updatePropertyStatus = useCallback(async (id, status) => {
|
||||
const updatePropertyStatus = useCallback(
|
||||
async (id, status) => {
|
||||
return updateProperty(id, { status });
|
||||
}, [updateProperty]);
|
||||
},
|
||||
[updateProperty],
|
||||
);
|
||||
|
||||
const addBookingToProperty = useCallback(async (propertyId, bookingData) => {
|
||||
const addBookingToProperty = useCallback(
|
||||
async (propertyId, bookingData) => {
|
||||
const property = getProperty(propertyId);
|
||||
if (!property) throw new Error('Property not found');
|
||||
if (!property) throw new Error("Property not found");
|
||||
|
||||
const updatedBookings = [...(property.bookings || []), bookingData];
|
||||
return updateProperty(propertyId, { bookings: updatedBookings });
|
||||
}, [getProperty, updateProperty]);
|
||||
},
|
||||
[getProperty, updateProperty],
|
||||
);
|
||||
|
||||
return (
|
||||
<PropertyContext.Provider value={{
|
||||
<PropertyContext.Provider
|
||||
value={{
|
||||
properties,
|
||||
loading,
|
||||
error,
|
||||
@ -133,8 +150,9 @@ export const PropertyProvider = ({ children }) => {
|
||||
getPropertiesByOwner,
|
||||
getAvailableProperties,
|
||||
updatePropertyStatus,
|
||||
addBookingToProperty
|
||||
}}>
|
||||
addBookingToProperty,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PropertyContext.Provider>
|
||||
);
|
||||
|
||||
38
app/enums/BookingStatus.js
Normal file
38
app/enums/BookingStatus.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* BookingStatus Enum
|
||||
* Backend values are strings
|
||||
* Used in: Reservation workflow
|
||||
*/
|
||||
const BookingStatus = Object.freeze({
|
||||
PENDING: 'pending',
|
||||
OWNER_APPROVED: 'owner_approved',
|
||||
ADMIN_APPROVED: 'admin_approved',
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
REJECTED: 'rejected',
|
||||
CANCELLED: 'cancelled',
|
||||
});
|
||||
|
||||
// Map status → Arabic label
|
||||
const BookingStatusLabels = Object.freeze({
|
||||
[BookingStatus.PENDING]: 'بانتظار الموافقة',
|
||||
[BookingStatus.OWNER_APPROVED]: 'موافقة المالك',
|
||||
[BookingStatus.ADMIN_APPROVED]: 'موافقة الإدارة',
|
||||
[BookingStatus.ACTIVE]: 'إيجار نشط',
|
||||
[BookingStatus.COMPLETED]: 'منتهي',
|
||||
[BookingStatus.REJECTED]: 'مرفوض',
|
||||
[BookingStatus.CANCELLED]: 'ملغي',
|
||||
});
|
||||
|
||||
// Map status → color class (Tailwind bg)
|
||||
const BookingStatusColors = Object.freeze({
|
||||
[BookingStatus.PENDING]: 'yellow',
|
||||
[BookingStatus.OWNER_APPROVED]: 'blue',
|
||||
[BookingStatus.ADMIN_APPROVED]: 'green',
|
||||
[BookingStatus.ACTIVE]: 'purple',
|
||||
[BookingStatus.COMPLETED]: 'gray',
|
||||
[BookingStatus.REJECTED]: 'red',
|
||||
[BookingStatus.CANCELLED]: 'red',
|
||||
});
|
||||
|
||||
export { BookingStatus, BookingStatusLabels, BookingStatusColors };
|
||||
33
app/enums/BuildingType.js
Normal file
33
app/enums/BuildingType.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* BuildingType Enum
|
||||
* Backend values are numeric (0, 1, 2)
|
||||
* Used in: PropertyInformation.buildingType
|
||||
*/
|
||||
const BuildingType = Object.freeze({
|
||||
APARTMENT: 0,
|
||||
VILLA: 1,
|
||||
HOUSE: 2,
|
||||
});
|
||||
|
||||
// Map numeric value → Arabic label
|
||||
const BuildingTypeLabels = Object.freeze({
|
||||
[BuildingType.APARTMENT]: 'شقة',
|
||||
[BuildingType.VILLA]: 'فيلا',
|
||||
[BuildingType.HOUSE]: 'بيت',
|
||||
});
|
||||
|
||||
// Map numeric value → English key (for UI filters)
|
||||
const BuildingTypeKeys = Object.freeze({
|
||||
[BuildingType.APARTMENT]: 'apartment',
|
||||
[BuildingType.VILLA]: 'villa',
|
||||
[BuildingType.HOUSE]: 'house',
|
||||
});
|
||||
|
||||
// Reverse map: English key → numeric value
|
||||
const BuildingTypeByKey = Object.freeze({
|
||||
apartment: BuildingType.APARTMENT,
|
||||
villa: BuildingType.VILLA,
|
||||
house: BuildingType.HOUSE,
|
||||
});
|
||||
|
||||
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey };
|
||||
38
app/enums/City.js
Normal file
38
app/enums/City.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* City Enum
|
||||
* Syrian cities used in property locations
|
||||
* Used in: Property search filters, location display
|
||||
*/
|
||||
const City = Object.freeze({
|
||||
DAMASCUS: 'دمشق',
|
||||
ALEPPO: 'حلب',
|
||||
HOMS: 'حمص',
|
||||
LATAKIA: 'اللاذقية',
|
||||
DARAA: 'درعا',
|
||||
TARTOUS: 'طرطوس',
|
||||
SUWEIDA: 'السويداء',
|
||||
DEIR_EZZOR: 'دير الزور',
|
||||
RAQQA: 'الرقة',
|
||||
IDLIB: 'إدلب',
|
||||
HASAKAH: 'الحسكة',
|
||||
QAMISHLI: 'القامشلي',
|
||||
RURAL_DAMASCUS: 'ريف دمشق',
|
||||
});
|
||||
|
||||
// All cities as a flat array
|
||||
const CitiesList = Object.freeze(Object.values(City));
|
||||
|
||||
/**
|
||||
* Extract city name from a full address string
|
||||
* @param {string} address
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractCity(address) {
|
||||
if (!address) return '';
|
||||
for (const city of CitiesList) {
|
||||
if (address.includes(city)) return city;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export { City, CitiesList, extractCity };
|
||||
19
app/enums/CommissionType.js
Normal file
19
app/enums/CommissionType.js
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* CommissionType Enum
|
||||
* Defines who pays the platform commission
|
||||
* Used in: Property pricing, booking financials
|
||||
*/
|
||||
const CommissionType = Object.freeze({
|
||||
FROM_OWNER: 'from_owner',
|
||||
FROM_TENANT: 'from_tenant',
|
||||
FROM_BOTH: 'from_both',
|
||||
});
|
||||
|
||||
// Map type → Arabic label
|
||||
const CommissionTypeLabels = Object.freeze({
|
||||
[CommissionType.FROM_OWNER]: 'من المالك',
|
||||
[CommissionType.FROM_TENANT]: 'من المستأجر',
|
||||
[CommissionType.FROM_BOTH]: 'من الاثنين',
|
||||
});
|
||||
|
||||
export { CommissionType, CommissionTypeLabels };
|
||||
20
app/enums/Currency.js
Normal file
20
app/enums/Currency.js
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Currency Enum
|
||||
* Currency IDs used in the backend
|
||||
*/
|
||||
const Currency = Object.freeze({
|
||||
SYP: 1,
|
||||
USD: 2,
|
||||
});
|
||||
|
||||
const CurrencyLabels = Object.freeze({
|
||||
[Currency.SYP]: 'ليرة سورية',
|
||||
[Currency.USD]: 'دولار أمريكي',
|
||||
});
|
||||
|
||||
const CurrencySymbols = Object.freeze({
|
||||
[Currency.SYP]: 'SYP',
|
||||
[Currency.USD]: 'USD',
|
||||
});
|
||||
|
||||
export { Currency, CurrencyLabels, CurrencySymbols };
|
||||
17
app/enums/CustomerType.js
Normal file
17
app/enums/CustomerType.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* CustomerType Enum
|
||||
* Backend values for customer sub-types
|
||||
* Used in: Customer registration (Customer/Add)
|
||||
*/
|
||||
const CustomerType = Object.freeze({
|
||||
PERSONAL: 0,
|
||||
FAMILY: 1,
|
||||
});
|
||||
|
||||
// Map value → Arabic label
|
||||
const CustomerTypeLabels = Object.freeze({
|
||||
[CustomerType.PERSONAL]: 'شخصي',
|
||||
[CustomerType.FAMILY]: 'عائلي',
|
||||
});
|
||||
|
||||
export { CustomerType, CustomerTypeLabels };
|
||||
23
app/enums/IdentityType.js
Normal file
23
app/enums/IdentityType.js
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* IdentityType Enum
|
||||
* Tenant identity document type
|
||||
* Used in: Property booking, allowedIdentities filter
|
||||
*/
|
||||
const IdentityType = Object.freeze({
|
||||
SYRIAN: 'syrian',
|
||||
PASSPORT: 'passport',
|
||||
});
|
||||
|
||||
// Map type → Arabic label
|
||||
const IdentityTypeLabels = Object.freeze({
|
||||
[IdentityType.SYRIAN]: 'هوية سورية',
|
||||
[IdentityType.PASSPORT]: 'جواز سفر',
|
||||
});
|
||||
|
||||
// Map type → flag emoji
|
||||
const IdentityTypeFlags = Object.freeze({
|
||||
[IdentityType.SYRIAN]: '🇸🇾',
|
||||
[IdentityType.PASSPORT]: '🛂',
|
||||
});
|
||||
|
||||
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags };
|
||||
11
app/enums/LoginMethod.js
Normal file
11
app/enums/LoginMethod.js
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* LoginMethod Enum
|
||||
* Authentication method type
|
||||
* Used in: Login page, OTP verification
|
||||
*/
|
||||
const LoginMethod = Object.freeze({
|
||||
EMAIL: 'email',
|
||||
PHONE: 'phone',
|
||||
});
|
||||
|
||||
export { LoginMethod };
|
||||
17
app/enums/OwnerType.js
Normal file
17
app/enums/OwnerType.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* OwnerType Enum
|
||||
* Backend values for owner sub-types
|
||||
* Used in: Owner registration (Owner/Add)
|
||||
*/
|
||||
const OwnerType = Object.freeze({
|
||||
PERSON: 0,
|
||||
REAL_ESTATE_AGENCY: 1,
|
||||
});
|
||||
|
||||
// Map value → Arabic label
|
||||
const OwnerTypeLabels = Object.freeze({
|
||||
[OwnerType.PERSON]: 'شخص',
|
||||
[OwnerType.REAL_ESTATE_AGENCY]: 'وكالة عقارية',
|
||||
});
|
||||
|
||||
export { OwnerType, OwnerTypeLabels };
|
||||
41
app/enums/PropertyService.js
Normal file
41
app/enums/PropertyService.js
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PropertyService Enum
|
||||
* Services available at the property
|
||||
* Used in detailsJSON.services array
|
||||
*/
|
||||
const PropertyService = Object.freeze({
|
||||
ELECTRICITY: 'Electricity',
|
||||
INTERNET: 'Internet',
|
||||
HEATING: 'Heating',
|
||||
WATER: 'Water',
|
||||
POOL: 'Pool',
|
||||
PRIVATE_GARDEN: 'PrivateGarden',
|
||||
PARKING: 'Parking',
|
||||
SECURITY_247: 'Security247',
|
||||
CENTRAL_HEATING: 'CentralHeating',
|
||||
CENTRAL_AIR_CONDITIONING: 'CentralAirConditioning',
|
||||
EQUIPPED_KITCHEN: 'EquippedKitchen',
|
||||
MAIDS_ROOM: 'MaidsRoom',
|
||||
ELEVATOR: 'Elevator',
|
||||
});
|
||||
|
||||
const PropertyServiceLabels = Object.freeze({
|
||||
[PropertyService.ELECTRICITY]: 'كهرباء',
|
||||
[PropertyService.INTERNET]: 'إنترنت',
|
||||
[PropertyService.HEATING]: 'تدفئة',
|
||||
[PropertyService.WATER]: 'ماء',
|
||||
[PropertyService.POOL]: 'مسبح',
|
||||
[PropertyService.PRIVATE_GARDEN]: 'حديقة خاصة',
|
||||
[PropertyService.PARKING]: 'موقف سيارات',
|
||||
[PropertyService.SECURITY_247]: 'حراسة 24 ساعة',
|
||||
[PropertyService.CENTRAL_HEATING]: 'تدفئة مركزية',
|
||||
[PropertyService.CENTRAL_AIR_CONDITIONING]: 'تكييف مركزي',
|
||||
[PropertyService.EQUIPPED_KITCHEN]: 'مطبخ مجهز',
|
||||
[PropertyService.MAIDS_ROOM]: 'غرفة خادمة',
|
||||
[PropertyService.ELEVATOR]: 'مصعد',
|
||||
});
|
||||
|
||||
// All services as array
|
||||
const PropertyServicesList = Object.freeze(Object.values(PropertyService));
|
||||
|
||||
export { PropertyService, PropertyServiceLabels, PropertyServicesList };
|
||||
33
app/enums/PropertyStatus.js
Normal file
33
app/enums/PropertyStatus.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* PropertyStatus Enum
|
||||
* Backend values are numeric (0, 1, 2)
|
||||
* Used in: PropertyInformation.status
|
||||
*/
|
||||
const PropertyStatus = Object.freeze({
|
||||
AVAILABLE: 0,
|
||||
BOOKED: 1,
|
||||
MAINTENANCE: 2,
|
||||
});
|
||||
|
||||
// Map numeric value → Arabic label
|
||||
const PropertyStatusLabels = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'متاح',
|
||||
[PropertyStatus.BOOKED]: 'محجوز',
|
||||
[PropertyStatus.MAINTENANCE]: 'صيانة',
|
||||
});
|
||||
|
||||
// Map numeric value → English key (for UI filters)
|
||||
const PropertyStatusKeys = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'available',
|
||||
[PropertyStatus.BOOKED]: 'booked',
|
||||
[PropertyStatus.MAINTENANCE]: 'maintenance',
|
||||
});
|
||||
|
||||
// Reverse map: English key → numeric value
|
||||
const PropertyStatusByKey = Object.freeze({
|
||||
available: PropertyStatus.AVAILABLE,
|
||||
booked: PropertyStatus.BOOKED,
|
||||
maintenance: PropertyStatus.MAINTENANCE,
|
||||
});
|
||||
|
||||
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey };
|
||||
21
app/enums/PropertyTerm.js
Normal file
21
app/enums/PropertyTerm.js
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* PropertyTerm Enum
|
||||
* Usage terms/conditions for the property
|
||||
* Used in detailsJSON.terms array
|
||||
*/
|
||||
const PropertyTerm = Object.freeze({
|
||||
NO_SMOKING: 'NoSmoking',
|
||||
NO_ANIMALS: 'NoAnimals',
|
||||
NO_PARTIES: 'NoParties',
|
||||
});
|
||||
|
||||
const PropertyTermLabels = Object.freeze({
|
||||
[PropertyTerm.NO_SMOKING]: 'ممنوع التدخين',
|
||||
[PropertyTerm.NO_ANIMALS]: 'ممنوع الحيوانات',
|
||||
[PropertyTerm.NO_PARTIES]: 'ممنوع الحفلات',
|
||||
});
|
||||
|
||||
// All terms as array
|
||||
const PropertyTermsList = Object.freeze(Object.values(PropertyTerm));
|
||||
|
||||
export { PropertyTerm, PropertyTermLabels, PropertyTermsList };
|
||||
16
app/enums/RentPropertyCondition.js
Normal file
16
app/enums/RentPropertyCondition.js
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RentPropertyCondition Enum
|
||||
* Furniture condition of the property
|
||||
* Sent as `propertyType` field in API
|
||||
*/
|
||||
const RentPropertyCondition = Object.freeze({
|
||||
WITH_FURNITURE: 0,
|
||||
WITHOUT_FURNITURE: 1,
|
||||
});
|
||||
|
||||
const RentPropertyConditionLabels = Object.freeze({
|
||||
[RentPropertyCondition.WITH_FURNITURE]: 'مفروش',
|
||||
[RentPropertyCondition.WITHOUT_FURNITURE]: 'غير مفروش',
|
||||
});
|
||||
|
||||
export { RentPropertyCondition, RentPropertyConditionLabels };
|
||||
17
app/enums/RentPropertyType.js
Normal file
17
app/enums/RentPropertyType.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* RentPropertyType Enum
|
||||
* Sent as `type` field in RentPropertyDto
|
||||
*/
|
||||
const RentPropertyType = Object.freeze({
|
||||
FURNISHED: 0,
|
||||
UNFURNISHED: 1,
|
||||
SEMI_FURNISHED: 2,
|
||||
});
|
||||
|
||||
const RentPropertyTypeLabels = Object.freeze({
|
||||
[RentPropertyType.FURNISHED]: 'مفروش بالكامل',
|
||||
[RentPropertyType.UNFURNISHED]: 'غير مفروش',
|
||||
[RentPropertyType.SEMI_FURNISHED]: 'مفروش جزئياً',
|
||||
});
|
||||
|
||||
export { RentPropertyType, RentPropertyTypeLabels };
|
||||
16
app/enums/RentType.js
Normal file
16
app/enums/RentType.js
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RentType Enum
|
||||
* Rental period type
|
||||
* Sent as `rentType` field in API
|
||||
*/
|
||||
const RentType = Object.freeze({
|
||||
MONTHLY: 0,
|
||||
DAILY: 1,
|
||||
});
|
||||
|
||||
const RentTypeLabels = Object.freeze({
|
||||
[RentType.MONTHLY]: 'شهري',
|
||||
[RentType.DAILY]: 'يومي',
|
||||
});
|
||||
|
||||
export { RentType, RentTypeLabels };
|
||||
27
app/enums/UserRole.js
Normal file
27
app/enums/UserRole.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* UserRole Enum
|
||||
* User account roles in the system
|
||||
* Derived from JWT token claims
|
||||
*/
|
||||
const UserRole = Object.freeze({
|
||||
GUEST: 'guest',
|
||||
CUSTOMER: 'customer',
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
});
|
||||
|
||||
const UserRoleLabels = Object.freeze({
|
||||
[UserRole.GUEST]: 'زائر',
|
||||
[UserRole.CUSTOMER]: 'مستأجر',
|
||||
[UserRole.OWNER]: 'مالك عقار',
|
||||
[UserRole.ADMIN]: 'مدير النظام',
|
||||
});
|
||||
|
||||
const UserRoleColors = Object.freeze({
|
||||
[UserRole.GUEST]: 'gray',
|
||||
[UserRole.CUSTOMER]: 'blue',
|
||||
[UserRole.OWNER]: 'amber',
|
||||
[UserRole.ADMIN]: 'red',
|
||||
});
|
||||
|
||||
export { UserRole, UserRoleLabels, UserRoleColors };
|
||||
21
app/enums/index.js
Normal file
21
app/enums/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Enums Index
|
||||
* Central export for all enum modules
|
||||
*/
|
||||
|
||||
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey } from './BuildingType';
|
||||
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey } from './PropertyStatus';
|
||||
export { BookingStatus, BookingStatusLabels, BookingStatusColors } from './BookingStatus';
|
||||
export { CommissionType, CommissionTypeLabels } from './CommissionType';
|
||||
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags } from './IdentityType';
|
||||
export { UserRole, UserRoleLabels, UserRoleColors } from './UserRole';
|
||||
export { City, CitiesList, extractCity } from './City';
|
||||
export { LoginMethod } from './LoginMethod';
|
||||
export { OwnerType, OwnerTypeLabels } from './OwnerType';
|
||||
export { CustomerType, CustomerTypeLabels } from './CustomerType';
|
||||
export { RentPropertyCondition, RentPropertyConditionLabels } from './RentPropertyCondition';
|
||||
export { RentPropertyType, RentPropertyTypeLabels } from './RentPropertyType';
|
||||
export { RentType, RentTypeLabels } from './RentType';
|
||||
export { PropertyService, PropertyServiceLabels, PropertyServicesList } from './PropertyService';
|
||||
export { PropertyTerm, PropertyTermLabels, PropertyTermsList } from './PropertyTerm';
|
||||
export { Currency, CurrencyLabels, CurrencySymbols } from './Currency';
|
||||
39
app/error.js
Normal file
39
app/error.js
Normal file
@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function GlobalError({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="w-24 h-24 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ غير متوقع</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر عن هذا الإزعاج، يرجى المحاولة مرة أخرى</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
إعادة المحاولة
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
app/favorites/page.js
Normal file
144
app/favorites/page.js
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Heart, MapPin, Bed, Bath, Square, X, ImageIcon } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const router = useRouter();
|
||||
const { favorites, isLoading: favoritesLoading, removeFavorite } = useFavorites();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setIsAdmin(AuthService.isAdmin());
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
if (favoritesLoading && favorites.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">المفضلة</h1>
|
||||
<p className="text-gray-600">العقارات التي قمت بحفظها</p>
|
||||
</div>
|
||||
|
||||
{favorites.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد عقارات في المفضلة</h3>
|
||||
<p className="text-gray-500 mb-6">يمكنك إضافة العقارات التي تعجبك بالنقر على أيقونة القلب</p>
|
||||
<Link
|
||||
href="/properties"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
استعرض العقارات
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{favorites.map((property) => (
|
||||
<motion.div
|
||||
key={property.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-gray-200"
|
||||
>
|
||||
<div className="relative h-48 bg-gray-100">
|
||||
{property.images && property.images[0] ? (
|
||||
<Image
|
||||
src={property.images[0]}
|
||||
alt={property.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFavorite(property.id)}
|
||||
className="absolute top-2 right-2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center hover:bg-red-50 transition-colors shadow-sm"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium">
|
||||
{property.type === 'apartment' ? 'شقة' : property.type === 'villa' ? 'فيلا' : 'بيت'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-xs mb-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="line-clamp-1">
|
||||
{property.location.city}، {property.location.district}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-600 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bed className="w-4 h-4" />
|
||||
<span>{property.bedrooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{property.bathrooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>{property.area}م²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/property/${property.id}`}
|
||||
className="block w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
عرض التفاصيل
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/forgot-password/error.js
Normal file
27
app/forgot-password/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/forgot-password/loading.js
Normal file
14
app/forgot-password/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,78 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Madani Arabic Font ─── */
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Thin.woff2') format('woff2');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Extra Light.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani-Arabic-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Semi Bold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani-Arabic-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Extra Bold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
src: url('/fonts/Madani Arabic Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ede6e6;
|
||||
--foreground: #156874;
|
||||
@ -19,10 +92,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
|
||||
BIN
app/icon.png
Normal file
BIN
app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@ -15,16 +15,44 @@ const geistMono = Geist_Mono({
|
||||
export const metadata = {
|
||||
title: "Sweet Home",
|
||||
description: "Discover premium furniture and home decor",
|
||||
icons: {
|
||||
icon: [{ url: "/logo.png", type: "image/png" }],
|
||||
shortcut: [{ url: "/logo.png", type: "image/png" }],
|
||||
apple: [{ url: "/logo.png", type: "image/png" }],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<ClientLayout>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/fonts/Madani-Arabic-Regular.woff2"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/fonts/Madani-Arabic-Bold.woff2"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/fonts/Madani Arabic Medium.woff2"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
|
||||
>
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
18
app/loading.js
Normal file
18
app/loading.js
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/login/error.js
Normal file
27
app/login/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/login/loading.js
Normal file
14
app/login/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
@ -16,106 +15,277 @@ import {
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Home,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
Shield,
|
||||
Phone,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
loginWithEmail,
|
||||
loginWithPhone,
|
||||
sendEmailOTP,
|
||||
sendPhoneOTP,
|
||||
verifyEmail,
|
||||
verifyPhone,
|
||||
isEmail,
|
||||
isPhoneNumber,
|
||||
getOwnerByUserId,
|
||||
getCustomerByUserId,
|
||||
} from "../utils/api";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Step: 'login' | 'otp'
|
||||
const [step, setStep] = useState("login");
|
||||
const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
credential: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const ADMIN_EMAIL = 'admin@gmail.com';
|
||||
const ADMIN_PASSWORD = '123';
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
if (!formData.credential) {
|
||||
newErrors.credential =
|
||||
loginMethod === "email"
|
||||
? "البريد الإلكتروني مطلوب"
|
||||
: "رقم الهاتف مطلوب";
|
||||
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||
newErrors.credential = "رقم الهاتف غير صالح";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'كلمة المرور مطلوبة';
|
||||
newErrors.password = "كلمة المرور مطلوبة";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
if (!validateForm()) {
|
||||
toast.error('يرجى تصحيح الأخطاء في النموذج', {
|
||||
style: { background: '#fee2e2', color: '#991b1b' }
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||
console.log(
|
||||
"[Login] Attempting login via",
|
||||
loginMethod,
|
||||
":",
|
||||
formData.credential,
|
||||
);
|
||||
|
||||
const result = await loginFn(formData.credential, formData.password);
|
||||
|
||||
console.log("[Login] Response status:", result.status);
|
||||
|
||||
if (result.status === 200) {
|
||||
const token =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
AuthService.addToken(token);
|
||||
console.log("[Login] Token stored");
|
||||
|
||||
// Fetch user profile to get full name
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser?.id) {
|
||||
try {
|
||||
const isOwner = AuthService.isOwner();
|
||||
const fetchFn = isOwner ? getOwnerByUserId : getCustomerByUserId;
|
||||
const profile = await fetchFn(authUser.id);
|
||||
if (profile) {
|
||||
AuthService.cacheUser({
|
||||
name:
|
||||
profile.fullName ||
|
||||
profile.name ||
|
||||
`${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email: profile.email || authUser.email,
|
||||
phone: profile.phone || profile.phoneNumber || authUser.phone,
|
||||
});
|
||||
console.log("[Login] User profile cached");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Login] Failed to fetch profile:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userRole = AuthService.isAdmin()
|
||||
? "admin"
|
||||
: AuthService.isOwner()
|
||||
? "owner"
|
||||
: "customer";
|
||||
console.log("[Login] User role:", userRole);
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success("تم تسجيل الدخول بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
toast.success('تم تسجيل الدخول كأدمن!', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
duration: 3000
|
||||
toast.success("تم التحقق بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
localStorage.setItem('user', JSON.stringify({
|
||||
name: 'مدير النظام',
|
||||
email: ADMIN_EMAIL,
|
||||
role: 'admin',
|
||||
avatar: 'أ'
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/admin');
|
||||
console.log("[OTP] Redirecting to home");
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
toast.error('بيانات الدخول غير صحيحة. حاول مع admin@gmail.com / 123', {
|
||||
style: { background: '#fee2e2', color: '#991b1b' },
|
||||
duration: 4000
|
||||
});
|
||||
console.error("[OTP] Verification failed:", result.data);
|
||||
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
|
||||
}
|
||||
} 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,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 3 + 1,
|
||||
duration: Math.random() * 15 + 10,
|
||||
delay: Math.random() * 5
|
||||
delay: Math.random() * 5,
|
||||
}));
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2
|
||||
}
|
||||
}
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
@ -123,24 +293,25 @@ export default function LoginPage() {
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { type: 'spring', stiffness: 100 }
|
||||
}
|
||||
transition: { type: "spring", stiffness: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
{/* Particles */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{particles.map((particle) => (
|
||||
{particles.map((p) => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
key={p.id}
|
||||
className="absolute rounded-full bg-amber-500/20"
|
||||
style={{
|
||||
left: `${particle.x}%`,
|
||||
top: `${particle.y}%`,
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -20, 0],
|
||||
@ -148,31 +319,24 @@ export default function LoginPage() {
|
||||
opacity: [0.2, 0.4, 0.2],
|
||||
}}
|
||||
transition={{
|
||||
duration: particle.duration,
|
||||
duration: p.duration,
|
||||
repeat: Infinity,
|
||||
delay: particle.delay,
|
||||
ease: "linear"
|
||||
delay: p.delay,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Glow orbs */}
|
||||
<motion.div
|
||||
className="absolute top-20 left-20 w-64 h-64 bg-amber-500/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
x: [0, 30, 0],
|
||||
y: [0, -20, 0],
|
||||
}}
|
||||
animate={{ scale: [1, 1.2, 1], x: [0, 30, 0], y: [0, -20, 0] }}
|
||||
transition={{ duration: 12, repeat: Infinity }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-20 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
x: [0, -30, 0],
|
||||
y: [0, 20, 0],
|
||||
}}
|
||||
animate={{ scale: [1, 1.3, 1], x: [0, -30, 0], y: [0, 20, 0] }}
|
||||
transition={{ duration: 15, repeat: Infinity }}
|
||||
/>
|
||||
|
||||
@ -182,17 +346,15 @@ export default function LoginPage() {
|
||||
animate="visible"
|
||||
className="relative w-full max-w-md z-10"
|
||||
>
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="absolute -top-16 left-0"
|
||||
>
|
||||
{/* Back link */}
|
||||
<motion.div variants={itemVariants} className="absolute -top-16 left-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ x: -5 }}
|
||||
transition={{ type: 'spring', stiffness: 400 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
@ -204,6 +366,7 @@ export default function LoginPage() {
|
||||
variants={itemVariants}
|
||||
className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
@ -229,81 +392,136 @@ export default function LoginPage() {
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
||||
>
|
||||
{step === "otp" ? (
|
||||
<KeyRound className="w-10 h-10 text-white" />
|
||||
) : (
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
)}
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
|
||||
<p className="text-amber-100">مرحباً بعودتك!</p>
|
||||
<p className="text-amber-100">
|
||||
{step === "otp" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "login" ? (
|
||||
<motion.form
|
||||
variants={itemVariants}
|
||||
onSubmit={handleSubmit}
|
||||
key="login"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
onSubmit={handleLogin}
|
||||
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">
|
||||
البريد الإلكتروني
|
||||
{loginMethod === "email"
|
||||
? "البريد الإلكتروني"
|
||||
: "رقم الهاتف"}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Mail className={`w-5 h-5 transition-colors ${
|
||||
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
|
||||
}`} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, email: e.target.value});
|
||||
if (errors.email) setErrors({...errors, email: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل بريدك الإلكتروني"
|
||||
{loginMethod === "email" ? (
|
||||
<Mail
|
||||
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
) : (
|
||||
<Phone
|
||||
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
{formData.email && validateEmail(formData.email) && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
{errors.email && (
|
||||
<input
|
||||
type="text"
|
||||
// type={loginMethod === 'email' ? 'email' : 'tel'}
|
||||
value={formData.credential}
|
||||
onChange={(e) => handleCredentialChange(e.target.value)}
|
||||
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.credential
|
||||
? "border-red-500"
|
||||
: "border-gray-700"
|
||||
}`}
|
||||
placeholder={
|
||||
loginMethod === "email"
|
||||
? "example@email.com"
|
||||
: "+963XXXXXXXXX"
|
||||
}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
{errors.credential && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.email}
|
||||
{errors.credential}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 transition-colors ${
|
||||
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
|
||||
}`} />
|
||||
<Lock
|
||||
className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, password: e.target.value});
|
||||
if (errors.password) setErrors({...errors, password: null});
|
||||
setFormData({
|
||||
...formData,
|
||||
password: e.target.value,
|
||||
});
|
||||
if (errors.password)
|
||||
setErrors({ ...errors, password: null });
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-700'
|
||||
errors.password ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
@ -328,19 +546,20 @@ export default function LoginPage() {
|
||||
{errors.password}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(e) => setFormData({...formData, rememberMe: e.target.checked})}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
rememberMe: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||
@ -353,22 +572,16 @@ export default function LoginPage() {
|
||||
>
|
||||
نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
variants={itemVariants}
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-amber-600 to-amber-700"
|
||||
initial={{ x: '100%' }}
|
||||
whileHover={{ x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
@ -389,12 +602,112 @@ export default function LoginPage() {
|
||||
</span>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
) : (
|
||||
/* OTP Verification Step */
|
||||
<motion.form
|
||||
key="otp"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onSubmit={handleVerifyOTP}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
|
||||
<p className="text-gray-300 text-sm">
|
||||
تم إرسال رمز التحقق إلى{" "}
|
||||
<span className="text-white font-medium" dir="ltr">
|
||||
{formData.credential}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
رمز التحقق
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => {
|
||||
setOtpCode(e.target.value);
|
||||
if (otpError) setOtpError("");
|
||||
}}
|
||||
className={`w-full px-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white text-center text-2xl tracking-[0.5em] placeholder-gray-500 transition-all ${
|
||||
otpError ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="______"
|
||||
maxLength={6}
|
||||
dir="ltr"
|
||||
/>
|
||||
{otpError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1 text-center"
|
||||
>
|
||||
{otpError}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
جاري التحقق...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
تم بنجاح!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="w-5 h-5" />
|
||||
تحقق
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("login");
|
||||
setOtpCode("");
|
||||
setOtpError("");
|
||||
console.log("[OTP] Going back to login");
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
← العودة
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resendOTP}
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
إعادة إرسال الرمز
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-400 mt-6"
|
||||
>
|
||||
ليس لديك حساب؟{' '}
|
||||
ليس لديك حساب؟{" "}
|
||||
<Link
|
||||
href="/auth/choose-role"
|
||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||
@ -409,12 +722,18 @@ export default function LoginPage() {
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-500 text-xs mt-4"
|
||||
>
|
||||
بتسجيل الدخول، أنت توافق على{' '}
|
||||
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||
بتسجيل الدخول، أنت توافق على{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
شروط الاستخدام
|
||||
</Link>
|
||||
{' '}و{' '}
|
||||
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||
</Link>{" "}
|
||||
و{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
سياسة الخصوصية
|
||||
</Link>
|
||||
</motion.p>
|
||||
|
||||
36
app/not-found.js
Normal file
36
app/not-found.js
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<svg viewBox="0 0 200 160" className="w-64 h-48 mx-auto">
|
||||
<circle cx="100" cy="80" r="70" fill="#fef3c7" />
|
||||
<text x="100" y="95" textAnchor="middle" fontSize="60" fontWeight="bold" fill="#f59e0b">404</text>
|
||||
<circle cx="80" cy="110" r="8" fill="#92400e" />
|
||||
<circle cx="120" cy="110" r="8" fill="#92400e" />
|
||||
<path d="M85 130 Q100 120 115 130" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">الصفحة غير موجودة</h2>
|
||||
<p className="text-gray-500 mb-8">عذراً، الصفحة التي تبحث عنها غير متوفرة</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app/notifications/page.js
Normal file
102
app/notifications/page.js
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const { notifications, unreadCount, isLoading } = useNotifications();
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const markAsRead = (id) => {
|
||||
// This will be handled by context if needed
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
// This will be handled by context if needed
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">خطأ في التحميل</h3>
|
||||
<p className="text-gray-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الإشعارات</h1>
|
||||
<p className="text-gray-600">
|
||||
{unreadCount > 0 ? `لديك ${unreadCount} إشعار غير مقروء` : 'جميع الإشعارات مقروءة'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد إشعارات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا الإشعارات المتعلقة بحجوزاتك ومدفوعاتك</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
|
||||
>
|
||||
<div className="p-5 flex gap-4">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-full flex items-center justify-center shrink-0">
|
||||
<Bell className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{notification.title}</h3>
|
||||
{notification.message && (
|
||||
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
|
||||
)}
|
||||
{notification.date && (
|
||||
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/bookings/error.js
Normal file
27
app/owner/bookings/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/bookings/loading.js
Normal file
14
app/owner/bookings/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
740
app/owner/bookings/page.js
Normal file
740
app/owner/bookings/page.js
Normal file
@ -0,0 +1,740 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Calendar,
|
||||
Home,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
DollarSign,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MessageCircle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Filter,
|
||||
Search,
|
||||
Download,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Building
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import Image from 'next/image';
|
||||
|
||||
const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [hoverDate, setHoverDate] = useState(null);
|
||||
|
||||
const daysInMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth() + 1,
|
||||
0
|
||||
).getDate();
|
||||
|
||||
const firstDayOfMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
1
|
||||
).getDay();
|
||||
|
||||
const monthNames = [
|
||||
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
|
||||
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
|
||||
];
|
||||
|
||||
const isDateBooked = (date) => {
|
||||
if (!property?.bookings) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.some(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const isDateSelected = (date) => {
|
||||
if (!selectedDates) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return dateStr === selectedDates.start || dateStr === selectedDates.end;
|
||||
};
|
||||
|
||||
const isInRange = (date) => {
|
||||
if (!selectedDates?.start || !selectedDates?.end) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return dateStr > selectedDates.start && dateStr < selectedDates.end;
|
||||
};
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
if (isDateBooked(date)) return;
|
||||
onDateSelect?.(date);
|
||||
};
|
||||
|
||||
const renderDays = () => {
|
||||
const days = [];
|
||||
const totalDays = daysInMonth + firstDayOfMonth;
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
if (i < firstDayOfMonth) {
|
||||
days.push(<div key={`empty-${i}`} className="p-2" />);
|
||||
} else {
|
||||
const dayNumber = i - firstDayOfMonth + 1;
|
||||
const date = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
dayNumber
|
||||
);
|
||||
|
||||
const isBooked = isDateBooked(date);
|
||||
const isSelected = isDateSelected(date);
|
||||
const inRange = isInRange(date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={dayNumber}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={isBooked}
|
||||
onMouseEnter={() => setHoverDate(dayNumber)}
|
||||
onMouseLeave={() => setHoverDate(null)}
|
||||
className={`
|
||||
p-2 rounded-lg text-center text-sm transition-all relative
|
||||
${isBooked ? 'bg-red-100 text-red-500 cursor-not-allowed line-through' : ''}
|
||||
${isSelected ? 'bg-amber-500 text-white shadow-md' : ''}
|
||||
${inRange ? 'bg-amber-100' : ''}
|
||||
${!isBooked && !isSelected ? 'hover:bg-amber-50 hover:text-amber-600 cursor-pointer' : ''}
|
||||
${isToday && !isSelected && !isBooked ? 'border-2 border-amber-500' : ''}
|
||||
`}
|
||||
>
|
||||
{dayNumber}
|
||||
{isBooked && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
{/* رأس التقويم */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* أيام الأسبوع */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
|
||||
<div>أحد</div>
|
||||
<div>إثنين</div>
|
||||
<div>ثلاثاء</div>
|
||||
<div>أربعاء</div>
|
||||
<div>خميس</div>
|
||||
<div>جمعة</div>
|
||||
<div>سبت</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{renderDays()}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-6 pt-4 border-t border-gray-200 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-100 rounded" />
|
||||
<span className="text-gray-600">محجوز</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-amber-500 rounded" />
|
||||
<span className="text-gray-600">محدد</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-amber-100 rounded" />
|
||||
<span className="text-gray-600">ضمن الفترة</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-amber-500 rounded" />
|
||||
<span className="text-gray-600">اليوم</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BookingCard = ({ booking, onViewDetails, onContact }) => {
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
confirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
cancelled: { label: 'ملغي', color: 'bg-red-100 text-red-800', icon: XCircle },
|
||||
completed: { label: 'منتهي', color: 'bg-gray-100 text-gray-800', icon: CheckCircle }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${config.color}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-gray-900">{booking.propertyTitle}</h3>
|
||||
{getStatusBadge(booking.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{booking.location}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</div>
|
||||
<div className="text-xs text-gray-500">إجمالي المبلغ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.tenantName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Phone className="w-3 h-3" />
|
||||
{booking.tenantPhone}
|
||||
<Mail className="w-3 h-3 mr-1" />
|
||||
{booking.tenantEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-4 text-center">
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-xs text-gray-500">من</div>
|
||||
<div className="text-sm font-medium">{booking.startDate}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-xs text-gray-500">إلى</div>
|
||||
<div className="text-sm font-medium">{booking.endDate}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Clock className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-xs text-gray-500">المدة</div>
|
||||
<div className="text-sm font-medium">{booking.days} يوم</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => onViewDetails(booking)}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
التفاصيل
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => onContact(booking)}
|
||||
className="flex-1 bg-amber-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
تواصل
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const BookingDetailsModal = ({ booking, isOpen, onClose }) => {
|
||||
if (!isOpen || !booking) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">تفاصيل الحجز</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-amber-100 text-sm mt-1">رقم الحجز: #{booking.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">معلومات العقار</h3>
|
||||
<div className="space-y-2">
|
||||
<p><span className="text-gray-500">العقار:</span> {booking.propertyTitle}</p>
|
||||
<p><span className="text-gray-500">الموقع:</span> {booking.location}</p>
|
||||
{booking.propertyDetails && (
|
||||
<div className="flex gap-3 mt-2">
|
||||
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.bedrooms} غرف</span>
|
||||
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.bathrooms} حمامات</span>
|
||||
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.area} م²</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">معلومات المستأجر</h3>
|
||||
<div className="space-y-2">
|
||||
<p><span className="text-gray-500">الاسم:</span> {booking.tenantName}</p>
|
||||
<p><span className="text-gray-500">البريد الإلكتروني:</span> {booking.tenantEmail}</p>
|
||||
<p><span className="text-gray-500">رقم الهاتف:</span> {booking.tenantPhone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">تفاصيل الحجز</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-500">تاريخ البداية</p>
|
||||
<p className="font-medium">{booking.startDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">تاريخ النهاية</p>
|
||||
<p className="font-medium">{booking.endDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">عدد الأيام</p>
|
||||
<p className="font-medium">{booking.days} يوم</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">حالة الحجز</p>
|
||||
<p className="font-medium">{booking.status === 'pending' ? 'قيد الانتظار' :
|
||||
booking.status === 'confirmed' ? 'مؤكد' :
|
||||
booking.status === 'cancelled' ? 'ملغي' : 'منتهي'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3">المعلومات المالية</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">السعر اليومي</span>
|
||||
<span className="font-medium">{formatCurrency(booking.dailyPrice)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">المدة ({booking.days} أيام)</span>
|
||||
<span className="font-medium">{formatCurrency(booking.dailyPrice * booking.days)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">سلفة الضمان</span>
|
||||
<span className="font-medium">{formatCurrency(booking.securityDeposit || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-amber-200 font-bold">
|
||||
<span className="text-gray-900">الإجمالي</span>
|
||||
<span className="text-amber-600 text-lg">{formatCurrency(booking.totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.notes && (
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-2">ملاحظات</h3>
|
||||
<p className="text-gray-600">{booking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OwnerBookingsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [bookings, setBookings] = useState([]);
|
||||
const [filteredBookings, setFilteredBookings] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadBookings();
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
|
||||
const loadBookings = () => {
|
||||
const storedBookings = localStorage.getItem('ownerBookings');
|
||||
if (storedBookings) {
|
||||
setBookings(JSON.parse(storedBookings));
|
||||
setFilteredBookings(JSON.parse(storedBookings));
|
||||
} else {
|
||||
const mockBookings = [
|
||||
{
|
||||
id: 'BK001',
|
||||
propertyId: 1,
|
||||
propertyTitle: 'فيلا فاخرة في المزة',
|
||||
location: 'دمشق، المزة',
|
||||
propertyDetails: { bedrooms: 5, bathrooms: 4, area: 450 },
|
||||
tenantName: 'أحمد محمد',
|
||||
tenantEmail: 'ahmed@example.com',
|
||||
tenantPhone: '0933111222',
|
||||
startDate: '2024-03-10',
|
||||
endDate: '2024-03-15',
|
||||
days: 5,
|
||||
dailyPrice: 500000,
|
||||
totalAmount: 2500000,
|
||||
securityDeposit: 500000,
|
||||
status: 'confirmed',
|
||||
createdAt: '2024-02-25',
|
||||
notes: 'طلب الحجز من خلال الموقع'
|
||||
},
|
||||
{
|
||||
id: 'BK002',
|
||||
propertyId: 2,
|
||||
propertyTitle: 'شقة حديثة في الشهباء',
|
||||
location: 'حلب، الشهباء',
|
||||
propertyDetails: { bedrooms: 3, bathrooms: 2, area: 180 },
|
||||
tenantName: 'سارة أحمد',
|
||||
tenantEmail: 'sara@example.com',
|
||||
tenantPhone: '0945123789',
|
||||
startDate: '2024-03-05',
|
||||
endDate: '2024-03-08',
|
||||
days: 3,
|
||||
dailyPrice: 250000,
|
||||
totalAmount: 750000,
|
||||
securityDeposit: 250000,
|
||||
status: 'pending',
|
||||
createdAt: '2024-02-24',
|
||||
notes: 'تحتاج إلى تأكيد'
|
||||
},
|
||||
{
|
||||
id: 'BK003',
|
||||
propertyId: 3,
|
||||
propertyTitle: 'بيت عائلي في بابا عمرو',
|
||||
location: 'حمص، بابا عمرو',
|
||||
propertyDetails: { bedrooms: 4, bathrooms: 3, area: 300 },
|
||||
tenantName: 'محمد الحلبي',
|
||||
tenantEmail: 'mohammed@example.com',
|
||||
tenantPhone: '0956123456',
|
||||
startDate: '2024-02-20',
|
||||
endDate: '2024-03-20',
|
||||
days: 30,
|
||||
dailyPrice: 350000,
|
||||
totalAmount: 10500000,
|
||||
securityDeposit: 500000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-02-15',
|
||||
notes: 'تم إنهاء الإيجار بنجاح'
|
||||
}
|
||||
];
|
||||
setBookings(mockBookings);
|
||||
setFilteredBookings(mockBookings);
|
||||
localStorage.setItem('ownerBookings', JSON.stringify(mockBookings));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleViewDetails = (booking) => {
|
||||
setSelectedBooking(booking);
|
||||
};
|
||||
|
||||
const handleContact = (booking) => {
|
||||
toast.success(`جاري فتح محادثة مع ${booking.tenantName}`, {
|
||||
icon: '💬',
|
||||
style: { background: '#dcfce7', color: '#166534' }
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = (bookingId, newStatus) => {
|
||||
const updatedBookings = bookings.map(b =>
|
||||
b.id === bookingId ? { ...b, status: newStatus } : b
|
||||
);
|
||||
setBookings(updatedBookings);
|
||||
setFilteredBookings(updatedBookings);
|
||||
localStorage.setItem('ownerBookings', JSON.stringify(updatedBookings));
|
||||
toast.success(`تم تحديث حالة الحجز بنجاح`);
|
||||
};
|
||||
|
||||
const statusCounts = {
|
||||
all: bookings.length,
|
||||
pending: bookings.filter(b => b.status === 'pending').length,
|
||||
confirmed: bookings.filter(b => b.status === 'confirmed').length,
|
||||
completed: bookings.filter(b => b.status === 'completed').length,
|
||||
cancelled: bookings.filter(b => b.status === 'cancelled').length
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل الحجوزات...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<BookingDetailsModal
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.name}، لديك {bookings.length} حجز</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCalendar(!showCalendar)}
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
{showCalendar ? 'إخفاء التقويم' : 'عرض التقويم'}
|
||||
</button>
|
||||
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
</button> */}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200 cursor-pointer hover:shadow-md transition-all"
|
||||
onClick={() => setFilterStatus('all')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900">{statusCounts.all}</div>
|
||||
<div className="text-sm text-gray-600">جميع الحجوزات</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'pending' ? 'border-yellow-500 bg-yellow-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('pending')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-yellow-600">{statusCounts.pending}</div>
|
||||
<div className="text-sm text-gray-600">قيد الانتظار</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'confirmed' ? 'border-green-500 bg-green-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('confirmed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-green-600">{statusCounts.confirmed}</div>
|
||||
<div className="text-sm text-gray-600">مؤكدة</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'completed' ? 'border-gray-500 bg-gray-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('completed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-600">{statusCounts.completed}</div>
|
||||
<div className="text-sm text-gray-600">منتهية</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
|
||||
filterStatus === 'cancelled' ? 'border-red-500 bg-red-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setFilterStatus('cancelled')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-red-600">{statusCounts.cancelled}</div>
|
||||
<div className="text-sm text-gray-600">ملغية</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ابحث باسم العقار أو المستأجر.."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="من تاريخ"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="إلى تاريخ"
|
||||
/>
|
||||
{(dateRange.start || dateRange.end) && (
|
||||
<button
|
||||
onClick={() => setDateRange({ start: '', end: '' })}
|
||||
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
مسح
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCalendar && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<OwnerBookingCalendar
|
||||
property={{ bookings }}
|
||||
onDateSelect={(date) => console.log('Date selected:', date)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{filteredBookings.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<div className="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-12 h-12 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{filterStatus !== 'all' ? 'لا توجد حجوزات في هذه الفئة' : 'لم يتم استلام أي حجوزات بعد'}
|
||||
</p>
|
||||
{filterStatus !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600"
|
||||
>
|
||||
عرض جميع الحجوزات
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredBookings.map((booking) => (
|
||||
<BookingCard
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
onViewDetails={handleViewDetails}
|
||||
onContact={handleContact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/calendar/error.js
Normal file
27
app/owner/calendar/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/calendar/loading.js
Normal file
14
app/owner/calendar/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
738
app/owner/calendar/page.js
Normal file
738
app/owner/calendar/page.js
Normal file
@ -0,0 +1,738 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Home,
|
||||
Building,
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
DollarSign,
|
||||
Eye,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Filter,
|
||||
Download,
|
||||
Printer,
|
||||
ChevronDown,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List,
|
||||
AlertCircle,
|
||||
XCircle as XCircleIcon,
|
||||
Calendar as CalendarIcon
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
|
||||
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [viewType, setViewType] = useState('grid');
|
||||
|
||||
const monthNames = [
|
||||
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
|
||||
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
|
||||
];
|
||||
|
||||
const daysInMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth() + 1,
|
||||
0
|
||||
).getDate();
|
||||
|
||||
const firstDayOfMonth = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
1
|
||||
).getDay();
|
||||
|
||||
const isDateBookedForProperty = (date, property) => {
|
||||
if (!property?.bookings) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.some(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const getDayStatus = (date) => {
|
||||
if (selectedPropertyId === 'all') {
|
||||
const totalProperties = properties.length;
|
||||
const bookedCount = properties.filter(p => isDateBookedForProperty(date, p)).length;
|
||||
|
||||
if (bookedCount === 0) return { status: 'all_available', label: 'جميع العقارات متاحة', color: 'bg-green-100 text-green-800' };
|
||||
if (bookedCount === totalProperties) return { status: 'all_booked', label: 'جميع العقارات محجوزة', color: 'bg-red-100 text-red-800' };
|
||||
return { status: 'partial', label: `${bookedCount}/${totalProperties} محجوز`, color: 'bg-yellow-100 text-yellow-800' };
|
||||
} else {
|
||||
const property = properties.find(p => p.id === selectedPropertyId);
|
||||
if (!property) return { status: 'no_property', label: 'غير متاح', color: 'bg-gray-100 text-gray-500' };
|
||||
|
||||
const isBooked = isDateBookedForProperty(date, property);
|
||||
return {
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
label: isBooked ? 'محجوز' : 'متاح',
|
||||
color: isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateClick = (date) => {
|
||||
setSelectedDate(date);
|
||||
onDateClick?.(date);
|
||||
};
|
||||
|
||||
const changeMonth = (direction) => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + direction, 1));
|
||||
};
|
||||
|
||||
const renderDays = () => {
|
||||
const days = [];
|
||||
const totalDays = daysInMonth + firstDayOfMonth;
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
if (i < firstDayOfMonth) {
|
||||
days.push(<div key={`empty-${i}`} className="p-2 md:p-3" />);
|
||||
} else {
|
||||
const dayNumber = i - firstDayOfMonth + 1;
|
||||
const date = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
dayNumber
|
||||
);
|
||||
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
const status = getDayStatus(date);
|
||||
const isSelected = selectedDate?.toDateString() === date.toDateString();
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={dayNumber}
|
||||
onClick={() => handleDateClick(date)}
|
||||
className={`
|
||||
p-2 md:p-3 rounded-xl text-center transition-all relative group
|
||||
${status.color}
|
||||
${isToday ? 'ring-2 ring-amber-500 ring-offset-2' : ''}
|
||||
${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}
|
||||
hover:scale-105 hover:shadow-md
|
||||
`}
|
||||
>
|
||||
<div className="text-sm md:text-base font-medium">{dayNumber}</div>
|
||||
<div className="text-xs mt-1 hidden md:block">{status.label}</div>
|
||||
{status.status === 'partial' && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 md:p-6 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900">
|
||||
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date())}
|
||||
className="px-4 py-2 bg-amber-500 text-white rounded-xl text-sm hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
اليوم
|
||||
</button>
|
||||
<div className="flex border border-gray-200 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewType('grid')}
|
||||
className={`p-2 transition-colors ${viewType === 'grid' ? 'bg-amber-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewType('list')}
|
||||
className={`p-2 transition-colors ${viewType === 'list' ? 'bg-amber-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 p-4 bg-gray-50 border-b border-gray-200">
|
||||
{['أحد', 'إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت'].map((day, index) => (
|
||||
<div key={index} className="text-center text-sm font-medium text-gray-600 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-7 gap-1 md:gap-2">
|
||||
{renderDays()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-100 rounded" />
|
||||
<span className="text-gray-600">متاح</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-100 rounded" />
|
||||
<span className="text-gray-600">محجوز</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-yellow-100 rounded" />
|
||||
<span className="text-gray-600">محجوز جزئياً</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 ring-2 ring-amber-500 rounded" />
|
||||
<span className="text-gray-600">اليوم</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 ring-2 ring-blue-500 rounded" />
|
||||
<span className="text-gray-600">محدد</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) => {
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const isDateBooked = (property, date) => {
|
||||
if (!property?.bookings || !date) return false;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.some(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const getBookingForDate = (property, date) => {
|
||||
if (!property?.bookings || !date) return null;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return property.bookings.find(booking => {
|
||||
const start = new Date(booking.startDate);
|
||||
const end = new Date(booking.endDate);
|
||||
const current = new Date(date);
|
||||
return current >= start && current <= end;
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<CalendarDays className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-2">اختر تاريخاً</h3>
|
||||
<p className="text-gray-500">اضغط على أي يوم في التقويم لعرض حالة العقارات في ذلك التاريخ</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedDate = selectedDate.toLocaleDateString('ar-SA', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 md:p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-amber-100">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
حالة العقارات في تاريخ: {formattedDate}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{properties.map((property) => {
|
||||
const isBooked = isDateBooked(property, selectedDate);
|
||||
const booking = getBookingForDate(property, selectedDate);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={property.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`p-4 md:p-6 hover:bg-gray-50 transition-colors cursor-pointer ${isBooked ? 'bg-red-50/30' : 'bg-green-50/30'}`}
|
||||
onClick={() => onPropertyClick(property)}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-bold text-gray-900 text-lg">{property.title}</h4>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{isBooked ? 'محجوز' : 'متاح'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm mb-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{property.location}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bed className="w-4 h-4" />
|
||||
<span>{property.bedrooms} غرف</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{property.bathrooms} حمامات</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>{property.area} م²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/يوم</div>
|
||||
{isBooked && booking && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div>مستأجر: {booking.tenantName || 'غير معروف'}</div>
|
||||
<div>من: {booking.startDate} إلى {booking.endDate}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{property.title}</h2>
|
||||
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<XCircleIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{property.images && property.images.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3">صور العقار</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{property.images.slice(0, 4).map((image, index) => (
|
||||
<div key={index} className="relative h-32 rounded-lg overflow-hidden bg-gray-100">
|
||||
<img src={image} alt={`${property.title} ${index + 1}`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bedrooms}</div>
|
||||
<div className="text-xs text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bathrooms}</div>
|
||||
<div className="text-xs text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.area}</div>
|
||||
<div className="text-xs text-gray-500">م²</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/يوم</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{property.features && property.features.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3">المميزات</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{property.features.map((feature, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.bookings && property.bookings.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-500" />
|
||||
الحجوزات القادمة
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{property.bookings.slice(0, 3).map((booking, index) => (
|
||||
<div key={index} className="bg-gray-50 p-3 rounded-lg flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.startDate} - {booking.endDate}</p>
|
||||
<p className="text-xs text-gray-500">مستأجر: {booking.tenantName || 'غير معروف'}</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 flex gap-3">
|
||||
<Link
|
||||
href={`/owner/properties/edit?id=${property.id}`}
|
||||
className="flex-1 bg-amber-500 text-white py-3 rounded-xl text-center font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تعديل العقار
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.location.href = `/owner/bookings?property=${property.id}`}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl text-center font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
عرض الحجوزات
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OwnerCalendarPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [filteredProperties, setFilteredProperties] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState('all');
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [selectedProperty, setSelectedProperty] = useState(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadCalendar();
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
|
||||
|
||||
const loadProperties = () => {
|
||||
const storedProperties = localStorage.getItem('ownerProperties');
|
||||
if (storedProperties) {
|
||||
const props = JSON.parse(storedProperties);
|
||||
setProperties(props);
|
||||
setFilteredProperties(props);
|
||||
} else {
|
||||
const mockProperties = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
location: 'دمشق، المزة',
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
price: 500000,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7'],
|
||||
images: ['/villa1.jpg'],
|
||||
status: 'available',
|
||||
bookings: [
|
||||
{ startDate: '2024-03-10', endDate: '2024-03-15', totalAmount: 2500000, tenantName: 'أحمد محمد' },
|
||||
{ startDate: '2024-03-20', endDate: '2024-03-25', totalAmount: 2500000, tenantName: 'سارة أحمد' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
location: 'حلب، الشهباء',
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
price: 250000,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
images: ['/apartment1.jpg'],
|
||||
status: 'available',
|
||||
bookings: [
|
||||
{ startDate: '2024-03-05', endDate: '2024-03-08', totalAmount: 750000, tenantName: 'محمد علي' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
location: 'حمص، بابا عمرو',
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
price: 350000,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
bookings: []
|
||||
}
|
||||
];
|
||||
setProperties(mockProperties);
|
||||
setFilteredProperties(mockProperties);
|
||||
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const calendarStats = {
|
||||
totalProperties: properties.length,
|
||||
availableToday: properties.filter(p => {
|
||||
const today = new Date();
|
||||
const isBooked = p.bookings?.some(b => {
|
||||
const start = new Date(b.startDate);
|
||||
const end = new Date(b.endDate);
|
||||
return today >= start && today <= end;
|
||||
});
|
||||
return !isBooked;
|
||||
}).length,
|
||||
bookedToday: properties.filter(p => {
|
||||
const today = new Date();
|
||||
return p.bookings?.some(b => {
|
||||
const start = new Date(b.startDate);
|
||||
const end = new Date(b.endDate);
|
||||
return today >= start && today <= end;
|
||||
});
|
||||
}).length,
|
||||
upcomingBookings: properties.reduce((sum, p) => sum + (p.bookings?.length || 0), 0)
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل التقويم...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<PropertyDetailsModal
|
||||
property={selectedProperty}
|
||||
isOpen={!!selectedProperty}
|
||||
onClose={() => setSelectedProperty(null)}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">تقويم العقارات</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.name}، تتبع حالة عقاراتك عبر التقويم</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
فلترة العقارات
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
<Printer className="w-5 h-5" />
|
||||
طباعة التقويم
|
||||
</button> */}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<Building className="w-6 h-6 text-amber-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-gray-900">{calendarStats.totalProperties}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي العقارات</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-green-600">{calendarStats.availableToday}</div>
|
||||
<div className="text-sm text-gray-600">متاح اليوم</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<XCircle className="w-6 h-6 text-red-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-red-600">{calendarStats.bookedToday}</div>
|
||||
<div className="text-sm text-gray-600">محجوز اليوم</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
|
||||
>
|
||||
<CalendarDays className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-blue-600">{calendarStats.upcomingBookings}</div>
|
||||
<div className="text-sm text-gray-600">حجوزات قادمة</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<label className="text-sm font-medium text-gray-700">اختر عقاراً:</label>
|
||||
<select
|
||||
value={selectedPropertyId}
|
||||
onChange={(e) => setSelectedPropertyId(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="all">جميع العقارات</option>
|
||||
{properties.map((property) => (
|
||||
<option key={property.id} value={property.id}>{property.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSelectedPropertyId('all')}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إعادة تعيين
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mb-8">
|
||||
<MonthlyCalendar
|
||||
properties={filteredProperties}
|
||||
selectedPropertyId={selectedPropertyId}
|
||||
onDateClick={setSelectedDate}
|
||||
onPropertySelect={setSelectedProperty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PropertyCalendarList
|
||||
properties={filteredProperties}
|
||||
selectedDate={selectedDate}
|
||||
onPropertyClick={setSelectedProperty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-6 text-center text-sm text-gray-500"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 inline ml-1" />
|
||||
اضغط على أي عقار لعرض التفاصيل الكاملة
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/profits/error.js
Normal file
27
app/owner/profits/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/profits/loading.js
Normal file
14
app/owner/profits/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
592
app/owner/profits/page.js
Normal file
592
app/owner/profits/page.js
Normal file
@ -0,0 +1,592 @@
|
||||
// 'use client';
|
||||
|
||||
// import { useState, useEffect } from 'react';
|
||||
// import { motion } from 'framer-motion';
|
||||
// import { useRouter } from 'next/navigation';
|
||||
// import {
|
||||
// DollarSign,
|
||||
// TrendingUp,
|
||||
// Wallet,
|
||||
// Star,
|
||||
// Eye,
|
||||
// Download,
|
||||
// CalendarDays
|
||||
// } from 'lucide-react';
|
||||
// import toast, { Toaster } from 'react-hot-toast';
|
||||
// import AuthService from '@/app/services/AuthService';
|
||||
|
||||
// const StatCard = ({ title, value, icon: Icon, color }) => {
|
||||
// return (
|
||||
// <motion.div
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
|
||||
// >
|
||||
// <div className="flex items-center justify-between mb-4">
|
||||
// <div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
|
||||
// <Icon className="w-6 h-6 text-white" />
|
||||
// </div>
|
||||
// </div>
|
||||
// <h3 className="text-sm text-gray-500 mb-1">{title}</h3>
|
||||
// <div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
// </motion.div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const PropertyProfitCard = ({ property, onViewDetails }) => {
|
||||
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
|
||||
|
||||
// return (
|
||||
// <motion.div
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
// >
|
||||
// <div className="p-5">
|
||||
// <div className="flex justify-between items-start mb-4">
|
||||
// <div>
|
||||
// <h3 className="font-bold text-lg text-gray-900">{property.title}</h3>
|
||||
// {property.isNotSeized && (
|
||||
// <span className="inline-block mt-1 px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
|
||||
// غير محجوز
|
||||
// </span>
|
||||
// )}
|
||||
// </div>
|
||||
// <span className="text-xs text-gray-500">{property.location}</span>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-3 gap-4 mb-4">
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">الإيرادات</div>
|
||||
// <div className="text-lg font-bold text-amber-600">{formatCurrency(property.revenue)}</div>
|
||||
// </div>
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">العمولة</div>
|
||||
// <div className="text-lg font-bold text-blue-600">{formatCurrency(property.commission)}</div>
|
||||
// </div>
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">المتبقي</div>
|
||||
// <div className="text-lg font-bold text-green-600">{formatCurrency(property.remaining)}</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="flex justify-between items-center pt-3 border-t border-gray-100">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <Star className="w-4 h-4 text-amber-500" />
|
||||
// <span className="text-sm font-medium text-gray-700">التقييم العام:</span>
|
||||
// <span className="text-sm font-medium text-gray-900">{property.valuation}</span>
|
||||
// </div>
|
||||
// <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
// <CalendarDays className="w-4 h-4" />
|
||||
// <span>مؤجر {property.rentedCount} مرة</span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <button
|
||||
// onClick={() => onViewDetails(property)}
|
||||
// className="w-full mt-4 py-2 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
|
||||
// >
|
||||
// <Eye className="w-4 h-4" />
|
||||
// عرض التفاصيل
|
||||
// </button>
|
||||
// </div>
|
||||
// </motion.div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const PropertyCalendar = ({ year, month }) => {
|
||||
// const [currentMonth, setCurrentMonth] = useState(new Date(year, month - 1));
|
||||
// const monthNames = ['يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'];
|
||||
// const weekDays = ['إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت', 'أحد'];
|
||||
|
||||
// const getDaysInMonth = (date) => {
|
||||
// return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
// };
|
||||
|
||||
// const getFirstDayOfMonth = (date) => {
|
||||
// const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
// return day === 0 ? 6 : day - 1;
|
||||
// };
|
||||
|
||||
// const daysInMonth = getDaysInMonth(currentMonth);
|
||||
// const firstDayIndex = getFirstDayOfMonth(currentMonth);
|
||||
|
||||
// const cells = [];
|
||||
// for (let i = 0; i < firstDayIndex; i++) {
|
||||
// cells.push(<div key={`empty-${i}`} className="p-2 md:p-3 text-center" />);
|
||||
// }
|
||||
// for (let d = 1; d <= daysInMonth; d++) {
|
||||
// cells.push(
|
||||
// <div
|
||||
// key={d}
|
||||
// className="p-2 md:p-3 text-center rounded-xl hover:bg-gray-100 transition-colors"
|
||||
// >
|
||||
// {d}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
// <div className="flex justify-between items-center mb-6">
|
||||
// <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
// <CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
// {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
// </h3>
|
||||
// <div className="flex gap-2">
|
||||
// <button
|
||||
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
|
||||
// className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
// >
|
||||
// ←
|
||||
// </button>
|
||||
// <button
|
||||
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
|
||||
// className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
// >
|
||||
// →
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
|
||||
// {weekDays.map(day => (
|
||||
// <div key={day}>{day}</div>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-7 gap-1">
|
||||
// {cells}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default function OwnerProfitsPage() {
|
||||
// const router = useRouter();
|
||||
// const [user, setUser] = useState(null);
|
||||
// const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// const [summary] = useState({
|
||||
// totalRevenue: 4290,
|
||||
// totalCommission: 644,
|
||||
// remainingBalance: 3647,
|
||||
// });
|
||||
|
||||
// const [properties] = useState([
|
||||
// {
|
||||
// id: 1,
|
||||
// title: 'Damascus Olive Residence',
|
||||
// location: 'دمشق، المزة',
|
||||
// isNotSeized: true,
|
||||
// revenue: 3240,
|
||||
// commission: 486,
|
||||
// remaining: 2754,
|
||||
// valuation: 'جيد جدا',
|
||||
// rentedCount: 18,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (AuthService.isGuest()) {
|
||||
// router.push('/auth/choose-role');
|
||||
// return;
|
||||
// }
|
||||
// if (!AuthService.isOwner()) {
|
||||
// router.push('/');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const authUser = AuthService.getUser();
|
||||
// if (authUser) {
|
||||
// setUser({
|
||||
// name: authUser.name || authUser.email,
|
||||
// email: authUser.email,
|
||||
// });
|
||||
// }
|
||||
// setIsLoading(false);
|
||||
// }, [router]);
|
||||
|
||||
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
|
||||
|
||||
// const handleViewDetails = (property) => {
|
||||
// toast.info(`عرض تفاصيل ${property.title}`);
|
||||
// };
|
||||
|
||||
// const handleExportReport = () => {
|
||||
// toast.success('جاري تصدير التقرير...');
|
||||
// };
|
||||
|
||||
// if (isLoading) {
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center">
|
||||
// <div className="text-center">
|
||||
// <div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
// <p className="text-gray-600">جاري التحميل...</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="min-h-screen bg-gray-50 py-8">
|
||||
// <Toaster position="top-center" reverseOrder={false} />
|
||||
// <div className="container mx-auto px-4 max-w-6xl">
|
||||
// <div className="mb-8">
|
||||
// <h1 className="text-3xl font-bold text-gray-900 mb-2">دفتر الحسابات</h1>
|
||||
// <p className="text-gray-600">نظرة عامة على أرباح المالك</p>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
// <StatCard
|
||||
// title="الإيرادات"
|
||||
// value={formatCurrency(summary.totalRevenue)}
|
||||
// icon={DollarSign}
|
||||
// color="bg-green-500"
|
||||
// />
|
||||
// <StatCard
|
||||
// title="العمولة"
|
||||
// value={formatCurrency(summary.totalCommission)}
|
||||
// icon={TrendingUp}
|
||||
// color="bg-blue-500"
|
||||
// />
|
||||
// <StatCard
|
||||
// title="المتبقي"
|
||||
// value={formatCurrency(summary.remainingBalance)}
|
||||
// icon={Wallet}
|
||||
// color="bg-amber-500"
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <div className="mb-12">
|
||||
// <h2 className="text-xl font-bold text-gray-900 mb-4">عقاراتي</h2>
|
||||
// <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
// {properties.map((property) => (
|
||||
// <PropertyProfitCard
|
||||
// key={property.id}
|
||||
// property={property}
|
||||
// onViewDetails={handleViewDetails}
|
||||
// />
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mb-12">
|
||||
// <h2 className="text-xl font-bold text-gray-900 mb-4">تقويم العقار</h2>
|
||||
// <PropertyCalendar year={2026} month={3} />
|
||||
// </div>
|
||||
|
||||
// {/* <div className="flex justify-end">
|
||||
// <button
|
||||
// onClick={handleExportReport}
|
||||
// className="px-6 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
|
||||
// >
|
||||
// <Download className="w-5 h-5" />
|
||||
// تصدير التقرير
|
||||
// </button>
|
||||
// </div> */}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import * as XLSX from 'xlsx';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function OwnerProfitsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [tableData, setTableData] = useState([]);
|
||||
|
||||
const sampleData = [
|
||||
{
|
||||
id: 1,
|
||||
property: 'A000000001',
|
||||
bookingNumber: 'XX-101',
|
||||
fromDate: '2025-05-01',
|
||||
toDate: '2025-05-07',
|
||||
amountReceived: 500,
|
||||
platformCommission: 0,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: '—',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
property: 'A000000002',
|
||||
bookingNumber: 'XX-202',
|
||||
fromDate: '2025-05-10',
|
||||
toDate: '2025-05-15',
|
||||
amountReceived: 300,
|
||||
platformCommission: 0,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: '—',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
property: 'A000000003',
|
||||
bookingNumber: 'XX-309',
|
||||
fromDate: '2025-06-01',
|
||||
toDate: '2025-06-05',
|
||||
amountReceived: 800,
|
||||
platformCommission: 150,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: 'قيد الانتظار',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const computeRows = (data) => {
|
||||
return data.map((item) => {
|
||||
const platformProfit = item.amountReceived * 0.05;
|
||||
const ownerDue = item.amountReceived - platformProfit;
|
||||
return {
|
||||
...item,
|
||||
platformProfit,
|
||||
ownerDue,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isGuest()) {
|
||||
router.push('/auth/choose-role');
|
||||
return;
|
||||
}
|
||||
if (!AuthService.isOwner()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
});
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem('ownerProfitsTable');
|
||||
if (stored) {
|
||||
setTableData(computeRows(JSON.parse(stored)));
|
||||
} else {
|
||||
setTableData(computeRows(sampleData));
|
||||
localStorage.setItem('ownerProfitsTable', JSON.stringify(sampleData));
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [router]);
|
||||
|
||||
const totals = tableData.reduce(
|
||||
(acc, row) => {
|
||||
acc.totalAmountReceived += row.amountReceived;
|
||||
acc.totalCommission += row.platformCommission;
|
||||
acc.totalPlatformProfit += row.platformProfit;
|
||||
acc.totalOwnerDue += row.ownerDue;
|
||||
acc.totalTransferred += row.transferredToOwner;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalAmountReceived: 0,
|
||||
totalCommission: 0,
|
||||
totalPlatformProfit: 0,
|
||||
totalOwnerDue: 0,
|
||||
totalTransferred: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const handleExportReport = () => {
|
||||
try {
|
||||
const exportData = tableData.map((row) => ({
|
||||
'العقار': row.property,
|
||||
'رقم الحجز': row.bookingNumber,
|
||||
'من تاريخ': row.fromDate,
|
||||
'حتى تاريخ': row.toDate,
|
||||
'العروض المستلم': row.amountReceived,
|
||||
'عمولة المنصة': row.platformCommission,
|
||||
'ربح المنصة (5%)': row.platformProfit,
|
||||
'المستحق للمالك': row.ownerDue,
|
||||
'تم التحويل للمالك': row.transferredToOwner,
|
||||
'رقم وصل التحويل': row.transferReceipt,
|
||||
}));
|
||||
|
||||
exportData.push({
|
||||
'العقار': 'الإجمالي العام',
|
||||
'رقم الحجز': '',
|
||||
'من تاريخ': '',
|
||||
'حتى تاريخ': '',
|
||||
'العروض المستلم': totals.totalAmountReceived,
|
||||
'عمولة المنصة': totals.totalCommission,
|
||||
'ربح المنصة (5%)': totals.totalPlatformProfit,
|
||||
'المستحق للمالك': totals.totalOwnerDue,
|
||||
'تم التحويل للمالك': totals.totalTransferred,
|
||||
'رقم وصل التحويل': '—',
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
const colWidths = [
|
||||
{ wch: 15 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 14 },
|
||||
{ wch: 14 },
|
||||
{ wch: 16 },
|
||||
{ wch: 16 },
|
||||
{ wch: 16 },
|
||||
{ wch: 18 },
|
||||
];
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'أرباح المالك');
|
||||
|
||||
XLSX.writeFile(workbook, `تقرير_الأرباح_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
|
||||
|
||||
toast.success('تم تصدير التقرير بنجاح!');
|
||||
} catch (error) {
|
||||
console.error('خطأ في التصدير:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير التقرير');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل بيانات الأرباح...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
|
||||
<p className="text-gray-600">
|
||||
مرحباً {user?.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportReport}
|
||||
className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-800 text-gray-100">
|
||||
<tr>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العقار</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم الحجز</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">من تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">حتى تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العروض المستلم</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">عمولة المنصة</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider bg-amber-50 text-amber-800">
|
||||
ربح المنصة <span className="font-normal text-[11px] block">(5% من العربون)</span>
|
||||
</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">المستحق للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">تم التحويل للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم وصل التحويل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{tableData.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`hover:bg-amber-50/40 transition-colors ${
|
||||
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-medium text-gray-800">
|
||||
{row.property}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.bookingNumber}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.fromDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.toDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-gray-800">
|
||||
{row.amountReceived}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
|
||||
{row.platformCommission}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-bold text-amber-700 bg-amber-50/50">
|
||||
{row.platformProfit}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-emerald-700">
|
||||
{row.ownerDue}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
|
||||
{row.transferredToOwner}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-500 text-xs">
|
||||
{row.transferReceipt}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-100 border-t-2 border-gray-300">
|
||||
<tr>
|
||||
<td colSpan="4" className="px-4 py-4 text-right font-bold text-gray-800">
|
||||
الإجمالي العام
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalAmountReceived}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalCommission}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-amber-700 bg-amber-100/60">
|
||||
{totals.totalPlatformProfit}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-emerald-700">
|
||||
{totals.totalOwnerDue}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalTransferred}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center text-gray-500">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
|
||||
<span className="inline-flex items-center gap-1"></span> ملاحظة:
|
||||
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/owner/properties/add/error.js
Normal file
27
app/owner/properties/add/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/properties/add/loading.js
Normal file
14
app/owner/properties/add/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -51,12 +51,27 @@ import {
|
||||
Move
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addRentProperty, getCurrencies, uploadPicture } from '../../../utils/api';
|
||||
import {
|
||||
BuildingType,
|
||||
RentPropertyCondition,
|
||||
RentPropertyType,
|
||||
RentType,
|
||||
PropertyService,
|
||||
PropertyServiceLabels,
|
||||
PropertyServicesList,
|
||||
PropertyTerm,
|
||||
PropertyTermLabels,
|
||||
PropertyTermsList,
|
||||
Currency,
|
||||
CurrencyLabels
|
||||
} from '../../../enums';
|
||||
|
||||
const MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false });
|
||||
const useMapEvents = dynamic(() => import('react-leaflet').then(mod => mod.useMapEvents), { ssr: false });
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
|
||||
function MapClickHandler({ onMapClick }) {
|
||||
const map = useMapEvents({
|
||||
@ -84,29 +99,27 @@ export default function AddPropertyPage() {
|
||||
livingRooms: 1,
|
||||
|
||||
services: {
|
||||
electricity: false,
|
||||
internet: false,
|
||||
heating: false,
|
||||
water: false,
|
||||
airConditioning: false,
|
||||
parking: false,
|
||||
elevator: false
|
||||
[PropertyService.ELECTRICITY]: false,
|
||||
[PropertyService.INTERNET]: false,
|
||||
[PropertyService.HEATING]: false,
|
||||
[PropertyService.WATER]: false,
|
||||
[PropertyService.CENTRAL_AIR_CONDITIONING]: false,
|
||||
[PropertyService.PARKING]: false,
|
||||
[PropertyService.ELEVATOR]: false
|
||||
},
|
||||
|
||||
serviceDetails: {},
|
||||
|
||||
terms: {
|
||||
noSmoking: false,
|
||||
noPets: false,
|
||||
noParties: false,
|
||||
noAlcohol: false,
|
||||
suitableForChildren: true,
|
||||
suitableForElderly: true
|
||||
[PropertyTerm.NO_SMOKING]: false,
|
||||
[PropertyTerm.NO_ANIMALS]: false,
|
||||
[PropertyTerm.NO_PARTIES]: false
|
||||
},
|
||||
|
||||
offerType: 'daily',
|
||||
|
||||
dailyPrice: '',
|
||||
monthlyPrice: '',
|
||||
salePrice: '',
|
||||
|
||||
city: '',
|
||||
district: '',
|
||||
@ -120,11 +133,15 @@ export default function AddPropertyPage() {
|
||||
});
|
||||
|
||||
const [imagePreviews, setImagePreviews] = useState([]);
|
||||
const [uploadedImagePaths, setUploadedImagePaths] = useState([]);
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||
const [mapCenter, setMapCenter] = useState([33.5138, 36.2765]);
|
||||
const [mapZoom, setMapZoom] = useState(13);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [selectedCurrencyId, setSelectedCurrencyId] = useState(Currency.SYP);
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
@ -140,30 +157,26 @@ export default function AddPropertyPage() {
|
||||
];
|
||||
|
||||
const serviceList = [
|
||||
{ id: 'electricity', label: 'كهرباء', icon: Zap },
|
||||
{ id: 'internet', label: 'انترنت', icon: Wifi },
|
||||
{ id: 'heating', label: 'تدفئة', icon: Flame },
|
||||
{ id: 'water', label: 'ماء', icon: Droplets },
|
||||
{ id: 'airConditioning', label: 'تكييف', icon: Wind },
|
||||
{ id: 'parking', label: 'موقف سيارات', icon: Warehouse },
|
||||
{ id: 'elevator', label: 'مصعد', icon: Layers }
|
||||
{ id: PropertyService.ELECTRICITY, label: PropertyServiceLabels[PropertyService.ELECTRICITY], icon: Zap },
|
||||
{ id: PropertyService.INTERNET, label: PropertyServiceLabels[PropertyService.INTERNET], icon: Wifi },
|
||||
{ id: PropertyService.HEATING, label: PropertyServiceLabels[PropertyService.HEATING], icon: Flame },
|
||||
{ id: PropertyService.WATER, label: PropertyServiceLabels[PropertyService.WATER], icon: Droplets },
|
||||
{ id: PropertyService.CENTRAL_AIR_CONDITIONING, label: PropertyServiceLabels[PropertyService.CENTRAL_AIR_CONDITIONING], icon: Wind },
|
||||
{ id: PropertyService.PARKING, label: PropertyServiceLabels[PropertyService.PARKING], icon: Warehouse },
|
||||
{ id: PropertyService.ELEVATOR, label: PropertyServiceLabels[PropertyService.ELEVATOR], icon: Layers },
|
||||
];
|
||||
|
||||
const termsList = [
|
||||
{ id: 'noSmoking', label: 'ممنوع التدخين', icon: Cigarette },
|
||||
{ id: 'noPets', label: 'ممنوع الحيوانات', icon: Dog },
|
||||
{ id: 'noParties', label: 'عدم إقامة حفلات', icon: Music },
|
||||
{ id: 'noAlcohol', label: 'ممنوع الكحول', icon: X },
|
||||
{ id: 'suitableForChildren', label: 'مناسب للأطفال', icon: Star },
|
||||
{ id: 'suitableForElderly', label: 'مناسب لكبار السن', icon: Star }
|
||||
{ id: PropertyTerm.NO_SMOKING, label: PropertyTermLabels[PropertyTerm.NO_SMOKING], icon: Cigarette },
|
||||
{ id: PropertyTerm.NO_ANIMALS, label: PropertyTermLabels[PropertyTerm.NO_ANIMALS], icon: Dog },
|
||||
{ id: PropertyTerm.NO_PARTIES, label: PropertyTermLabels[PropertyTerm.NO_PARTIES], icon: Music },
|
||||
];
|
||||
|
||||
const offerTypes = [
|
||||
{ id: 'daily', label: 'إيجار يومي', icon: Clock },
|
||||
{ id: 'monthly', label: 'إيجار شهري', icon: Calendar },
|
||||
{ id: 'both', label: 'إيجار يومي وشهري', icon: Calendar },
|
||||
{ id: 'sale', label: 'للبيع', icon: DollarSign }
|
||||
];
|
||||
].filter(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -176,6 +189,16 @@ export default function AddPropertyPage() {
|
||||
});
|
||||
}
|
||||
setMapLoaded(true);
|
||||
|
||||
// Fetch available currencies
|
||||
getCurrencies().then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setCurrencies(data);
|
||||
console.log('[AddProperty] Currencies loaded:', data);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[AddProperty] Failed to load currencies:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
@ -317,36 +340,48 @@ const handleMapClick = async (coords) => {
|
||||
toast.info('تم إلغاء تحديد الموقع');
|
||||
};
|
||||
|
||||
const handleImageUpload = (files) => {
|
||||
const handleImageUpload = async (files) => {
|
||||
const newImages = Array.from(files);
|
||||
console.log('[AddProperty] handleImageUpload called with', newImages.length, 'files');
|
||||
|
||||
if (formData.images.length + newImages.length > 10) {
|
||||
toast.error('يمكنك رفع 10 صور كحد أقصى');
|
||||
return;
|
||||
}
|
||||
|
||||
newImages.forEach(file => {
|
||||
for (const file of newImages) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('الرجاء اختيار صور صالحة فقط');
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews(prev => [...prev, reader.result]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
images: [...formData.images, file]
|
||||
});
|
||||
});
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, file]
|
||||
}));
|
||||
|
||||
// Upload to server immediately
|
||||
try {
|
||||
const path = await uploadPicture(file);
|
||||
setUploadedImagePaths(prev => [...prev, path]);
|
||||
console.log('[AddProperty] Image uploaded:', path);
|
||||
} catch (err) {
|
||||
console.error('[AddProperty] Image upload failed:', err);
|
||||
toast.error('فشل رفع الصورة: ' + file.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index) => {
|
||||
@ -356,30 +391,34 @@ const handleMapClick = async (coords) => {
|
||||
const newPreviews = [...imagePreviews];
|
||||
newPreviews.splice(index, 1);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
images: newImages
|
||||
});
|
||||
const newPaths = [...uploadedImagePaths];
|
||||
newPaths.splice(index, 1);
|
||||
|
||||
setFormData(prev => ({ ...prev, images: newImages }));
|
||||
setImagePreviews(newPreviews);
|
||||
setUploadedImagePaths(newPaths);
|
||||
};
|
||||
|
||||
const toggleService = (serviceId) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
services: {
|
||||
...formData.services,
|
||||
[serviceId]: !formData.services[serviceId]
|
||||
}
|
||||
setFormData(prev => {
|
||||
const services = { ...prev.services };
|
||||
services[serviceId] = !services[serviceId];
|
||||
return { ...prev, services };
|
||||
});
|
||||
};
|
||||
|
||||
const updateServiceDetail = (serviceId, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
serviceDetails: { ...prev.serviceDetails, [serviceId]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleTerm = (termId) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
terms: {
|
||||
...formData.terms,
|
||||
[termId]: !formData.terms[termId]
|
||||
}
|
||||
setFormData(prev => {
|
||||
const terms = { ...prev.terms };
|
||||
terms[termId] = !terms[termId];
|
||||
return { ...prev, terms };
|
||||
});
|
||||
};
|
||||
|
||||
@ -464,9 +503,6 @@ const handleMapClick = async (coords) => {
|
||||
if (!formData.dailyPrice) newErrors.dailyPrice = 'السعر اليومي مطلوب';
|
||||
if (!formData.monthlyPrice) newErrors.monthlyPrice = 'السعر الشهري مطلوب';
|
||||
}
|
||||
if (formData.offerType === 'sale' && !formData.salePrice) {
|
||||
newErrors.salePrice = 'سعر البيع مطلوب';
|
||||
}
|
||||
break;
|
||||
|
||||
case 4:
|
||||
@ -499,16 +535,92 @@ const handleMapClick = async (coords) => {
|
||||
if (!validateStep()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
console.log('[AddProperty] Building RentPropertyDto payload...');
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Property Data:', formData);
|
||||
setIsLoading(false);
|
||||
// Map UI property type to API BuildingType enum
|
||||
const buildingTypeMap = { apartment: BuildingType.APARTMENT, villa: BuildingType.VILLA, suite: BuildingType.APARTMENT, room: BuildingType.APARTMENT };
|
||||
|
||||
// Map offer type to RentType enum: 0=Monthly, 1=Daily
|
||||
const rentTypeMap = { daily: RentType.DAILY, monthly: RentType.MONTHLY, both: RentType.MONTHLY };
|
||||
|
||||
// Services: collect selected service enum names into array
|
||||
const selectedServices = Object.entries(formData.services)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k); // k is already the enum value (e.g. "Electricity")
|
||||
|
||||
// Terms: collect selected term enum names into array
|
||||
const selectedTerms = Object.entries(formData.terms)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k); // k is already the enum value (e.g. "NoSmoking")
|
||||
|
||||
// Build detailsJSON matching Flutter structure
|
||||
const detailsJSON = JSON.stringify({
|
||||
services: selectedServices,
|
||||
serviceDetails: selectedServices.reduce((acc, s) => ({ ...acc, [s]: 'in general' }), {}),
|
||||
terms: selectedTerms,
|
||||
displayType: formData.offerType === 'both' ? 'Both' : formData.offerType === 'daily' ? 'Daily' : 'Monthly',
|
||||
propertyCondition: formData.furnished ? 'Furnished' : 'Unfurnished',
|
||||
photos: imagePreviews.map((_, i) => `photo_${i}.jpg`),
|
||||
room: {
|
||||
areaType: formData.propertyType === 'room' ? 'Shared room' : 'Private room',
|
||||
peopleAllowed: String(formData.bedrooms),
|
||||
entranceType: formData.propertyType === 'room' ? 'Shared entrance' : 'Private entrance',
|
||||
bathroomType: formData.bathrooms > 1 ? 'Private' : 'Shared',
|
||||
kitchenType: 'Not available',
|
||||
hasRestrictedOwnerAreas: false,
|
||||
languageDialect: '',
|
||||
hasChildren: false,
|
||||
hasPets: false,
|
||||
dedicatedTo: 'Everyone',
|
||||
visitorsAllowed: true,
|
||||
quietTimesEnabled: false,
|
||||
quietTimes: '',
|
||||
}
|
||||
});
|
||||
|
||||
const payload = {
|
||||
propertyInformation: {
|
||||
cordsX: formData.lat ? String(formData.lat) : '',
|
||||
cordsY: formData.lng ? String(formData.lng) : '',
|
||||
address: `${formData.city} - ${formData.district} - ${formData.address}`.trim(),
|
||||
description: formData.description || '',
|
||||
numberOfBathRooms: formData.bathrooms || 0,
|
||||
numberOfRooms: (formData.bedrooms || 0) + (formData.livingRooms || 0),
|
||||
numberOfBedRooms: formData.bedrooms || 0,
|
||||
space: parseFloat(formData.space) || 0,
|
||||
detailsJSON,
|
||||
buildingType: buildingTypeMap[formData.propertyType] ?? BuildingType.APARTMENT,
|
||||
status: 0,
|
||||
propertyType: formData.furnished ? RentPropertyCondition.WITH_FURNITURE : RentPropertyCondition.WITHOUT_FURNITURE,
|
||||
images: uploadedImagePaths,
|
||||
},
|
||||
deposit: parseFloat(formData.deposit) || 0,
|
||||
monthlyRent: parseFloat(formData.monthlyPrice) || 0,
|
||||
dailyRent: parseFloat(formData.dailyPrice) || 0,
|
||||
rating: 0,
|
||||
currencyId: selectedCurrencyId,
|
||||
rentType: rentTypeMap[formData.offerType] ?? RentType.MONTHLY,
|
||||
isSmokeAllow: !formData.terms[PropertyTerm.NO_SMOKING],
|
||||
specializedFor: false,
|
||||
isVisitorAllow: !formData.terms[PropertyTerm.NO_PARTIES],
|
||||
type: formData.furnished ? RentPropertyType.FURNISHED : RentPropertyType.UNFURNISHED,
|
||||
};
|
||||
|
||||
console.log('[AddProperty] Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
try {
|
||||
const res = await addRentProperty(payload);
|
||||
console.log('[AddProperty] API response:', res);
|
||||
toast.success('تم إضافة العقار بنجاح!');
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/owner/properties');
|
||||
}, 1500);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('[AddProperty] API error:', err);
|
||||
toast.error(err.message || 'فشل في إضافة العقار');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
@ -517,15 +629,6 @@ const handleMapClick = async (coords) => {
|
||||
transition: { duration: 0.5 }
|
||||
};
|
||||
|
||||
function MapClickHandler({ onMapClick }) {
|
||||
const map = useMapEvents({
|
||||
dblclick: (e) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
onMapClick([lat, lng]);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
@ -752,34 +855,37 @@ function MapClickHandler({ onMapClick }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة <span className="text-red-500">*</span></h3>
|
||||
<div className="space-y-3">
|
||||
{serviceList.map((service) => {
|
||||
const Icon = service.icon;
|
||||
const isSelected = formData.services[service.id];
|
||||
return (
|
||||
<label
|
||||
key={service.id}
|
||||
className={`flex items-center gap-2 p-3 border rounded-xl cursor-pointer transition-all ${
|
||||
formData.services[service.id]
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-gray-200 hover:border-amber-200 hover:bg-amber-50/50'
|
||||
}`}
|
||||
>
|
||||
<div key={service.id} className={`border rounded-xl transition-all ${isSelected ? 'border-amber-500 bg-amber-50' : 'border-gray-200'}`}>
|
||||
<label className="flex items-center gap-3 p-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.services[service.id]}
|
||||
checked={isSelected}
|
||||
onChange={() => toggleService(service.id)}
|
||||
className="hidden"
|
||||
className="w-4 h-4 text-amber-500 rounded"
|
||||
/>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
formData.services[service.id] ? 'text-amber-600' : 'text-gray-400'
|
||||
}`} />
|
||||
<span className={`text-sm ${
|
||||
formData.services[service.id] ? 'text-amber-700' : 'text-gray-600'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-amber-700' : 'text-gray-600'}`}>
|
||||
{service.label}
|
||||
</span>
|
||||
</label>
|
||||
{isSelected && (
|
||||
<div className="px-3 pb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serviceDetails[service.id] || ''}
|
||||
onChange={(e) => updateServiceDetail(service.id, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="تفاصيل الخدمة (مثال: في جميع الغرف)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -857,6 +963,41 @@ function MapClickHandler({ onMapClick }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currency dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
العملة <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedCurrencyId}
|
||||
onChange={(e) => setSelectedCurrencyId(parseInt(e.target.value))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
{Object.entries(CurrencyLabels).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deposit field */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
مبلغ الضمان (العربون)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
value={formData.deposit || ''}
|
||||
onChange={(e) => setFormData({...formData, deposit: e.target.value})}
|
||||
className="w-full pr-12 pl-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="مثال: 500000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{(formData.offerType === 'daily' || formData.offerType === 'both') && (
|
||||
<motion.div
|
||||
@ -919,37 +1060,6 @@ function MapClickHandler({ onMapClick }) {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{formData.offerType === 'sale' && (
|
||||
<motion.div
|
||||
key="sale"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
سعر البيع (ل.س) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
value={formData.salePrice}
|
||||
onChange={(e) => setFormData({...formData, salePrice: e.target.value})}
|
||||
className={`w-full pr-12 pl-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||
errors.salePrice ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="أدخل السعر المطلوب"
|
||||
/>
|
||||
</div>
|
||||
{errors.salePrice && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.salePrice}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
27
app/owner/properties/error.js
Normal file
27
app/owner/properties/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/owner/properties/loading.js
Normal file
14
app/owner/properties/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -45,6 +45,8 @@ import {
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { getMyRentListings } from '../../utils/api';
|
||||
|
||||
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
||||
if (!isOpen) return null;
|
||||
@ -692,70 +694,84 @@ export default function OwnerPropertiesPage() {
|
||||
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (userData.role !== 'owner') {
|
||||
router.push('/');
|
||||
} else {
|
||||
setUser(userData);
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadProperties();
|
||||
}
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const loadProperties = () => {
|
||||
const storedProperties = localStorage.getItem('ownerProperties');
|
||||
if (storedProperties) {
|
||||
setProperties(JSON.parse(storedProperties));
|
||||
} else {
|
||||
const mockProperties = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
propertyType: 'villa',
|
||||
purpose: 'rent',
|
||||
rentType: 'both',
|
||||
dailyPrice: 500000,
|
||||
monthlyPrice: 15000000,
|
||||
location: 'دمشق، المزة',
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
livingRooms: 3,
|
||||
status: 'available',
|
||||
images: ['/villa1.jpg'],
|
||||
createdAt: new Date().toISOString(),
|
||||
furnished: true,
|
||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح',
|
||||
address: 'شارع المزة - فيلات غربية',
|
||||
city: 'دمشق',
|
||||
district: 'المزة',
|
||||
services: {
|
||||
electricity: true,
|
||||
internet: true,
|
||||
heating: true,
|
||||
water: true,
|
||||
airConditioning: true,
|
||||
parking: true,
|
||||
elevator: false
|
||||
},
|
||||
terms: {
|
||||
noSmoking: true,
|
||||
noPets: false,
|
||||
noParties: true,
|
||||
noAlcohol: false,
|
||||
suitableForChildren: true,
|
||||
suitableForElderly: true
|
||||
}
|
||||
}
|
||||
];
|
||||
setProperties(mockProperties);
|
||||
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties));
|
||||
}
|
||||
|
||||
|
||||
const loadProperties = async () => {
|
||||
const authUser = AuthService.getUser();
|
||||
const userId = authUser?.id;
|
||||
|
||||
if (!userId) {
|
||||
console.warn('[OwnerProperties] No user ID found');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[OwnerProperties] Fetching listings for user:', userId);
|
||||
const data = await getMyRentListings();
|
||||
const list = Array.isArray(data) ? data : (data ? [data] : []);
|
||||
console.log('[OwnerProperties] API returned:', list.length, 'properties');
|
||||
|
||||
const mapped = list.map((item) => {
|
||||
const info = item.propertyInformation || {};
|
||||
const details = (() => {
|
||||
try { return JSON.parse(info.detailsJSON || '{}'); } catch { return {}; }
|
||||
})();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: info.address || `عقار #${item.id}`,
|
||||
propertyType: { 0: 'apartment', 1: 'villa', 2: 'house' }[info.buildingType] || 'apartment',
|
||||
purpose: 'rent',
|
||||
rentType: { 0: 'daily', 1: 'weekly', 2: 'monthly' }[item.rentType] || 'daily',
|
||||
dailyPrice: item.dailyRent || 0,
|
||||
monthlyPrice: item.monthlyRent || 0,
|
||||
deposit: item.deposit || 0,
|
||||
location: info.address || '',
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
livingRooms: details.livingRooms || 0,
|
||||
status: { 0: 'available', 1: 'booked', 2: 'maintenance' }[info.status] || 'available',
|
||||
images: (() => {
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const raw = Array.isArray(info.images) ? info.images : [];
|
||||
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`) : ['/property-placeholder.jpg'];
|
||||
})(),
|
||||
createdAt: item.createdAt || new Date().toISOString(),
|
||||
furnished: details.furnished || false,
|
||||
description: info.description || '',
|
||||
address: info.address || '',
|
||||
city: '',
|
||||
district: '',
|
||||
services: details.services || {},
|
||||
terms: details.terms || {},
|
||||
rating: item.rating || 0,
|
||||
currencyId: item.currencyId,
|
||||
_raw: item,
|
||||
};
|
||||
});
|
||||
|
||||
setProperties(mapped);
|
||||
} catch (err) {
|
||||
console.error('[OwnerProperties] Failed to load properties:', err);
|
||||
toast.error('فشل في تحميل العقارات');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
366
app/page.js
366
app/page.js
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
@ -25,14 +25,91 @@ import {
|
||||
Heart,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
import './i18n/config';
|
||||
import HeroSearch from './components/home/HeroSearch';
|
||||
import PropertyMap from './components/home/PropertyMap';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getRentProperties, getSaleProperties } from './utils/api';
|
||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from './enums';
|
||||
import AuthService from './services/AuthService';
|
||||
|
||||
// Map API property data to the format the UI expects
|
||||
// API returns { propertyInformationId, deposit, monthlyRent, dailyRent, rating, propertyInformation: {...}, ... }
|
||||
function mapApiProperty(item, index) {
|
||||
const info = item.propertyInformation || {};
|
||||
|
||||
const dailyPrice = item.dailyRent ?? 0;
|
||||
const monthlyPrice = item.monthlyRent ?? 0;
|
||||
const salePrice = item.price ?? 0;
|
||||
const isRentListing = Boolean(item.dailyRent != null || item.monthlyRent != null);
|
||||
|
||||
const price = isRentListing ? (dailyPrice || monthlyPrice || 0) : salePrice;
|
||||
const priceUnit = isRentListing ? (monthlyPrice ? 'monthly' : 'daily') : 'sale';
|
||||
|
||||
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? (item.type || 'apartment');
|
||||
const status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
|
||||
|
||||
const features = [];
|
||||
if (item.isSmokeAllow) features.push('يسمح بالتدخين');
|
||||
if (item.isVisitorAllow) features.push('يسمح بالزوار');
|
||||
if (item.specializedFor) features.push('متخصص');
|
||||
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} غرف نوم`);
|
||||
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const rawImages = Array.isArray(info.images) ? info.images : [];
|
||||
const images = rawImages.length > 0
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg'];
|
||||
|
||||
const ownerSource = info.ownerType == null && item.ownerType == null
|
||||
? 'all'
|
||||
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
|
||||
? 'agency'
|
||||
: 'owner';
|
||||
|
||||
return {
|
||||
id: item.id ?? index + 1,
|
||||
title: info.address || `عقار #${item.id || index + 1}`,
|
||||
description: info.description || '',
|
||||
type: propType,
|
||||
price: price,
|
||||
priceUSD: price,
|
||||
priceUnit,
|
||||
listingType: isRentListing ? 'rent' : 'sale',
|
||||
location: {
|
||||
city: extractCity(info.address) || 'دمشق',
|
||||
district: info.address || '',
|
||||
address: info.address || '',
|
||||
lat: parseFloat(info.cordsX) || 0,
|
||||
lng: parseFloat(info.cordsY) || 0,
|
||||
},
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
features,
|
||||
images,
|
||||
status,
|
||||
rating: item.rating || 4.5,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: dailyPrice,
|
||||
monthly: monthlyPrice,
|
||||
sale: salePrice,
|
||||
},
|
||||
ownerSource,
|
||||
bookings: [],
|
||||
_raw: item,
|
||||
};
|
||||
}
|
||||
|
||||
// extractCity is now imported from @/app/enums
|
||||
|
||||
// API-only — no fallback data
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const mapSectionRef = useRef(null);
|
||||
const [searchFilters, setSearchFilters] = useState(null);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
@ -41,12 +118,55 @@ export default function HomePage() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const [allProperties, setAllProperties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Re-read user from JWT on every route change
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: AuthService.isOwner() ? 'owner' : 'customer',
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Fetch properties from API on mount
|
||||
useEffect(() => {
|
||||
|
||||
async function fetchProperties() {
|
||||
try {
|
||||
const [rentData, saleData] = await Promise.all([
|
||||
getRentProperties().catch(() => []),
|
||||
getSaleProperties().catch(() => []),
|
||||
]);
|
||||
|
||||
const rentList = Array.isArray(rentData) ? rentData : [];
|
||||
const saleList = Array.isArray(saleData) ? saleData : [];
|
||||
|
||||
const mapped = [
|
||||
...rentList.map((p, i) => mapApiProperty(p, i)),
|
||||
...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
|
||||
];
|
||||
|
||||
if (mapped.length > 0) {
|
||||
setAllProperties(mapped);
|
||||
}
|
||||
// If API returns empty, keep fallback
|
||||
} catch (err) {
|
||||
console.error('[Home] Failed to fetch properties:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -60,173 +180,25 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('user');
|
||||
AuthService.deleteToken();
|
||||
setUser(null);
|
||||
setShowUserMenu(false);
|
||||
};
|
||||
|
||||
const [allProperties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.',
|
||||
type: 'villa',
|
||||
price: 500000,
|
||||
priceUSD: 50,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'دمشق',
|
||||
district: 'المزة',
|
||||
address: 'شارع المزة - فيلات غربية',
|
||||
lat: 33.5138,
|
||||
lng: 36.2765
|
||||
},
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7', 'تدفئة مركزية', 'تكييف مركزي'],
|
||||
images: ['/villa1.jpg', '/villa2.jpg', '/villa3.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.8,
|
||||
isNew: true,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: 500000,
|
||||
monthly: 15000000
|
||||
},
|
||||
bookings: [
|
||||
{ startDate: '2024-03-10', endDate: '2024-03-15' },
|
||||
{ startDate: '2024-03-20', endDate: '2024-03-25' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
description: 'شقة عصرية في حي الشهباء الراقي بحلب.',
|
||||
type: 'apartment',
|
||||
price: 250000,
|
||||
priceUSD: 25,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'حلب',
|
||||
district: 'الشهباء',
|
||||
address: 'شارع النيل - بناء الرحاب',
|
||||
lat: 36.2021,
|
||||
lng: 37.1347
|
||||
},
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
images: ['/apartment1.jpg', '/apartment2.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.5,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian'],
|
||||
priceDisplay: {
|
||||
daily: 250000,
|
||||
monthly: 7500000
|
||||
},
|
||||
bookings: [
|
||||
{ startDate: '2024-03-05', endDate: '2024-03-08' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
description: 'بيت واسع مناسب للعائلات في حمص.',
|
||||
type: 'house',
|
||||
price: 350000,
|
||||
priceUSD: 35,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'حمص',
|
||||
district: 'بابا عمرو',
|
||||
address: 'حي الزهور',
|
||||
lat: 34.7265,
|
||||
lng: 36.7186
|
||||
},
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
rating: 4.3,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: 350000,
|
||||
monthly: 10500000
|
||||
},
|
||||
bookings: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'شقة بجانب البحر',
|
||||
description: 'شقة رائعة مع إطلالة بحرية في اللاذقية.',
|
||||
type: 'apartment',
|
||||
price: 300000,
|
||||
priceUSD: 30,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'اللاذقية',
|
||||
district: 'الشاطئ الأزرق',
|
||||
address: 'الكورنيش الغربي',
|
||||
lat: 35.5306,
|
||||
lng: 35.7801
|
||||
},
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 200,
|
||||
features: ['إطلالة بحرية', 'شرفة', 'تكييف', 'أمن'],
|
||||
images: ['/seaside1.jpg', '/seaside2.jpg', '/seaside3.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.9,
|
||||
isNew: true,
|
||||
allowedIdentities: ['passport'],
|
||||
priceDisplay: {
|
||||
daily: 300000,
|
||||
monthly: 9000000
|
||||
},
|
||||
bookings: []
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'فيلا في درعا',
|
||||
description: 'فيلا فاخرة في حي الأطباء بدرعا.',
|
||||
type: 'villa',
|
||||
price: 400000,
|
||||
priceUSD: 40,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: 'درعا',
|
||||
district: 'حي الأطباء',
|
||||
address: 'شارع الشفاء',
|
||||
lat: 32.6237,
|
||||
lng: 36.1016
|
||||
},
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 350,
|
||||
features: ['حديقة مثمرة', 'أنظمة أمن', 'مسبح', 'كراج'],
|
||||
images: ['/villa4.jpg', '/villa5.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.6,
|
||||
isNew: false,
|
||||
allowedIdentities: ['syrian', 'passport'],
|
||||
priceDisplay: {
|
||||
daily: 400000,
|
||||
monthly: 12000000
|
||||
},
|
||||
bookings: []
|
||||
}
|
||||
]);
|
||||
|
||||
const applyFilters = (filters) => {
|
||||
setSearchFilters(filters);
|
||||
|
||||
const filtered = allProperties.filter(property => {
|
||||
if (filters.mode === 'rent' && property.listingType !== 'rent') {
|
||||
return false;
|
||||
}
|
||||
if (filters.mode === 'sell' && property.listingType !== 'sale') {
|
||||
return false;
|
||||
}
|
||||
if (filters.mode === 'buy' && property.listingType !== 'sale') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
|
||||
return false;
|
||||
}
|
||||
@ -246,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 (!property.allowedIdentities.includes(filters.identityType)) {
|
||||
return false;
|
||||
@ -338,7 +324,7 @@ export default function HomePage() {
|
||||
visible: { opacity: 1, y: 0 }
|
||||
}}
|
||||
>
|
||||
{t("heroTitleLine1")}<br />
|
||||
إيجاد منزلك الجديد<br />
|
||||
<motion.span
|
||||
className="text-amber-400"
|
||||
animate={{
|
||||
@ -350,7 +336,7 @@ export default function HomePage() {
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
{t("heroTitleLine2")}
|
||||
أصبح سهلاً
|
||||
</motion.span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
@ -360,11 +346,11 @@ export default function HomePage() {
|
||||
visible: { opacity: 1, y: 0 }
|
||||
}}
|
||||
>
|
||||
{t("heroSubtitle")}
|
||||
نوفر قوائم عقارات عالية الجودة لمساعدتك في إيجاد المنزل المثالي
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} />}
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
|
||||
|
||||
{isOwner && (
|
||||
<motion.div
|
||||
@ -379,13 +365,13 @@ export default function HomePage() {
|
||||
<p className="text-gray-200 mb-4">
|
||||
يمكنك إدارة عقاراتك من خلال لوحة التحكم الخاصة بك
|
||||
</p>
|
||||
{/* <Link
|
||||
<Link
|
||||
href="/owner/properties"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Building className="w-5 h-5" />
|
||||
إدارة عقاراتي
|
||||
</Link> */}
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@ -529,6 +515,25 @@ export default function HomePage() {
|
||||
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">مصدر العرض: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.ownerSource === 'all' ? 'الكل' :
|
||||
searchFilters.ownerSource === 'owner' ? 'من المالك' : 'من مكتب عقاري'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">نوع الإيجار: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.rentPeriod === 'all' ? 'الكل' :
|
||||
searchFilters.rentPeriod === 'daily' ? 'إيجار يومي' : 'إيجار شهري'}
|
||||
</span>
|
||||
</div>
|
||||
{searchFilters.availableToday && (
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="font-bold text-gray-900">فقط المتاحة من اليوم</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@ -546,14 +551,11 @@ export default function HomePage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="inline-block px-4 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium mb-4">
|
||||
لماذا نحن؟
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4 tracking-tight">
|
||||
{t("whyChooseUsTitle")}
|
||||
لماذا تختار سويت هوم؟
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto text-lg">
|
||||
{t("whyChooseUsSubtitle")}
|
||||
نجعل عملية إيجاد منزلك المثالي سهلة وسريعة
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@ -571,12 +573,12 @@ export default function HomePage() {
|
||||
<ShieldCheck className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t("feature1Title")}
|
||||
قوائم موثوقة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("feature1Description")}
|
||||
كل عقار يتم التحقق منه بدقة لضمان الدقة والجودة.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@ -593,12 +595,12 @@ export default function HomePage() {
|
||||
<Lock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t("feature2Title")}
|
||||
عمليات آمنة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("feature2Description")}
|
||||
سلامتك هي أولويتنا. نوفر معاملات آمنة ونحمي معلوماتك الشخصية.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@ -615,12 +617,12 @@ export default function HomePage() {
|
||||
<Zap className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t("feature3Title")}
|
||||
نتائج سريعة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("feature3Description")}
|
||||
اعثر على منزلك المثالي في دقائق باستخدام خوارزميات البحث والمطابقة المتقدمة لدينا.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
96
app/payments/page.js
Normal file
96
app/payments/page.js
Normal file
@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Download, Eye } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
const mockPayments = [
|
||||
{
|
||||
id: 1,
|
||||
property: 'فيلا فاخرة في المزة',
|
||||
amount: 2500000,
|
||||
date: '2024-03-10',
|
||||
status: 'completed',
|
||||
invoiceId: 'INV-001'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
property: 'شقة حديثة في الشهباء',
|
||||
amount: 750000,
|
||||
date: '2024-03-05',
|
||||
status: 'completed',
|
||||
invoiceId: 'INV-002'
|
||||
}
|
||||
];
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const router = useRouter();
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setPayments(mockPayments);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => amount?.toLocaleString() + ' ل.س';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">المدفوعات</h1>
|
||||
<p className="text-gray-600">سجل المعاملات المالية والفواتير</p>
|
||||
</div>
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<CreditCard className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد معاملات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا مدفوعاتك للحجوزات</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => (
|
||||
<div key={payment.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 hover:shadow-md transition-all">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{payment.property}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">رقم الفاتورة: {payment.invoiceId}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">{payment.date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-amber-600">{formatCurrency(payment.amount)}</div>
|
||||
<span className="inline-block px-2 py-1 bg-green-100 text-green-800 rounded-lg text-xs mt-1">
|
||||
مكتمل
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/profile/error.js
Normal file
27
app/profile/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/profile/loading.js
Normal file
14
app/profile/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -27,6 +27,8 @@ import {
|
||||
Pencil
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../services/AuthService';
|
||||
import { getCustomerByUserId, getOwnerByUserId } from '../utils/api';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
@ -62,14 +64,51 @@ export default function ProfilePage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
const userData = {
|
||||
id: authUser.id,
|
||||
name: authUser.name || '',
|
||||
email: authUser.email || '',
|
||||
phone: authUser.phone || '',
|
||||
role: AuthService.isOwner() ? 'owner' : 'customer',
|
||||
};
|
||||
setUser(userData);
|
||||
console.log('[Profile] User from JWT:', userData);
|
||||
|
||||
// Fetch full profile from API using user ID (SID from JWT)
|
||||
async function fetchProfile() {
|
||||
try {
|
||||
const fetchFn = userData.role === 'owner' ? getOwnerByUserId : getCustomerByUserId;
|
||||
console.log('[Profile] Fetching profile via', userData.role === 'owner' ? 'Owner' : 'Customer', 'GetByUserId:', userData.id);
|
||||
const profile = await fetchFn(userData.id);
|
||||
console.log('[Profile] API profile:', profile);
|
||||
|
||||
if (profile) {
|
||||
const profileData = {
|
||||
name: profile.fullName || profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || userData.name || '',
|
||||
email: profile.email || userData.email || '',
|
||||
phone: profile.phone || profile.phoneNumber || userData.phone || '',
|
||||
whatsapp: profile.whatsAppNumber || profile.whatsapp || '',
|
||||
bio: profile.bio || '',
|
||||
location: profile.address || profile.location || '',
|
||||
joinedDate: profile.createdAt
|
||||
? new Date(profile.createdAt).toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
||||
: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' }),
|
||||
};
|
||||
setFormData(profileData);
|
||||
setTempValues(profileData);
|
||||
localStorage.setItem('userProfile', JSON.stringify(profileData));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Profile] API fetch failed, falling back to JWT/localStorage:', err);
|
||||
}
|
||||
|
||||
// Fallback to JWT + localStorage
|
||||
const savedProfile = localStorage.getItem('userProfile');
|
||||
let profileData;
|
||||
|
||||
if (savedProfile) {
|
||||
profileData = JSON.parse(savedProfile);
|
||||
} else {
|
||||
@ -83,16 +122,17 @@ export default function ProfilePage() {
|
||||
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
|
||||
};
|
||||
}
|
||||
|
||||
setFormData(profileData);
|
||||
setTempValues(profileData);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const savedAvatar = localStorage.getItem('userAvatar');
|
||||
if (savedAvatar) {
|
||||
setAvatarPreview(savedAvatar);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
fetchProfile();
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
@ -167,7 +207,6 @@ export default function ProfilePage() {
|
||||
|
||||
if (field === 'name') {
|
||||
const updatedUser = { ...user, name: value };
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
setUser(updatedUser);
|
||||
}
|
||||
|
||||
|
||||
27
app/properties/error.js
Normal file
27
app/properties/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/properties/loading.js
Normal file
14
app/properties/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Search,
|
||||
MapPin,
|
||||
@ -32,12 +31,92 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getRentProperties, getSaleProperties } from '../utils/api';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Map API data to UI format
|
||||
function mapApiProperty(item, index) {
|
||||
const info = item.propertyInformation || {};
|
||||
|
||||
const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
|
||||
const monthlyPrice = item.monthlyRent ?? 0;
|
||||
|
||||
const buildingTypeMap = { 0: 'apartment', 1: 'villa', 2: 'house' };
|
||||
const propType = buildingTypeMap[info.buildingType] ?? buildingTypeMap[item.type] ?? 'apartment';
|
||||
|
||||
const statusMap = { 0: 'available', 1: 'booked', 2: 'maintenance' };
|
||||
const status = statusMap[info.status] ?? statusMap[item.status] ?? 'available';
|
||||
|
||||
const features = [];
|
||||
if (item.isSmokeAllow) features.push('يسمح بالتدخين');
|
||||
if (item.isVisitorAllow) features.push('يسمح بالزوار');
|
||||
if (item.specializedFor) features.push('متخصص');
|
||||
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} غرف نوم`);
|
||||
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const rawImages = Array.isArray(info.images) ? info.images : [];
|
||||
const images = rawImages.length > 0
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg'];
|
||||
|
||||
return {
|
||||
id: item.id ?? index + 1,
|
||||
title: info.address || `عقار #${item.id || index + 1}`,
|
||||
description: info.description || '',
|
||||
type: propType,
|
||||
price: dailyPrice,
|
||||
priceUnit: 'daily',
|
||||
location: {
|
||||
city: extractCity(info.address) || 'دمشق',
|
||||
district: info.address || '',
|
||||
},
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
features,
|
||||
images,
|
||||
status,
|
||||
rating: item.rating || 4.5,
|
||||
isNew: false,
|
||||
_raw: item,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCity(address) {
|
||||
if (!address) return '';
|
||||
const cities = ['دمشق', 'حلب', 'حمص', 'اللاذقية', 'درعا', 'طرطوس', 'السويداء', 'دير الزور', 'الرقة', 'إدلب', 'الحسكة', 'القامشلي', 'ريف دمشق'];
|
||||
for (const city of cities) {
|
||||
if (address.includes(city)) return city;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// API-only — no fallback data
|
||||
|
||||
const PropertyCard = ({ property, viewMode = 'grid', onLoginRequired }) => {
|
||||
const { isFavorite: checkFavorite, addFavorite, removeFavorite } = useFavorites();
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
|
||||
const isFav = checkFavorite(property.id);
|
||||
|
||||
const toggleFavorite = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!AuthService.isAuthenticated()) { onLoginRequired?.(); return; }
|
||||
setFavLoading(true);
|
||||
if (isFav) {
|
||||
await removeFavorite(property.id);
|
||||
} else {
|
||||
await addFavorite(property.id);
|
||||
}
|
||||
setFavLoading(false);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
@ -83,26 +162,20 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx)}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
|
||||
}`}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
onClick={toggleFavorite}
|
||||
disabled={favLoading}
|
||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{property.isNew && (
|
||||
<div className="absolute top-2 left-2 bg-gray-800 text-white px-2 py-1 rounded-lg text-xs font-medium">
|
||||
جديد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:w-2/3 p-6">
|
||||
@ -113,11 +186,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
{getPropertyTypeIcon(property.type)}
|
||||
{getPropertyTypeLabel(property.type)}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
property.status === 'available'
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${property.status === 'available' ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-600'}`}>
|
||||
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
||||
</span>
|
||||
</div>
|
||||
@ -148,22 +217,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{property.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{property.features.slice(0, 4).map((feature, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{property.features.length > 4 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
+{property.features.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">{property.description}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
@ -195,32 +249,15 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{property.images.length > 1 && (
|
||||
<div className="absolute bottom-2 left-2 right-2 flex justify-center gap-1">
|
||||
{property.images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx)}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
onClick={toggleFavorite}
|
||||
disabled={favLoading}
|
||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{property.isNew && (
|
||||
<div className="absolute top-2 left-2 bg-gray-800 text-white px-2 py-1 rounded-lg text-xs font-medium">
|
||||
جديد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
@ -232,9 +269,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
{getPropertyTypeLabel(property.type)}
|
||||
</span>
|
||||
{property.status === 'available' && (
|
||||
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">
|
||||
متاح
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">متاح</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
|
||||
@ -270,19 +305,6 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{property.features.slice(0, 3).map((feature, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{property.features.length > 3 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
|
||||
+{property.features.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/property/${property.id}`}
|
||||
className="block w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors text-center"
|
||||
@ -302,7 +324,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
{ id: 'apartment', label: 'شقة', icon: Building2 },
|
||||
{ id: 'villa', label: 'فيلا', icon: Home },
|
||||
{ id: 'house', label: 'بيت', icon: Home },
|
||||
{ id: 'studio', label: 'استوديو', icon: Building2 }
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
@ -364,11 +385,7 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onFilterChange({ ...filters, propertyType: type.id })}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
||||
filters.propertyType === type.id
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${filters.propertyType === type.id ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
{type.label}
|
||||
@ -439,30 +456,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">المميزات</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['مسبح', 'حديقة', 'موقف سيارات', 'أمن', 'مصعد', 'تكييف'].map((feature) => (
|
||||
<button
|
||||
key={feature}
|
||||
onClick={() => {
|
||||
const newFeatures = filters.features.includes(feature)
|
||||
? filters.features.filter(f => f !== feature)
|
||||
: [...filters.features, feature];
|
||||
onFilterChange({ ...filters, features: newFeatures });
|
||||
}}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
filters.features.includes(feature)
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{feature}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||
@ -498,6 +491,9 @@ const FilterBar = ({ filters, onFilterChange }) => {
|
||||
export default function PropertiesPage() {
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [sortBy, setSortBy] = useState('newest');
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
propertyType: 'all',
|
||||
@ -509,94 +505,35 @@ export default function PropertiesPage() {
|
||||
features: []
|
||||
});
|
||||
|
||||
const [properties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.',
|
||||
type: 'villa',
|
||||
price: 500000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'دمشق', district: 'المزة' },
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن'],
|
||||
images: ['/villa1.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.8,
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
description: 'شقة عصرية في حي الشهباء الراقي بحلب.',
|
||||
type: 'apartment',
|
||||
price: 250000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'حلب', district: 'الشهباء' },
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
images: ['/apartment1.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.5,
|
||||
isNew: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
description: 'بيت واسع مناسب للعائلات في حمص.',
|
||||
type: 'house',
|
||||
price: 350000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'حمص', district: 'بابا عمرو' },
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة'],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
rating: 4.3,
|
||||
isNew: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'شقة بجانب البحر',
|
||||
description: 'شقة رائعة مع إطلالة بحرية في اللاذقية.',
|
||||
type: 'apartment',
|
||||
price: 300000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'اللاذقية', district: 'الشاطئ الأزرق' },
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 200,
|
||||
features: ['إطلالة بحرية', 'شرفة', 'تكييف'],
|
||||
images: ['/seaside1.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.9,
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'فيلا في درعا',
|
||||
description: 'فيلا فاخرة في حي الأطباء بدرعا.',
|
||||
type: 'villa',
|
||||
price: 400000,
|
||||
priceUnit: 'daily',
|
||||
location: { city: 'درعا', district: 'حي الأطباء' },
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 350,
|
||||
features: ['حديقة مثمرة', 'أنظمة أمن', 'مسبح'],
|
||||
images: ['/villa4.jpg'],
|
||||
status: 'available',
|
||||
rating: 4.6,
|
||||
isNew: false
|
||||
}
|
||||
useEffect(() => {
|
||||
async function fetchProperties() {
|
||||
try {
|
||||
const [rentData, saleData] = await Promise.all([
|
||||
getRentProperties().catch(() => []),
|
||||
getSaleProperties().catch(() => []),
|
||||
]);
|
||||
|
||||
const rentList = Array.isArray(rentData) ? rentData : [];
|
||||
const saleList = Array.isArray(saleData) ? saleData : [];
|
||||
|
||||
const mapped = [
|
||||
...rentList.map((p, i) => mapApiProperty(p, i)),
|
||||
...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
|
||||
];
|
||||
|
||||
if (mapped.length > 0) {
|
||||
setProperties(mapped);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Properties] Failed to fetch properties:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
const filteredProperties = properties
|
||||
.filter(property => {
|
||||
if (filters.search && !property.title.includes(filters.search) && !property.description.includes(filters.search)) {
|
||||
@ -613,8 +550,8 @@ export default function PropertiesPage() {
|
||||
if (max) {
|
||||
if (property.price < parseInt(min) || property.price > parseInt(max)) return false;
|
||||
} else if (filters.priceRange.endsWith('+')) {
|
||||
const min = parseInt(filters.priceRange.replace('+', ''));
|
||||
if (property.price < min) return false;
|
||||
const minVal = parseInt(filters.priceRange.replace('+', ''));
|
||||
if (property.price < minVal) return false;
|
||||
}
|
||||
}
|
||||
if (filters.bedrooms !== 'all' && property.bedrooms < parseInt(filters.bedrooms)) {
|
||||
@ -622,9 +559,6 @@ export default function PropertiesPage() {
|
||||
}
|
||||
if (filters.minArea && property.area < parseInt(filters.minArea)) return false;
|
||||
if (filters.maxArea && property.area > parseInt(filters.maxArea)) return false;
|
||||
if (filters.features.length > 0) {
|
||||
if (!filters.features.every(f => property.features.includes(f))) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@ -632,7 +566,7 @@ export default function PropertiesPage() {
|
||||
case 'price_asc': return a.price - b.price;
|
||||
case 'price_desc': return b.price - a.price;
|
||||
case 'rating': return b.rating - a.rating;
|
||||
default: return b.isNew ? 1 : -1;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
@ -646,6 +580,11 @@ export default function PropertiesPage() {
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">عقارات للإيجار</h1>
|
||||
<p className="text-gray-500">أفضل العقارات في سوريا</p>
|
||||
{loading && (
|
||||
<div className="mt-4">
|
||||
<div className="inline-block w-6 h-6 border-2 border-gray-200 border-t-gray-800 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<FilterBar filters={filters} onFilterChange={setFilters} />
|
||||
@ -668,19 +607,13 @@ export default function PropertiesPage() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
title="عرض شبكي"
|
||||
className={`p-2 rounded-xl transition-colors ${viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<Grid3x3 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
title="عرض قائمة"
|
||||
className={`p-2 rounded-xl transition-colors ${viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</button>
|
||||
@ -693,7 +626,7 @@ export default function PropertiesPage() {
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredProperties.map((property) => (
|
||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} />
|
||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} onLoginRequired={() => setShowLoginDialog(true)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -711,6 +644,37 @@ export default function PropertiesPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
{showLoginDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowLoginDialog(false)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl text-center"
|
||||
>
|
||||
<div className="w-14 h-14 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-7 h-7 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">تسجيل الدخول مطلوب</h3>
|
||||
<p className="text-gray-500 mb-6">يجب تسجيل الدخول لإضافة العقارات إلى المفضلة</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="flex-1 py-3 border border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
DollarSign,
|
||||
Heart,
|
||||
Share2,
|
||||
Phone,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
Shield,
|
||||
Star,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Check,
|
||||
X,
|
||||
Wifi,
|
||||
Car,
|
||||
Coffee,
|
||||
Wind,
|
||||
Thermometer,
|
||||
Lock,
|
||||
Camera,
|
||||
Home,
|
||||
Building2,
|
||||
Users,
|
||||
Ruler,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Award,
|
||||
FileText,
|
||||
Printer,
|
||||
Download,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
|
||||
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م' }
|
||||
]
|
||||
// Server-side API fetch for metadata (runs at request time on server)
|
||||
async function fetchPropertyForMeta(id) {
|
||||
try {
|
||||
const res = await fetch(`http://45.93.137.91/api/RentProperties/GetRentProperties`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
const json = JSON.parse(text);
|
||||
const items = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
||||
return items.find(p => p.id == id) || items[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setProperty(propertiesData[params.id] || propertiesData[1]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, [params.id]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const calculateTotalPrice = () => {
|
||||
if (!property) return 0;
|
||||
const days = bookingDates.start && bookingDates.end
|
||||
? Math.ceil((new Date(bookingDates.end) - new Date(bookingDates.start)) / (1000 * 60 * 60 * 24))
|
||||
: selectedDuration;
|
||||
return property.price * (days > 0 ? days : 1);
|
||||
};
|
||||
|
||||
const handleBooking = () => {
|
||||
alert('تم إرسال طلب الحجز بنجاح. سيتم التواصل معك قريباً.');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-gray-800 rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">جاري تحميل تفاصيل العقار...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Home className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">العقار غير موجود</h2>
|
||||
<p className="text-gray-600 mb-4">لم نتمكن من العثور على العقار المطلوب</p>
|
||||
<Link href="/properties" className="bg-gray-800 text-white px-6 py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors">
|
||||
العودة إلى العقارات
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function mapProperty(item) {
|
||||
const info = item.propertyInformation || item.PropertyInformation || {};
|
||||
let details = {};
|
||||
try { details = JSON.parse(info.detailsJSON || info.DetailsJSON || '{}'); } catch {}
|
||||
|
||||
const price = item.monthlyRent || item.MonthlyRent || item.dailyRent || item.DailyRent || 0;
|
||||
const priceUnit = item.monthlyRent || item.MonthlyRent ? 'monthly' : 'daily';
|
||||
const buildingType = info.buildingType ?? info.BuildingType ?? 0;
|
||||
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
|
||||
const address = info.address || info.Address || '';
|
||||
const bedrooms = info.numberOfBedRooms || info.NumberOfBedRooms || 0;
|
||||
const bathrooms = info.numberOfBathRooms || info.NumberOfBathRooms || 0;
|
||||
const area = info.space || info.Space || 0;
|
||||
const desc = info.description || info.Description || '';
|
||||
const images = info.images || info.Images || [];
|
||||
const firstImage = Array.isArray(images) && images[0] ? images[0] : '';
|
||||
|
||||
return {
|
||||
title: `${typeLabel} في ${address}`,
|
||||
description: desc || `${typeLabel} في ${address} · ${bedrooms} غرف نوم · ${bathrooms} حمامات · ${area} م²`,
|
||||
price,
|
||||
priceUnit,
|
||||
typeLabel,
|
||||
address,
|
||||
bedrooms,
|
||||
bathrooms,
|
||||
area,
|
||||
image: firstImage,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b sticky top-16 z-40 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/properties" className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>العودة إلى العقارات</span>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<Heart className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<Share2 className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
export async function generateMetadata({ params }) {
|
||||
const { id } = await params;
|
||||
const raw = await fetchPropertyForMeta(id);
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="relative h-[500px] rounded-2xl overflow-hidden group bg-gray-100">
|
||||
<Image
|
||||
src={property.images[currentImage] || '/property-placeholder.jpg'}
|
||||
alt={property.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{property.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setCurrentImage(prev => Math.max(0, prev - 1))}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-white"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentImage(prev => Math.min(property.images.length - 1, prev + 1))}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-white"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2">
|
||||
{property.images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
idx === currentImage ? 'bg-gray-800 w-4' : 'bg-white/70 hover:bg-white'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-3 py-1 rounded-full text-sm backdrop-blur-sm">
|
||||
<Camera className="w-4 h-4 inline ml-1" />
|
||||
{currentImage + 1} / {property.images.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{property.images.slice(1, 5).map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setCurrentImage(idx + 1)}
|
||||
className="relative h-[240px] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity bg-gray-100"
|
||||
>
|
||||
<Image src={img} alt={`${property.title} ${idx + 2}`} fill className="object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{property.title}</h1>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<span>{property.location.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-3xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
|
||||
<div className="text-sm text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-5 h-5 fill-gray-800 text-gray-800" />
|
||||
<span className="font-bold text-gray-900">{property.rating}</span>
|
||||
<span className="text-gray-500">({property.reviews} تقييم)</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-gray-200" />
|
||||
<span className={`font-medium ${
|
||||
property.status === 'available' ? 'text-gray-800' : 'text-gray-500'
|
||||
}`}>
|
||||
{property.status === 'available' ? 'متاح للإيجار' : 'محجوز حالياً'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">المواصفات الرئيسية</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Bed className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">{property.bedrooms}</div>
|
||||
<div className="text-sm text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Bath className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">{property.bathrooms}</div>
|
||||
<div className="text-sm text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Square className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">{property.area}</div>
|
||||
<div className="text-sm text-gray-500">م²</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<Home className="w-6 h-6 text-gray-700 mx-auto mb-2" />
|
||||
<div className="font-bold text-gray-900">
|
||||
{property.type === 'villa' ? 'فيلا' :
|
||||
property.type === 'apartment' ? 'شقة' : 'بيت'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">نوع العقار</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{property.specifications && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>بناء: {property.specifications.constructionYear}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Ruler className="w-4 h-4" />
|
||||
<span>حديقة: {property.specifications.gardenArea} م²</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Car className="w-4 h-4" />
|
||||
<span>موقف: {property.specifications.parking}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Wind className="w-4 h-4" />
|
||||
<span>{property.specifications.airConditioning}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">وصف العقار</h2>
|
||||
<p className="text-gray-600 whitespace-pre-line leading-relaxed">{property.description}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">المميزات والخدمات</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{property.features.map((feature, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 p-3 bg-gray-50 rounded-xl">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
feature.available ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{feature.available ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<X className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{feature.icon}</span>
|
||||
<span className={`font-medium ${feature.available ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{feature.name}
|
||||
</span>
|
||||
</div>
|
||||
{feature.description && (
|
||||
<p className={`text-sm mt-1 ${feature.available ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
{feature.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">القرب من الخدمات</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{property.nearby.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span className="text-gray-700">{item.type}</span>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{item.distance}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{property.reviewList && property.reviewList.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">تقييمات المستأجرين</h2>
|
||||
<div className="space-y-4">
|
||||
{property.reviewList.map((review, idx) => (
|
||||
<div key={idx} className="border-b border-gray-100 last:border-0 pb-4 last:pb-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className="font-bold text-gray-900">{review.user}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className={`w-4 h-4 ${
|
||||
i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{review.date}</span>
|
||||
</div>
|
||||
<p className="text-gray-600">{review.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{property.rules && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">قوانين المنزل</h2>
|
||||
<ul className="space-y-2">
|
||||
{property.rules.map((rule, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 text-gray-600">
|
||||
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="sticky top-28">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">احجز هذا العقار</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">اختر المدة (أيام)</label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 3, 7, 14, 30].map(days => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => setSelectedDuration(days)}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
selectedDuration === days
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{days}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ البداية</label>
|
||||
<input
|
||||
type="date"
|
||||
value={bookingDates.start}
|
||||
onChange={(e) => setBookingDates({ ...bookingDates, start: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ النهاية</label>
|
||||
<input
|
||||
type="date"
|
||||
value={bookingDates.end}
|
||||
onChange={(e) => setBookingDates({ ...bookingDates, end: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl mb-6">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-600">السعر لـ {selectedDuration} أيام</span>
|
||||
<span className="font-bold text-gray-900">{formatCurrency(property.price * selectedDuration)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-600">سلفة ضمان</span>
|
||||
<span className="font-bold text-gray-900">{formatCurrency(500000)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-gray-200 font-bold">
|
||||
<span className="text-gray-900">الإجمالي</span>
|
||||
<span className="text-gray-900">{formatCurrency(property.price * selectedDuration + 500000)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBooking}
|
||||
className="w-full bg-gray-800 text-white py-4 rounded-xl font-bold text-lg hover:bg-gray-900 transition-colors mb-4"
|
||||
>
|
||||
تأكيد الحجز
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Shield className="w-4 h-4 text-gray-600" />
|
||||
<span>الدفع آمن ومضمون. سلفة الضمان قابلة للاسترداد.</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<h3 className="font-bold mb-4 text-gray-900">معلومات المالك</h3>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-gray-700">
|
||||
{property.owner.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">{property.owner.name}</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<Star className="w-3 h-3 fill-gray-600 text-gray-600" />
|
||||
<span>{property.owner.rating}</span>
|
||||
<span>· {property.owner.properties} عقارات</span>
|
||||
</div>
|
||||
{property.owner.responseRate && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>استجابة: {property.owner.responseRate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showContact ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-xl">
|
||||
<Phone className="w-4 h-4 text-gray-600" />
|
||||
<span className="font-medium text-gray-900">{property.owner.phone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-xl">
|
||||
<Mail className="w-4 h-4 text-gray-600" />
|
||||
<span className="font-medium text-gray-900">{property.owner.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowContact(true)}
|
||||
className="w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Phone className="w-5 h-5" />
|
||||
عرض معلومات الاتصال
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="w-full mt-3 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
مراسلة المالك
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!raw) {
|
||||
return {
|
||||
title: 'SweetHome - عقار',
|
||||
description: 'اكتشف أفضل العقارات للإيجار',
|
||||
};
|
||||
}
|
||||
|
||||
const p = mapProperty(raw);
|
||||
const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === 'daily' ? 'يوم' : 'شهر'}`;
|
||||
const propertyImage = p.image
|
||||
? (p.image.startsWith('http') ? p.image : `http://45.93.137.91${p.image}`)
|
||||
: '';
|
||||
const logoUrl = `http://45.93.137.91/logo.png`;
|
||||
|
||||
// Use property image if available, otherwise logo
|
||||
const ogImages = propertyImage
|
||||
? [{ url: propertyImage, width: 1200, height: 630 }, { url: logoUrl, width: 512, height: 512 }]
|
||||
: [{ url: logoUrl, width: 512, height: 512 }];
|
||||
|
||||
return {
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
openGraph: {
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
images: ogImages,
|
||||
url: `http://45.93.137.91/property/${id}`,
|
||||
type: 'website',
|
||||
siteName: 'SweetHome',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
images: ogImages.map(i => i.url),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function PropertyPage({ params }) {
|
||||
return <PropertyDetail params={params} />;
|
||||
}
|
||||
27
app/register/owner/error.js
Normal file
27
app/register/owner/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/register/owner/loading.js
Normal file
14
app/register/owner/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
27
app/register/tenant/error.js
Normal file
27
app/register/tenant/error.js
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
|
||||
<Home className="w-5 h-5" /> الرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/register/tenant/loading.js
Normal file
14
app/register/tenant/loading.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,117 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
Loader2
|
||||
User, Mail, Phone, Lock, Eye, EyeOff,
|
||||
CheckCircle, XCircle, ArrowLeft, Home, Loader2,
|
||||
Shield, KeyRound, Camera, X
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addCustomer, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { CustomerType, CustomerTypeLabels } from '../../enums';
|
||||
|
||||
export default function TenantRegisterPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1); // 1=form, 2=id images
|
||||
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
whatsapp: '',
|
||||
phone2: '',
|
||||
nationalNumber: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
customerType: CustomerType.PERSONAL,
|
||||
agreeTerms: false
|
||||
});
|
||||
|
||||
const [idImages, setIdImages] = useState({ front: null, back: null });
|
||||
const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
const fileInputFrontRef = useRef(null);
|
||||
const fileInputBackRef = useRef(null);
|
||||
|
||||
const handleImageUpload = (side, file) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('الرجاء اختيار صورة صالحة');
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setIdImagePreviews(prev => ({ ...prev, [side]: reader.result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setIdImages(prev => ({ ...prev, [side]: file }));
|
||||
console.log('[CustomerRegister] Image uploaded:', side);
|
||||
toast.success('تم رفع الصورة بنجاح', { style: { background: '#dcfce7', color: '#166534' } });
|
||||
};
|
||||
|
||||
const validatePhone = (phone) => {
|
||||
const re = /^(09|05)[0-9]{8}$/;
|
||||
return re.test(phone);
|
||||
};
|
||||
const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
|
||||
|
||||
const validateForm = () => {
|
||||
const validateStep1 = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.firstName) newErrors.firstName = 'الاسم الأول مطلوب';
|
||||
if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
|
||||
|
||||
if (!formData.name) {
|
||||
newErrors.name = 'الاسم الكامل مطلوب';
|
||||
} else if (formData.name.length < 3) {
|
||||
newErrors.name = 'الاسم يجب أن يكون 3 أحرف على الأقل';
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
}
|
||||
if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = 'رقم الهاتف مطلوب';
|
||||
} else if (!validatePhone(formData.phone)) {
|
||||
newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
|
||||
}
|
||||
if (!formData.phone) newErrors.phone = 'رقم الهاتف مطلوب';
|
||||
else if (!validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'كلمة المرور مطلوبة';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
||||
}
|
||||
if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
|
||||
else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
}
|
||||
if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
|
||||
if (!formData.phone2 || formData.phone2.length !== 7) newErrors.phone2 = 'رقم الهاتف يجب أن يكون 7 أرقام';
|
||||
if (!formData.nationalNumber) newErrors.nationalNumber = 'الرقم الوطني مطلوب';
|
||||
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validateStep2 = () => {
|
||||
const newErrors = {};
|
||||
if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
|
||||
if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (validateStep1()) {
|
||||
console.log('[CustomerRegister] Step 1 valid, moving to step 2');
|
||||
setStep(2);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
toast.error('يرجى تصحيح الأخطاء في النموذج');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Main signup handler ───
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
toast.error('يرجى تصحيح الأخطاء في النموذج');
|
||||
if (!validateStep2()) {
|
||||
toast.error('يرجى إكمال جميع الصور المطلوبة');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.agreeTerms) {
|
||||
toast.error('يجب الموافقة على الشروط والأحكام');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
console.log('[CustomerRegister] Submitting customer registration...');
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast.success('تم إنشاء الحساب بنجاح!', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
localStorage.setItem('user', JSON.stringify({
|
||||
name: formData.name,
|
||||
const payload = {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
role: 'tenant',
|
||||
avatar: formData.name.charAt(0).toUpperCase()
|
||||
}));
|
||||
phoneNumber: formData.phone,
|
||||
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('/');
|
||||
}, 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 = {
|
||||
@ -121,318 +236,379 @@ export default function TenantRegisterPage() {
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
animate: { transition: { staggerChildren: 0.1 } }
|
||||
};
|
||||
|
||||
|
||||
const backgroundElements = useMemo(() => {
|
||||
const circles = [
|
||||
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-blue-500/10' },
|
||||
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/10' },
|
||||
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-blue-500/10' },
|
||||
];
|
||||
|
||||
const dots = [
|
||||
{ left: '5%', top: '10%', size: '120px' },
|
||||
{ left: '15%', top: '70%', size: '80px' },
|
||||
{ left: '25%', top: '30%', size: '150px' },
|
||||
{ left: '35%', top: '85%', size: '100px' },
|
||||
{ left: '45%', top: '15%', size: '90px' },
|
||||
{ left: '55%', top: '60%', size: '130px' },
|
||||
{ left: '65%', top: '40%', size: '70px' },
|
||||
{ left: '75%', top: '80%', size: '110px' },
|
||||
{ left: '85%', top: '20%', size: '140px' },
|
||||
{ left: '95%', top: '50%', size: '85px' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{circles.map((circle, i) => (
|
||||
<div
|
||||
key={`circle-${i}`}
|
||||
className={`absolute rounded-full ${circle.className}`}
|
||||
style={circle.style}
|
||||
/>
|
||||
))}
|
||||
{dots.map((dot, i) => (
|
||||
<div
|
||||
key={`dot-${i}`}
|
||||
className="absolute rounded-full bg-blue-500/10"
|
||||
style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* <div className="absolute inset-0 overflow-hidden">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-blue-500/10"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
width: Math.random() * 200 + 50,
|
||||
height: Math.random() * 200 + 50,
|
||||
}}
|
||||
animate={{
|
||||
x: [0, Math.random() * 100 - 50, 0],
|
||||
y: [0, Math.random() * 100 - 50, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 15 + 15,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
/>
|
||||
<motion.div key={i} className="absolute rounded-full bg-blue-500/10"
|
||||
style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
|
||||
animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
|
||||
transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
|
||||
))}
|
||||
</div> */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{backgroundElements}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative z-10 w-full max-w-md"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="absolute -top-16 left-0"
|
||||
>
|
||||
<Link
|
||||
href="/auth/choose-role"
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group"
|
||||
>
|
||||
<motion.div whileHover={{ x: -5 }}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
|
||||
className="relative z-10 w-full max-w-md">
|
||||
{/* Back */}
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-8">
|
||||
<Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
|
||||
<motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
|
||||
<span>العودة</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-6 flex gap-2">
|
||||
{[1, 2].map((s) => (
|
||||
<motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? 'bg-blue-500' : 'bg-gray-700'}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-8 text-center relative overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring" }}
|
||||
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="relative z-10"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
||||
>
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2, type: "spring" }}
|
||||
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
|
||||
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="relative z-10">
|
||||
<motion.div animate={{ rotate: [0, 10, -10, 0] }} transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">إنشاء حساب مستأجر</h1>
|
||||
<p className="text-blue-100">انضم إلينا وابحث عن منزل أحلامك</p>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
{step === 1 ? 'إنشاء حساب مستأجر' : 'الوثائق الرسمية'}
|
||||
</h1>
|
||||
<p className="text-blue-100">
|
||||
{step === 1 ? 'انضم إلينا وابحث عن منزل أحلامك' : 'يرجى رفع صور الهوية للتحقق'}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<motion.form
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
الاسم الكامل <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<motion.form variants={staggerContainer} initial="initial" animate="animate"
|
||||
onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
|
||||
className="space-y-6">
|
||||
|
||||
{/* ─── STEP 1: Form ─── */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<User className={`w-5 h-5 ${
|
||||
errors.name ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
<User className={`w-5 h-5 ${errors.firstName ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, name: e.target.value});
|
||||
setErrors({...errors, name: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.name ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل اسمك الكامل"
|
||||
/>
|
||||
<input type="text" value={formData.firstName}
|
||||
onChange={(e) => { setFormData({...formData, firstName: e.target.value}); setErrors({...errors, firstName: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.firstName ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="الاسم الأول" />
|
||||
</div>
|
||||
{errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">اسم العائلة <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={formData.lastName}
|
||||
onChange={(e) => { setFormData({...formData, lastName: e.target.value}); setErrors({...errors, lastName: null}); }}
|
||||
className={`w-full px-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.lastName ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="اسم العائلة" />
|
||||
{errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.name}</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>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">البريد الإلكتروني <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Mail className={`w-5 h-5 ${
|
||||
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
<Mail className={`w-5 h-5 ${errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, email: e.target.value});
|
||||
setErrors({...errors, email: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل بريدك الإلكتروني"
|
||||
/>
|
||||
<input type="email" value={formData.email}
|
||||
onChange={(e) => { setFormData({...formData, email: e.target.value}); setErrors({...errors, email: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.email ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل بريدك الإلكتروني" />
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
||||
)}
|
||||
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
رقم الهاتف <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${
|
||||
errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
<Phone className={`w-5 h-5 ${errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, phone: e.target.value});
|
||||
setErrors({...errors, phone: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.phone ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل رقم هاتفك"
|
||||
/>
|
||||
<input type="tel" value={formData.phone}
|
||||
onChange={(e) => { setFormData({...formData, phone: e.target.value}); setErrors({...errors, phone: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم هاتفك" />
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.phone}</p>
|
||||
)}
|
||||
{errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<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'
|
||||
}`} />
|
||||
<Phone className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, password: e.target.value});
|
||||
setErrors({...errors, password: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
)}
|
||||
<input type="tel" value={formData.whatsapp}
|
||||
onChange={(e) => { setFormData({...formData, whatsapp: e.target.value}); setErrors({...errors, whatsapp: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.whatsapp ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم الواتساب" />
|
||||
</div>
|
||||
{errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="tel" value={formData.phone2}
|
||||
onChange={(e) => { setFormData({...formData, phone2: e.target.value}); setErrors({...errors, phone2: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone2 ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم الهاتف" maxLength={7} />
|
||||
</div>
|
||||
{errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">الرقم الوطني <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<User className={`w-5 h-5 ${errors.nationalNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="text" value={formData.nationalNumber}
|
||||
onChange={(e) => { setFormData({...formData, nationalNumber: e.target.value}); setErrors({...errors, nationalNumber: null}); }}
|
||||
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.nationalNumber ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل الرقم الوطني" />
|
||||
</div>
|
||||
{errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">نوع العميل <span className="text-red-500">*</span></label>
|
||||
<select value={formData.customerType}
|
||||
onChange={(e) => setFormData({...formData, customerType: e.target.value})}
|
||||
className="w-full py-3 px-4 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white appearance-none cursor-pointer">
|
||||
{Object.entries(CustomerTypeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value} className="bg-gray-900 text-white">{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type={showPassword ? "text" : "password"} value={formData.password}
|
||||
onChange={(e) => { setFormData({...formData, password: e.target.value}); setErrors({...errors, password: null}); }}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.password ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل كلمة المرور" />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
{showPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
||||
)}
|
||||
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
تأكيد كلمة المرور <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">تأكيد كلمة المرور <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 ${
|
||||
errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
|
||||
}`} />
|
||||
<Lock className={`w-5 h-5 ${errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, confirmPassword: e.target.value});
|
||||
setErrors({...errors, confirmPassword: null});
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.confirmPassword ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
placeholder="أعد إدخال كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
|
||||
)}
|
||||
<input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword}
|
||||
onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: null}); }}
|
||||
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.confirmPassword ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أعد إدخال كلمة المرور" />
|
||||
<button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
{showConfirmPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||
</button>
|
||||
{formData.confirmPassword && (
|
||||
<div className="absolute inset-y-0 left-12 flex items-center">
|
||||
{formData.password === formData.confirmPassword ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
{formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
||||
{errors.confirmPassword && <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 variants={fadeInUp} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={formData.agreeTerms}
|
||||
<input type="checkbox" id="terms" checked={formData.agreeTerms}
|
||||
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
|
||||
required
|
||||
/>
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500" required />
|
||||
<label htmlFor="terms" className="text-sm text-gray-300">
|
||||
أوافق على{' '}
|
||||
<Link href="/terms" className="text-blue-400 hover:text-blue-300">
|
||||
شروط الاستخدام
|
||||
</Link>
|
||||
{' '}و{' '}
|
||||
<Link href="/privacy" className="text-blue-400 hover:text-blue-300">
|
||||
سياسة الخصوصية
|
||||
</Link>
|
||||
أوافق على <Link href="/terms" className="text-blue-400 hover:text-blue-300">شروط الاستخدام</Link> و <Link href="/privacy" className="text-blue-400 hover:text-blue-300">سياسة الخصوصية</Link>
|
||||
</label>
|
||||
</motion.div>
|
||||
|
||||
<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">
|
||||
لديك حساب بالفعل؟{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-blue-400 hover:text-blue-300 font-medium transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">تسجيل الدخول</Link>
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.form>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── OTP Modal ─── */}
|
||||
<AnimatePresence>
|
||||
{showOtpModal && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Shield className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">التحقق من البريد</h2>
|
||||
<p className="text-gray-400 text-sm mt-1">تم إرسال رمز التحقق إلى</p>
|
||||
<p className="text-blue-400 font-medium text-sm">{formData.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رمز التحقق</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<KeyRound className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input type="text" value={otpCode} maxLength={6}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
className="w-full pr-12 pl-4 py-3 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-white text-center tracking-[0.5em] text-xl"
|
||||
placeholder="------" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleVerifyOTP} disabled={isLoading || !otpCode}
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{isLoading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التحقق...</span></> : 'تحقق'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={handleResendOTP} disabled={isLoading}
|
||||
className="w-full text-center text-blue-400 hover:text-blue-300 text-sm mt-3 disabled:opacity-50">
|
||||
إعادة إرسال الرمز
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
app/reservations/page.js
Normal file
226
app/reservations/page.js
Normal file
@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
|
||||
MapPin, DollarSign, Home, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../services/AuthService';
|
||||
import { getRentProperty } from '../utils/api';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
|
||||
|
||||
const STATUS_UI = {
|
||||
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
ownerConfirmed: { label: 'مؤكد من المالك', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
||||
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
|
||||
depositConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
completed: { label: 'منتهي', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
||||
};
|
||||
|
||||
function statusLabel(code) { return STATUS_UI[STATUS_MAP[code]]?.label ?? String(code); }
|
||||
function statusColor(code) { return STATUS_UI[STATUS_MAP[code]]?.color ?? 'bg-gray-100 text-gray-700'; }
|
||||
function statusIcon(code) { return STATUS_UI[STATUS_MAP[code]]?.icon ?? Clock; }
|
||||
|
||||
function StatusBadge({ code }) {
|
||||
const Icon = statusIcon(code);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${statusColor(code)}`}>
|
||||
<Icon className="w-3 h-3" /> {statusLabel(code)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function enrich(reservation) {
|
||||
if (!reservation.propertyId) return reservation;
|
||||
try {
|
||||
const prop = await getRentProperty(reservation.propertyId);
|
||||
reservation._prop = prop?.propertyInformation ?? prop ?? null;
|
||||
} catch { /* skip */ }
|
||||
return reservation;
|
||||
}
|
||||
|
||||
const propAddr = (p) => p?.address ?? '';
|
||||
const propImages = (p) => Array.isArray(p?.images) ? p.images : [];
|
||||
const propBeds = (p) => p?.numberOfBedRooms ?? 0;
|
||||
const propBaths = (p) => p?.numberOfBathRooms ?? 0;
|
||||
|
||||
function ReservationCard({ r, onViewDetails }) {
|
||||
const p = r._prop;
|
||||
const imgs = propImages(p);
|
||||
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
||||
const addr = propAddr(p);
|
||||
const beds = propBeds(p);
|
||||
const baths = propBaths(p);
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity:0,y:20 }} animate={{ opacity:1,y:0 }}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
|
||||
<div className="p-5">
|
||||
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover" /></div>}
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<StatusBadge code={r.status} />
|
||||
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString() ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">السعر الإجمالي</div>
|
||||
</div>
|
||||
</div>
|
||||
{(beds||baths) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{beds>0&&<span>{beds} غرف</span>}{baths>0&&<span>{baths} حمامات</span>}</div>}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
|
||||
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
|
||||
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-3 border-t border-gray-100">
|
||||
<button onClick={() => onViewDetails(r)}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||
<Eye className="w-4 h-4"/> التفاصيل
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsModal({ r, isOpen, onClose }) {
|
||||
if (!isOpen || !r) return null;
|
||||
const p = r._prop;
|
||||
|
||||
return (
|
||||
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
|
||||
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">تفاصيل الحجز</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
||||
</div>
|
||||
<p className="text-amber-100 text-sm mt-1">رقم الحجز: #{r.id}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{p && <div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
|
||||
<p><span className="text-gray-500">العنوان:</span> {propAddr(p)||'—'}</p>
|
||||
{(propBeds(p)||propBaths(p)) && <div className="flex gap-3 mt-2">
|
||||
{propBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBeds(p)} غرف</span>}
|
||||
{propBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBaths(p)} حمامات</span>}
|
||||
</div>}
|
||||
</div>}
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
|
||||
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
|
||||
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
|
||||
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/> المعلومات المالية</h3>
|
||||
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??'—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserReservationsPage() {
|
||||
const router = useRouter();
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [filtered, setFiltered] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
|
||||
|
||||
const loadReservations = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/Reservations/GetUserResevations`, {
|
||||
headers: { Authorization: `Bearer ${AuthService.getToken()}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
const enriched = await Promise.all(list.map(enrich));
|
||||
setReservations(enriched);
|
||||
setFiltered(enriched);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('فشل تحميل الحجوزات');
|
||||
setReservations([]);
|
||||
setFiltered([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let r = reservations;
|
||||
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
|
||||
if (searchTerm) { const q = searchTerm.toLowerCase(); r = r.filter(x => propAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q)); }
|
||||
setFiltered(r);
|
||||
}, [reservations, filterStatus, searchTerm]);
|
||||
|
||||
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
|
||||
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
|
||||
<p className="text-gray-600">لديك {reservations.length} حجز</p>
|
||||
</motion.div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{Object.entries(counts).map(([s, c]) => (
|
||||
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
|
||||
onClick={() => setFilterStatus(s)}>
|
||||
<div className="text-2xl font-bold text-amber-600">{c}</div>
|
||||
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-6 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
|
||||
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
|
||||
<p className="text-gray-600">لم تقم بأي حجز حتى الآن</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filtered.map(r => <ReservationCard key={r.id} r={r} onViewDetails={setSelected} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
app/services/AuthService.js
Normal file
162
app/services/AuthService.js
Normal file
@ -0,0 +1,162 @@
|
||||
/**
|
||||
* AuthService
|
||||
* Manages authentication tokens and user role detection via JWT decoding.
|
||||
*
|
||||
* Roles (from JWT claims):
|
||||
* - Owner: roles array contains "Owner"
|
||||
* - Customer: authenticated but no "Owner" role
|
||||
* - Guest: no token
|
||||
*
|
||||
* Methods:
|
||||
* addToken(token) — store JWT token
|
||||
* getToken() — retrieve JWT token
|
||||
* deleteToken() — remove JWT token
|
||||
* decodeToken() — decode JWT payload
|
||||
* getUser() — get decoded user info
|
||||
* getRoles() — get roles array from JWT
|
||||
* isOwner() — check if user has Owner role
|
||||
* isCustomer() — check if user is authenticated but not Owner
|
||||
* isGuest() — check if no token exists
|
||||
* isAuthenticated() — check if token exists
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const USER_KEY = 'cached_user';
|
||||
|
||||
const AuthService = Object.freeze({
|
||||
addToken(token) {
|
||||
if (!token || typeof token !== 'string') return;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
deleteToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache full user profile (from API)
|
||||
* @param {object} user — { name, email, phone, ... }
|
||||
*/
|
||||
cacheUser(user) {
|
||||
if (!user) return;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cached user profile
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getCachedUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Decode JWT payload (base64)
|
||||
* @returns {object|null}
|
||||
*/
|
||||
decodeToken() {
|
||||
const token = this.getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
return JSON.parse(atob(payload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract user info from JWT
|
||||
* @returns {object|null} — { id, name, email, phone, roles }
|
||||
*/
|
||||
getUser() {
|
||||
const payload = this.decodeToken();
|
||||
if (!payload) return null;
|
||||
|
||||
const cached = this.getCachedUser();
|
||||
|
||||
return {
|
||||
id: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] || payload.sub || null,
|
||||
name: cached?.name || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null,
|
||||
email: cached?.email || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || null,
|
||||
phone: cached?.phone || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone'] || null,
|
||||
roles: this.getRoles(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current authenticated user id
|
||||
* @returns {number|string|null}
|
||||
*/
|
||||
getUserId() {
|
||||
const user = this.getUser();
|
||||
if (!user?.id) return null;
|
||||
|
||||
const parsedId = Number(user.id);
|
||||
return Number.isFinite(parsedId) ? parsedId : user.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get roles array from JWT
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getRoles() {
|
||||
const payload = this.decodeToken();
|
||||
if (!payload) return [];
|
||||
const roles = payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
|
||||
if (Array.isArray(roles)) return roles;
|
||||
if (typeof roles === 'string') return [roles];
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* User has Owner role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOwner() {
|
||||
return this.getRoles().includes('Owner');
|
||||
},
|
||||
|
||||
/**
|
||||
* User has Admin role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAdmin() {
|
||||
return this.getRoles().includes('Admin');
|
||||
},
|
||||
|
||||
/**
|
||||
* Authenticated user without Owner or Admin role (i.e. customer)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isCustomer() {
|
||||
return this.isAuthenticated() && !this.isOwner() && !this.isAdmin();
|
||||
},
|
||||
|
||||
/**
|
||||
* No token — guest user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isGuest() {
|
||||
return !this.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* Token exists
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAuthenticated() {
|
||||
return !!this.getToken();
|
||||
},
|
||||
});
|
||||
|
||||
export default AuthService;
|
||||
450
app/utils/api.js
Normal file
450
app/utils/api.js
Normal file
@ -0,0 +1,450 @@
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
/**
|
||||
* Generic API fetch — attaches auth token, unwraps { data } envelope
|
||||
*/
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = AuthService.getToken();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
console.log('[API] Request:', options.method || 'GET', `${API_BASE}${endpoint}`);
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
console.log('[API] Response:', res.status, endpoint);
|
||||
|
||||
if (!res.ok && res.status !== 206) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[API] Error:', res.status, text);
|
||||
throw new Error(`API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json && typeof json === 'object' && 'data' in json) {
|
||||
return json.data;
|
||||
}
|
||||
return json;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth fetch — returns full { status, data, ok } for status-code handling
|
||||
*/
|
||||
async function authFetch(endpoint, body, token = null) {
|
||||
console.log('[Auth] Request:', `${API_BASE}${endpoint}`);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
console.log('[Auth] Sending with Bearer token');
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log('[Auth] Response status:', res.status, endpoint);
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
// Build message from response for toast display
|
||||
const message = (typeof data === 'object' && data?.message) ? data.message : null;
|
||||
|
||||
return { status: res.status, data, ok: res.ok || res.status === 206, message };
|
||||
}
|
||||
|
||||
// ─── Rent Properties ───
|
||||
|
||||
export async function getRentProperties() {
|
||||
return apiFetch('/RentProperties/GetRentProperties');
|
||||
}
|
||||
|
||||
export async function getRentProperty(id) {
|
||||
return apiFetch(`/RentProperties/GetRentPropertyById/${id}`);
|
||||
}
|
||||
|
||||
export async function getRentPropertyLocations(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.maxOffset != null) qs.set('maxOffset', params.maxOffset);
|
||||
if (params.minOffset != null) qs.set('minOffset', params.minOffset);
|
||||
const query = qs.toString();
|
||||
return apiFetch(`/RentProperties/GetRentPropertiesLocations${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
// ─── Sale Properties ───
|
||||
|
||||
export async function getSaleProperties() {
|
||||
return apiFetch('/SaleProperties/GetSaleProperties');
|
||||
}
|
||||
|
||||
export async function getSaleProperty(id) {
|
||||
const items = await apiFetch('/SaleProperties/GetSaleProperties');
|
||||
if (!Array.isArray(items)) return items;
|
||||
return items.find(p => p.id == id) || items[0];
|
||||
}
|
||||
|
||||
// ─── Properties (generic) ───
|
||||
|
||||
export async function getProperty(id) {
|
||||
return apiFetch(`/Properties/Get/${id}`);
|
||||
}
|
||||
|
||||
// ─── Recommendations ───
|
||||
|
||||
export async function getRecommendations() {
|
||||
return apiFetch('/Recommendations/GetRecommendations');
|
||||
}
|
||||
|
||||
export async function getTopRecommendations(count = 10) {
|
||||
return apiFetch(`/Recommendations/GetTopRecommendations?count=${count}`);
|
||||
}
|
||||
|
||||
// ─── Reservations ───
|
||||
|
||||
export async function getAvailableDateRanges(propertyId) {
|
||||
console.log('[API] Fetching available dates for property:', propertyId);
|
||||
return apiFetch(`/Reservations/GetAvailableDates/available/${propertyId}`);
|
||||
}
|
||||
|
||||
export async function getReservations() {
|
||||
return apiFetch('/Reservations/GetAllReservations');
|
||||
}
|
||||
|
||||
export async function getReservation(id) {
|
||||
return apiFetch(`/Reservations/GetReservation?id=${id}`);
|
||||
}
|
||||
|
||||
export async function checkAvailability(propertyId, fromDate = null, toDate = null) {
|
||||
const qs = new URLSearchParams();
|
||||
if (fromDate) qs.set('fromDate', fromDate);
|
||||
if (toDate) qs.set('toDate', toDate);
|
||||
const query = qs.toString();
|
||||
return apiFetch(`/Reservations/GetAvailable/${propertyId}${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function bookReservation(propertyId, startDate, endDate) {
|
||||
console.log('[API] Booking reservation:', { propertyId, startDate, endDate });
|
||||
return apiFetch('/Reservations/BookReservation/book', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ propertyId, startDate, endDate }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Terms ───
|
||||
|
||||
export async function getTerms() {
|
||||
return apiFetch('/Terms/GetTerms');
|
||||
}
|
||||
|
||||
// ─── Profile ───
|
||||
|
||||
export async function getCustomerByUserId(userId) {
|
||||
console.log('[API] Fetching customer by user ID:', userId);
|
||||
return apiFetch(`/Customer/GetByUserId/${userId}`);
|
||||
}
|
||||
|
||||
export async function getOwnerByUserId(userId) {
|
||||
console.log('[API] Fetching owner by user ID:', userId);
|
||||
return apiFetch(`/Owner/GetByUserId/${userId}`);
|
||||
}
|
||||
|
||||
// ─── Properties ───
|
||||
|
||||
export async function getMyRentListings() {
|
||||
console.log('[API] Fetching my rent listings');
|
||||
return apiFetch(`/RentProperties/GetMyRentListings`);
|
||||
}
|
||||
|
||||
export async function addRentProperty(data) {
|
||||
console.log('[API] Adding rent property:', data.PropertyInformation?.Address);
|
||||
return apiFetch('/RentProperties/AddRentProperty', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Currencies ───
|
||||
|
||||
export async function getCurrencies() {
|
||||
return apiFetch('/Currency/GetAll');
|
||||
}
|
||||
|
||||
// ─── Files ───
|
||||
|
||||
export async function uploadPicture(file) {
|
||||
console.log('[API] Uploading picture:', file.name);
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const token = AuthService.getToken();
|
||||
const res = await fetch(`${API_BASE}/Files/UploadPicture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
const text = await res.text();
|
||||
console.log('[API] Upload response:', res.status, text?.substring(0, 100));
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${text}`);
|
||||
// Response is the relative path string (e.g. /Pictures/abc123.jpg)
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json?.data || json;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth: Registration ───
|
||||
|
||||
/**
|
||||
* Register a new owner
|
||||
* @param {Object} data — { name, email, phoneNumber, whatsAppNumber, password, ownerType }
|
||||
* @returns {Promise<{status, data, ok, message}>}
|
||||
*/
|
||||
// Multipart form-data fetch for file uploads
|
||||
async function multipartAuthFetch(endpoint, formData) {
|
||||
console.log('[Auth] Multipart request:', `${API_BASE}${endpoint}`);
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
// Don't set Content-Type — browser sets it with boundary
|
||||
body: formData,
|
||||
});
|
||||
|
||||
console.log('[Auth] Response status:', res.status, endpoint);
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
return { status: res.status, data, ok: res.ok || res.status === 206, message: data?.message };
|
||||
}
|
||||
|
||||
export async function addOwner(data, frontImage = null, backImage = null) {
|
||||
console.log('[Auth] Registering owner (multipart):', data.email);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('FirstName', data.firstName || data.FirstName || '');
|
||||
formData.append('LastName', data.lastName || data.LastName || '');
|
||||
formData.append('Email', data.email || '');
|
||||
formData.append('PhoneNumber', data.phoneNumber || '');
|
||||
formData.append('WhatsAppNumber', data.whatsAppNumber || '');
|
||||
formData.append('Phone', data.phone || '');
|
||||
formData.append('NationalNumber', data.nationalNumber || '');
|
||||
formData.append('Password', data.password || '');
|
||||
formData.append('Type', String(data.ownerType ?? data.Type ?? 0));
|
||||
formData.append('Language', '0');
|
||||
|
||||
if (frontImage) formData.append('FrontIdCarImagePath', frontImage);
|
||||
if (backImage) formData.append('RearIdCarImagePath', backImage);
|
||||
|
||||
return multipartAuthFetch('/Owner/Add', formData);
|
||||
}
|
||||
|
||||
export async function addCustomer(data, frontImage = null, backImage = null) {
|
||||
console.log('[Auth] Registering customer (multipart):', data.email);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('FirstName', data.firstName || data.FirstName || '');
|
||||
formData.append('LastName', data.lastName || data.LastName || '');
|
||||
formData.append('Email', data.email || '');
|
||||
formData.append('PhoneNumber', data.phoneNumber || '');
|
||||
formData.append('WhatsAppNumber', data.whatsAppNumber || '');
|
||||
formData.append('Phone', data.phone || '');
|
||||
formData.append('NationalNumber', data.nationalNumber || '');
|
||||
formData.append('Password', data.password || '');
|
||||
formData.append('Type', String(data.customerType ?? data.Type ?? 0));
|
||||
formData.append('Language', '0');
|
||||
|
||||
if (frontImage) formData.append('FrontIdCarImagePath', frontImage);
|
||||
if (backImage) formData.append('RearIdCarImagePath', backImage);
|
||||
|
||||
return multipartAuthFetch('/Customer/Add', formData);
|
||||
}
|
||||
|
||||
// ─── Auth: Login ───
|
||||
|
||||
export async function loginWithEmail(credential, password) {
|
||||
console.log('[Auth] Login with email:', credential);
|
||||
return authFetch('/Auth/LogInWithEmail', {
|
||||
credential,
|
||||
password,
|
||||
device: 0,
|
||||
appVersion: '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginWithPhone(credential, password) {
|
||||
console.log('[Auth] Login with phone:', credential);
|
||||
return authFetch('/Auth/LogInWithPhoneNumber', {
|
||||
credential,
|
||||
password,
|
||||
device: 0,
|
||||
appVersion: '',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth: OTP ───
|
||||
|
||||
export async function sendEmailOTP() {
|
||||
console.log('[Auth] Sending email OTP...');
|
||||
return apiFetch('/Auth/SendEmailOTP', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function sendPhoneOTP() {
|
||||
console.log('[Auth] Sending phone OTP...');
|
||||
return apiFetch('/Auth/SendPhoneNumberOTP', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function verifyEmail(code) {
|
||||
console.log('[Auth] Verifying email with code:', code);
|
||||
const token = AuthService.getToken();
|
||||
return authFetch(`/Auth/VerifyEmail?code=${encodeURIComponent(code)}`, {}, token);
|
||||
}
|
||||
|
||||
export async function verifyPhone(code) {
|
||||
console.log('[Auth] Verifying phone with code:', code);
|
||||
const token = AuthService.getToken();
|
||||
return authFetch(`/Auth/VerifyPhoneNumber?code=${encodeURIComponent(code)}`, {}, token);
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
export function isEmail(value) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
export function isPhoneNumber(value) {
|
||||
return /^\+?\d{7,15}$/.test(value.replace(/[\s\-()]/g, ''));
|
||||
}
|
||||
|
||||
// ─── Favorites ───
|
||||
|
||||
export async function getUserFavoriteProperties() {
|
||||
return apiFetch('/FavoriteProperty/GetUserFavoriteProperties');
|
||||
}
|
||||
|
||||
export async function addFavoriteProperty(propId) {
|
||||
return apiFetch(`/FavoriteProperty/Add?propId=${propId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function removeFavoriteProperty(favePropId) {
|
||||
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getUserNotifications() {
|
||||
return apiFetch('/Notifications/GetUserNotifications');
|
||||
}
|
||||
|
||||
// ─── Booking/Reservation Management ───
|
||||
|
||||
export async function confirmDepositPayment(bookingId) {
|
||||
return apiFetch('/Reservations/ConfirmDepositPayment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ bookingId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
|
||||
const token = AuthService.getToken();
|
||||
const endpoint = `${API_BASE}/Reservations/AdminConfirmDeposit/admin-confirm-deposit`;
|
||||
const normalizedComment =
|
||||
typeof comment === 'string' && comment.trim()
|
||||
? comment.trim()
|
||||
: null;
|
||||
const payload = {
|
||||
reservationId,
|
||||
adminId,
|
||||
comment: normalizedComment,
|
||||
};
|
||||
|
||||
console.log('[API] AdminConfirmDeposit request', {
|
||||
method: 'PUT',
|
||||
endpoint,
|
||||
payload,
|
||||
adminIdSource: 'jwt-user-id',
|
||||
hasToken: Boolean(token),
|
||||
tokenPreview: token ? `${token.slice(0, 18)}...${token.slice(-8)}` : null,
|
||||
});
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
|
||||
console.log('[API] AdminConfirmDeposit raw response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
endpoint,
|
||||
rawText: text,
|
||||
});
|
||||
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
const message = typeof data === 'object' && data?.message ? data.message : null;
|
||||
|
||||
console.log('[API] AdminConfirmDeposit parsed response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
message,
|
||||
data,
|
||||
});
|
||||
|
||||
return { status: res.status, data, ok: res.ok, message };
|
||||
}
|
||||
|
||||
export async function updateBookingStatus(bookingId, status) {
|
||||
return apiFetch('/Reservations/UpdateStatus', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ bookingId, status }),
|
||||
});
|
||||
}
|
||||
@ -1,41 +1,71 @@
|
||||
export const PROPERTY_STATUS = {
|
||||
AVAILABLE: 'available',
|
||||
BOOKED: 'booked',
|
||||
MAINTENANCE: 'maintenance'
|
||||
};
|
||||
/**
|
||||
* Constants — re-exports from enums for backward compatibility
|
||||
*
|
||||
* New code should import directly from:
|
||||
* import { BuildingType, BookingStatus, City, ... } from '@/app/enums';
|
||||
*
|
||||
* Old imports from '@/app/utils/constants' continue to work.
|
||||
*/
|
||||
|
||||
export const BOOKING_STATUS = {
|
||||
PENDING: 'pending',
|
||||
OWNER_APPROVED: 'owner_approved',
|
||||
ADMIN_APPROVED: 'admin_approved',
|
||||
REJECTED: 'rejected',
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
CANCELLED: 'cancelled'
|
||||
};
|
||||
// Re-export all enums
|
||||
export {
|
||||
BuildingType,
|
||||
BuildingTypeLabels,
|
||||
BuildingTypeKeys,
|
||||
BuildingTypeByKey,
|
||||
} from '../enums/BuildingType';
|
||||
|
||||
export const COMMISSION_TYPE = {
|
||||
FROM_OWNER: 'from_owner',
|
||||
FROM_TENANT: 'from_tenant',
|
||||
FROM_BOTH: 'from_both'
|
||||
};
|
||||
export {
|
||||
PropertyStatus,
|
||||
PropertyStatusLabels,
|
||||
PropertyStatusKeys,
|
||||
PropertyStatusByKey,
|
||||
} from '../enums/PropertyStatus';
|
||||
|
||||
export const IDENTITY_TYPE = {
|
||||
SYRIAN: 'syrian',
|
||||
PASSPORT: 'passport'
|
||||
};
|
||||
export {
|
||||
BookingStatus,
|
||||
BookingStatusLabels,
|
||||
BookingStatusColors,
|
||||
} from '../enums/BookingStatus';
|
||||
|
||||
export const PAYMENT_METHOD = {
|
||||
export {
|
||||
CommissionType,
|
||||
CommissionTypeLabels,
|
||||
} from '../enums/CommissionType';
|
||||
|
||||
export {
|
||||
IdentityType,
|
||||
IdentityTypeLabels,
|
||||
IdentityTypeFlags,
|
||||
} from '../enums/IdentityType';
|
||||
|
||||
export {
|
||||
UserRole,
|
||||
UserRoleLabels,
|
||||
UserRoleColors,
|
||||
} from '../enums/UserRole';
|
||||
|
||||
export {
|
||||
City,
|
||||
CitiesList,
|
||||
extractCity,
|
||||
} from '../enums/City';
|
||||
|
||||
export { LoginMethod } from '../enums/LoginMethod';
|
||||
export { OwnerType, OwnerTypeLabels } from '../enums/OwnerType';
|
||||
export { CustomerType, CustomerTypeLabels } from '../enums/CustomerType';
|
||||
|
||||
// ─── Legacy aliases (keep old imports working) ───
|
||||
export const PROPERTY_STATUS = PropertyStatusKeys;
|
||||
export const BOOKING_STATUS = BookingStatus;
|
||||
export const COMMISSION_TYPE = CommissionType;
|
||||
export const IDENTITY_TYPE = IdentityType;
|
||||
export const CITIES = City;
|
||||
|
||||
// ─── Misc constants ───
|
||||
export const PAYMENT_METHOD = Object.freeze({
|
||||
CASH: 'cash',
|
||||
ELECTRONIC: 'electronic'
|
||||
};
|
||||
|
||||
export const CITIES = {
|
||||
DAMASCUS: 'damascus',
|
||||
ALEPPO: 'aleppo',
|
||||
HOMS: 'homs',
|
||||
LATTAKIA: 'latakia',
|
||||
DARAA: 'daraa'
|
||||
};
|
||||
ELECTRONIC: 'electronic',
|
||||
});
|
||||
|
||||
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,22 @@
|
||||
const nextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "45.93.137.91.nip.io",
|
||||
pathname: "/api/Pictures/**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "45.93.137.91",
|
||||
pathname: "/api/Pictures/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
// basePath: "/sweetHome",
|
||||
// assetPrefix: "/sweetHome/",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1507
package-lock.json
generated
1507
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -5,24 +5,28 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start -p 5900"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pbe/react-yandex-maps": "^1.2.5",
|
||||
"firebase": "^12.11.0",
|
||||
"flowbite": "^4.0.1",
|
||||
"flowbite-react": "^0.12.16",
|
||||
"framer-motion": "^12.29.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-intersection-observer": "^10.0.3",
|
||||
"react-leaflet": "^5.0.0"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
BIN
public/apartment1.jpg
Normal file
BIN
public/apartment1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
public/apartment2.jpg
Normal file
BIN
public/apartment2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
public/files/app-release.apk
Normal file
BIN
public/files/app-release.apk
Normal file
Binary file not shown.
38
public/firebase-messaging-sw.js
Normal file
38
public/firebase-messaging-sw.js
Normal file
@ -0,0 +1,38 @@
|
||||
// Firebase Cloud Messaging Service Worker
|
||||
// This file MUST be in the public/ directory (served at /firebase-messaging-sw.js)
|
||||
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js");
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js");
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
console.log("[FCM SW] Background message:", payload);
|
||||
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
|
||||
const options = {
|
||||
body: payload.notification?.body || payload.data?.body || "",
|
||||
icon: payload.notification?.icon || "/logo.png",
|
||||
badge: "/logo.png",
|
||||
data: payload.data,
|
||||
tag: "sweethome-notification",
|
||||
};
|
||||
self.registration.showNotification(title, options);
|
||||
});
|
||||
|
||||
// Handle notification click
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/";
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user