Compare commits
277 Commits
ac1241583b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5592d9fbd2 | |||
| 4b53669a35 | |||
| 00dde588cb | |||
| 4924e7c2f3 | |||
| 9d3d02c88a | |||
| 1fa2fabb38 | |||
| 793ec51c9a | |||
| e3e9aa81a8 | |||
| 989a7f580f | |||
| f7afef03d4 | |||
| f09561ef00 | |||
| e42b0421bd | |||
| 9602154ba4 | |||
| 9b4f9979b6 | |||
| b9a4c6157a | |||
| 4d1b744516 | |||
| 7da4bf9f2b | |||
| 7973c15e3f | |||
| 4ebb75ddd9 | |||
| 30693c6495 | |||
| 491f10a1e0 | |||
| f91dfaacad | |||
| b4bb3ec279 | |||
| 3ee4a93365 | |||
| 689170ebce | |||
| 46ecf74572 | |||
| 66db009562 | |||
| 83a06e8007 | |||
| 4166a5d652 | |||
| ca6b12bfb9 | |||
| 2374e80380 | |||
| 747e42dc00 | |||
| 038df6397d | |||
| b5f5fabfc8 | |||
| 8ed623bbe2 | |||
| 47550dbfac | |||
| 7176031485 | |||
| 9a7e7fb5f8 | |||
| c01ada480d | |||
| c365503382 | |||
| bb47b05858 | |||
| 3bef951d5c | |||
| 2d2e2427a2 | |||
| 3b66bb53f1 | |||
| c7698741c7 | |||
| dab5b62fb7 | |||
| 3052209df8 | |||
| 409cb41dd6 | |||
| 3a3fa5f3fd | |||
| 03b39317c9 | |||
| dc64178f94 | |||
| 253e1b7542 | |||
| d7ef0977ad | |||
| 4f58519bac | |||
| df5801ee25 | |||
| 9dcbb940c6 | |||
| 12cb804b58 | |||
| 5738d0369e | |||
| c0fdc7ef03 | |||
| 389dca56d2 | |||
| 6e4d629cbe | |||
| 13b83e9aea | |||
| 4a9380313b | |||
| 8d3a745cc3 | |||
| 199478f237 | |||
| 61e527fab3 | |||
| d0b626ac8c | |||
| 792bed6250 | |||
| bf45a48504 | |||
| eaa4206b0b | |||
| c5d9ad7b70 | |||
| 46945464ae | |||
| 64d422ddd8 | |||
| 224c29bc19 | |||
| f892fc4a4d | |||
| 01ac4f8d6c | |||
| f2724a5cd2 | |||
| bef133ad5b | |||
| a9eb1cc684 | |||
| 13b563e35e | |||
| 5d593d593f | |||
| 51850b85c2 | |||
| 8cacf464d1 | |||
| 91de3d47b7 | |||
| 71b1a71904 | |||
| 34da1314d4 | |||
| ce6caf08eb | |||
| 845ba2436a | |||
| 471332b59f | |||
| 53a83494b7 | |||
| 6bc0c8ba27 | |||
| 74dc12171d | |||
| 9fdeadaa61 | |||
| d11f105dfc | |||
| 8d4ac3ddd6 | |||
| b4196c340d | |||
| ddf5367f92 | |||
| 417b6cb393 | |||
| 819d5ea802 | |||
| f1a14c2f7a | |||
| 29de55196a | |||
| 01b966c1d2 | |||
| e1204bf69e | |||
| a0a78079c5 | |||
| e201b1cd8a | |||
| 3466d4715a | |||
| 88884ed2d7 | |||
| e21dc53227 | |||
| 82e39e6f90 | |||
| caddf55811 | |||
| a3657e5c2f | |||
| b8d779a1d0 | |||
| c53a8eeb6d | |||
| 9979877e1c | |||
| 9fda2618c8 | |||
| bf044cef45 | |||
| 3f24696c48 | |||
| 9c79e0648b | |||
| 13a0649806 | |||
| 1ae3a635f7 | |||
| 0e61dd5789 | |||
| 50a4816327 | |||
| 09bbf07d8c | |||
| 439f69419f | |||
| 71ce933c6a | |||
| 5045e69191 | |||
| f91c78f759 | |||
| 80ea9af86b | |||
| 4c350d7589 | |||
| ca94c5fd87 | |||
| c40486b99e | |||
| 9f6492cf41 | |||
| 085c60ec33 | |||
| 2c4a163cdb | |||
| 8d48fcae99 | |||
| af54bded13 | |||
| 4988302bc1 | |||
| 427dc74e7f | |||
| 7b333f9b53 | |||
| 291e82a1b4 | |||
| 9d8ccbac3c | |||
| 7a3248aec5 | |||
| aff9912ac2 | |||
| 50d636a577 | |||
| 00ccf5f262 | |||
| a5577765ed | |||
| 4bedd24315 | |||
| 378d2d0f37 | |||
| 5936e0f553 | |||
| 84f5bbe9db | |||
| 46cbce1d88 | |||
| 1198c18004 | |||
| 481f683e64 | |||
| 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 |
@ -6,11 +6,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
repository: Rahaf/SweetHome
|
repository: amer2004/SweetHome
|
||||||
github-server-url: http://45.93.137.91:3000
|
github-server-url: http://45.93.137.91:3000
|
||||||
|
|
||||||
- name: Stopping server
|
- name: Stopping server
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"i18n-ally-next.localesPaths": ["app/i18n"]
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||||
|
import BottomNav from "./components/BottomNav";
|
||||||
import {
|
import {
|
||||||
Globe,
|
Globe,
|
||||||
LogIn,
|
LogIn,
|
||||||
@ -15,28 +16,27 @@ import {
|
|||||||
Building,
|
Building,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Heart,
|
Heart,
|
||||||
MessageCircle,
|
|
||||||
Settings,
|
Settings,
|
||||||
Edit,
|
|
||||||
Phone,
|
Phone,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
|
||||||
Camera,
|
|
||||||
Shield,
|
|
||||||
Bell,
|
Bell,
|
||||||
Home,
|
|
||||||
ChevronDown,
|
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Clock,
|
|
||||||
Users,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
Star,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import AuthService from "./services/AuthService";
|
||||||
|
import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||||
import "./i18n/config";
|
import "./i18n/config";
|
||||||
|
import NotificationHandler from "./components/NotificationHandler";
|
||||||
|
|
||||||
|
import { useNotifications } from "@/app/contexts/NotificationsContext";
|
||||||
|
import Loading from "./loading";
|
||||||
|
|
||||||
export default function ClientLayout({ children }) {
|
export default function ClientLayout({ children }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@ -49,19 +49,14 @@ export default function ClientLayout({ children }) {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
const { unreadCount } = useNotifications();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
const savedLanguage = localStorage.getItem("language") || "ar";
|
const savedLanguage = localStorage.getItem("language") || "ar";
|
||||||
setCurrentLanguage(savedLanguage);
|
setCurrentLanguage(savedLanguage);
|
||||||
i18n.changeLanguage(savedLanguage);
|
i18n.changeLanguage(savedLanguage);
|
||||||
|
|
||||||
const storedUser = localStorage.getItem("user");
|
|
||||||
if (storedUser) {
|
|
||||||
const userData = JSON.parse(storedUser);
|
|
||||||
console.log("User data loaded:", userData);
|
|
||||||
setUser(userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedLanguage === "ar") {
|
if (savedLanguage === "ar") {
|
||||||
document.documentElement.dir = "rtl";
|
document.documentElement.dir = "rtl";
|
||||||
document.documentElement.lang = "ar";
|
document.documentElement.lang = "ar";
|
||||||
@ -71,6 +66,21 @@ export default function ClientLayout({ children }) {
|
|||||||
}
|
}
|
||||||
}, [i18n]);
|
}, [i18n]);
|
||||||
|
|
||||||
|
// Re-read user from JWT on every route change (handles post-login)
|
||||||
|
useEffect(() => {
|
||||||
|
const authUser = AuthService.getUser();
|
||||||
|
if (authUser) {
|
||||||
|
setUser({
|
||||||
|
name: authUser.name || authUser.email,
|
||||||
|
email: authUser.email,
|
||||||
|
phone: authUser.phone,
|
||||||
|
role: AuthService.isOwner() ? UserRole.OWNER : AuthService.isAgent() ? UserRole.AGENT : UserRole.CUSTOMER,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@ -104,26 +114,22 @@ export default function ClientLayout({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem("user");
|
AuthService.deleteToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowUserMenu(false);
|
setShowUserMenu(false);
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAuthPage = [
|
const isAuthPage = ["/login", "/blocked", "/register", "/forgot-password", "/auth/choose-role"].includes(pathname);
|
||||||
"/login",
|
|
||||||
"/register",
|
|
||||||
"/forgot-password",
|
|
||||||
"/auth/choose-role",
|
|
||||||
].includes(pathname);
|
|
||||||
|
|
||||||
const isProfilePage = pathname === "/profile";
|
const isProfilePage = pathname === "/profile";
|
||||||
|
const isHomePage = pathname === "/";
|
||||||
|
|
||||||
const isOwner = user?.role === "owner";
|
const isOwner = user?.role === UserRole.OWNER;
|
||||||
const isAdmin = user?.role === "admin";
|
const isAgent = user?.role === UserRole.AGENT;
|
||||||
|
const isOwnerOrAgent = isOwner || isAgent;
|
||||||
console.log("User role:", user?.role);
|
const isCustomer = user?.role === UserRole.CUSTOMER;
|
||||||
console.log("Is Admin:", isAdmin);
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
const getUserInitial = () => {
|
const getUserInitial = () => {
|
||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
@ -134,101 +140,99 @@ export default function ClientLayout({ children }) {
|
|||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<Loading/>
|
||||||
<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"></div>
|
|
||||||
<p className="text-gray-600">جاري التحميل...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen flex flex-col">
|
||||||
{!isAuthPage && (
|
{!isAuthPage && !isAuthenticated && (
|
||||||
<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">
|
<nav
|
||||||
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${isHomePage ? "bg-transparent backdrop-blur-sm" : "bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm"}`}
|
||||||
|
>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div
|
<div className={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}>
|
||||||
className={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link href="/" className="flex items-center space-x-3 group">
|
<Link href="/" className="flex items-center space-x-3 group">
|
||||||
<div className="relative w-[150px] h-[60px]">
|
<div className="relative w-[150px] h-[60px]">
|
||||||
<Image
|
<Image src="/logo.png" alt={t("logoAlt")} fill className="object-contain" priority />
|
||||||
src="/logo.png"
|
|
||||||
alt={t("logoAlt")}
|
|
||||||
fill
|
|
||||||
className="object-contain"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-3xl font-bold text-gray-800 hidden md:block">
|
<span className={`text-3xl font-bold hidden md:block ${isHomePage ? "text-white" : "text-gray-800"}`}>
|
||||||
{t("brandNamePart1")}
|
{t("brandNamePart1")}
|
||||||
<span className="text-amber-600">
|
<span className={isHomePage ? "text-amber-400" : "text-amber-600"}>{t("brandNamePart2")}</span>
|
||||||
{t("brandNamePart2")}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
<div
|
<div className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}>
|
||||||
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
|
{/* Download App Dropdown */}
|
||||||
>
|
<div className="relative group">
|
||||||
<Link
|
<button
|
||||||
href="/files/SweetHome.apk"
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${isHomePage ? "text-white/80 hover:text-amber-300 hover:bg-white/10" : "text-gray-700 hover:text-amber-600 hover:bg-amber-50"}`}
|
||||||
className="group flex items-center gap-2 text-gray-700 hover:text-green-600 transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="currentColor"
|
|
||||||
className="bi bi-android"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
>
|
||||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2" />
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||||
</svg>
|
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z" />
|
||||||
<span className="text-green-600 text-sm font-semibold opacity-0 max-w-0 overflow-hidden whitespace-nowrap group-hover:opacity-100 group-hover:max-w-xs transition-all duration-300">
|
<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>
|
<span className="text-sm font-semibold">{t("downloadApp")}</span>
|
||||||
</Link>
|
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<NavLink href="/">الرئيسية</NavLink>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
<NavLink href="/properties">عقاراتنا</NavLink>
|
</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">{t("android")}</p>
|
||||||
|
<p className="text-xs text-green-600">{t("downloadAPK")}</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">{t("ios")}</p>
|
||||||
|
<p className="text-xs text-gray-400">{t("comingSoon")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
<NavLink light={isHomePage} href="/">
|
||||||
<NavLink href="/admin">
|
{t("home")}
|
||||||
<span className="flex items-center gap-2">
|
</NavLink>
|
||||||
<Shield className="w-4 h-4" />
|
<NavLink light={isHomePage} href="/properties">
|
||||||
الإدارة
|
{t("ourProperties")}
|
||||||
</span>
|
</NavLink>
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<>
|
<>
|
||||||
<NavLink href="/owner/properties">
|
<NavLink light={isHomePage} href="/owner/properties">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Building className="w-4 h-4" />
|
<Building className="w-4 h-4" />
|
||||||
عقاراتي
|
{t("myProperties")}
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/owner/bookings">
|
<NavLink light={isHomePage} href="/owner/reservations">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
الحجوزات
|
{t("reservations")}
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/owner/calendar">
|
<NavLink light={isHomePage} href="/owner/profits">
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<CalendarDays className="w-4 h-4" />
|
|
||||||
التقويم
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
|
||||||
<NavLink href="/owner/profits">
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
الأرباح
|
{t("profits")}
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</>
|
</>
|
||||||
@ -236,16 +240,16 @@ export default function ClientLayout({ children }) {
|
|||||||
|
|
||||||
{!user && !isAuthPage && (
|
{!user && !isAuthPage && (
|
||||||
<>
|
<>
|
||||||
<NavLink href="/login">
|
<NavLink light={isHomePage} href="/login">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
تسجيل الدخول
|
{t("login")}
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/auth/choose-role">
|
<NavLink light={isHomePage} href="/auth/choose-role">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
إنشاء حساب
|
{t("createAccount")}
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</>
|
</>
|
||||||
@ -284,53 +288,57 @@ export default function ClientLayout({ children }) {
|
|||||||
>
|
>
|
||||||
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-4 text-white">
|
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-4 text-white">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center text-xl font-bold">
|
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center text-xl font-bold">{getUserInitial()}</div>
|
||||||
{getUserInitial()}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold">
|
<p className="font-bold">{user?.name || t("userLabel")}</p>
|
||||||
{user?.name || "مستخدم"}
|
<p className="text-xs text-amber-100">{user?.email || ""}</p>
|
||||||
</p>
|
<p className="text-xs text-amber-100 mt-1">{t(UserRoleLabels[user?.role] ? `userRole.${user?.role.toLowerCase()}` : "visitor")}</p>
|
||||||
<p className="text-xs text-amber-100">
|
|
||||||
{user?.email || ""}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-amber-100 mt-1">
|
|
||||||
{isOwner
|
|
||||||
? "مالك عقار"
|
|
||||||
: isAdmin
|
|
||||||
? "مدير النظام"
|
|
||||||
: "مستأجر"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<Link
|
<Link href="/profile" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" onClick={() => setShowUserMenu(false)}>
|
||||||
href="/profile"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
|
||||||
onClick={() => setShowUserMenu(false)}
|
|
||||||
>
|
|
||||||
<UserCircle className="w-5 h-5 text-amber-500" />
|
<UserCircle className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">الملف الشخصي</p>
|
<p className="font-medium">{t("profile")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("viewAndEditInfo")}</p>
|
||||||
عرض وتعديل معلوماتك
|
</div>
|
||||||
</p>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/favorites" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" onClick={() => setShowUserMenu(false)}>
|
||||||
|
<Heart className="w-5 h-5 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("favorites")}</p>
|
||||||
|
<p className="text-xs text-gray-500">{t("savedProperties")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/favorites"
|
href="/booked-properties"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
<Heart className="w-5 h-5 text-amber-500" />
|
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">المفضلة</p>
|
<p className="font-medium">{t("myBookedProperties")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("viewAndRateBooked")}</p>
|
||||||
العقارات المحفوظة
|
</div>
|
||||||
</p>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/my-rates" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" onClick={() => setShowUserMenu(false)}>
|
||||||
|
<Star className="w-5 h-5 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("myRatings")}</p>
|
||||||
|
<p className="text-xs text-gray-500">{t("ratingsReceived")}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" onClick={() => setShowUserMenu(false)}>
|
||||||
|
<Settings className="w-5 h-5 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("settings")}</p>
|
||||||
|
<p className="text-xs text-gray-500">{t("accountAndSecurity")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -345,10 +353,8 @@ export default function ClientLayout({ children }) {
|
|||||||
>
|
>
|
||||||
<Building className="w-5 h-5 text-amber-500" />
|
<Building className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">عقاراتي</p>
|
<p className="font-medium">{t("myProperties")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("manageYourProperties")}</p>
|
||||||
إدارة عقاراتك
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -359,24 +365,20 @@ export default function ClientLayout({ children }) {
|
|||||||
>
|
>
|
||||||
<PlusCircle className="w-5 h-5 text-amber-500" />
|
<PlusCircle className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">إضافة عقار</p>
|
<p className="font-medium">{t("addProperty")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("addNewProperty")}</p>
|
||||||
أضف عقاراً جديداً
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/owner/bookings"
|
href="/owner/reservations"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
<Calendar className="w-5 h-5 text-amber-500" />
|
<Calendar className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">الحجوزات</p>
|
<p className="font-medium">{t("reservations")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("manageReservations")}</p>
|
||||||
إدارة حجوزاتك
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -387,10 +389,8 @@ export default function ClientLayout({ children }) {
|
|||||||
>
|
>
|
||||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">التقويم</p>
|
<p className="font-medium">{t("calendar")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("propertyAvailability")}</p>
|
||||||
جدول توفر العقارات
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -401,106 +401,70 @@ export default function ClientLayout({ children }) {
|
|||||||
>
|
>
|
||||||
<TrendingUp className="w-5 h-5 text-amber-500" />
|
<TrendingUp className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">الأرباح</p>
|
<p className="font-medium">{t("profits")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("statisticsAndProfits")}</p>
|
||||||
إحصائيات وأرباح
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-gray-100 my-2"></div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
|
||||||
onClick={() => setShowUserMenu(false)}
|
|
||||||
>
|
|
||||||
<Shield className="w-5 h-5 text-amber-500" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">لوحة التحكم</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
إدارة المنصة
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin/users"
|
href="/owner/account-book"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
|
||||||
onClick={() => setShowUserMenu(false)}
|
|
||||||
>
|
|
||||||
<Users className="w-5 h-5 text-amber-500" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">المستخدمين</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
إدارة المستخدمين
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/admin/properties"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
|
||||||
onClick={() => setShowUserMenu(false)}
|
|
||||||
>
|
|
||||||
<Building className="w-5 h-5 text-amber-500" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">العقارات</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
إدارة جميع العقارات
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/admin/bookings"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/admin/ledger"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
<DollarSign className="w-5 h-5 text-amber-500" />
|
<DollarSign className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">دفتر الحسابات</p>
|
<p className="font-medium">{t("accountBook")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("financialRecords")}</p>
|
||||||
إدارة المعاملات المالية
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOwner && !isAdmin && user && (
|
{isCustomer && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t border-gray-100 my-2"></div>
|
<div className="border-t border-gray-100 my-2"></div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/tenant/bookings"
|
href="/reservations"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
<Calendar className="w-5 h-5 text-amber-500" />
|
<Calendar className="w-5 h-5 text-amber-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">حجوزاتي</p>
|
<p className="font-medium">{t("myBookings")}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t("viewBookings")}</p>
|
||||||
عرض حجوزاتك
|
</div>
|
||||||
</p>
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/booked-properties"
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("myBookedProperties")}</p>
|
||||||
|
<p className="text-xs text-gray-500">{t("viewAndRateBooked")}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/my-rates"
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
<Star className="w-5 h-5 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("myRatings")}</p>
|
||||||
|
<p className="text-xs text-gray-500">{t("ratingsReceived")}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/reports" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" onClick={() => setShowUserMenu(false)}>
|
||||||
|
<FileText className="w-5 h-5 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("reports")}</p>
|
||||||
|
<p className="text-xs text-gray-500">{t("submitAndTrackReports")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@ -508,16 +472,11 @@ export default function ClientLayout({ children }) {
|
|||||||
|
|
||||||
<div className="border-t border-gray-100 my-2"></div>
|
<div className="border-t border-gray-100 my-2"></div>
|
||||||
|
|
||||||
<button
|
<button onClick={logout} className="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||||
onClick={logout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">تسجيل الخروج</p>
|
<p className="font-medium">{t("logout")}</p>
|
||||||
<p className="text-xs text-red-400">
|
<p className="text-xs text-red-400">{t("endSession")}</p>
|
||||||
إنهاء الجلسة الحالية
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -532,9 +491,7 @@ export default function ClientLayout({ children }) {
|
|||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.1, rotate: 360 }}
|
whileHover={{ scale: 1.1, rotate: 360 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={() =>
|
onClick={() => changeLanguage(currentLanguage === "en" ? "ar" : "en")}
|
||||||
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"
|
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" />
|
<Globe className="w-5 h-5 text-gray-700" />
|
||||||
@ -555,11 +512,7 @@ export default function ClientLayout({ children }) {
|
|||||||
className="inline-flex items-center justify-center p-2 rounded-md text-gray-700 hover:text-amber-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-amber-500"
|
className="inline-flex items-center justify-center p-2 rounded-md text-gray-700 hover:text-amber-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-amber-500"
|
||||||
>
|
>
|
||||||
<span className="sr-only">{t("openMainMenu")}</span>
|
<span className="sr-only">{t("openMainMenu")}</span>
|
||||||
{isMobileMenuOpen ? (
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
<X className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<Menu className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -570,61 +523,66 @@ export default function ClientLayout({ children }) {
|
|||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="md:hidden bg-white border-t border-gray-200 shadow-lg"
|
className={`md:hidden border-t shadow-lg ${isHomePage ? "bg-black/60 backdrop-blur-md border-white/10" : "bg-white border-gray-200"}`}
|
||||||
>
|
>
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
<MobileNavLink href="/" onClick={closeMobileMenu}>
|
<MobileNavLink light={isHomePage} href="/" onClick={closeMobileMenu}>
|
||||||
{t("home")}
|
{t("home")}
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink href="/properties" onClick={closeMobileMenu}>
|
<MobileNavLink light={isHomePage} href="/properties" onClick={closeMobileMenu}>
|
||||||
{t("ourProducts")}
|
{t("ourProducts")}
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
|
|
||||||
{isAdmin && (
|
{/* Download App - Mobile */}
|
||||||
<MobileNavLink href="/admin" onClick={closeMobileMenu}>
|
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||||
<span className="flex items-center gap-2">
|
<p className={`px-3 py-1 text-xs font-medium ${isHomePage ? "text-white/50" : "text-gray-400"}`}>{t("downloadApp")}</p>
|
||||||
<Shield className="w-4 h-4" />
|
<a
|
||||||
الإدارة
|
href="/files/SweetHome.apk"
|
||||||
</span>
|
download
|
||||||
</MobileNavLink>
|
onClick={closeMobileMenu}
|
||||||
)}
|
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors ${isHomePage ? "text-green-400 hover:bg-white/10" : "text-green-600 hover:bg-green-50"}`}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{t("android")} - {t("downloadAPK")}
|
||||||
|
</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">
|
||||||
|
{t("ios")} - {t("comingSoon")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<>
|
<>
|
||||||
<MobileNavLink
|
<MobileNavLink light={isHomePage} href="/owner/properties" onClick={closeMobileMenu}>
|
||||||
href="/owner/properties"
|
|
||||||
onClick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Building className="w-4 h-4" />
|
<Building className="w-4 h-4" />
|
||||||
عقاراتي
|
{t("myProperties")}
|
||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink
|
<MobileNavLink light={isHomePage} href="/owner/reservations" onClick={closeMobileMenu}>
|
||||||
href="/owner/bookings"
|
|
||||||
onClick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
الحجوزات
|
{t("reservations")}
|
||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink
|
<MobileNavLink light={isHomePage} href="/owner/calendar" onClick={closeMobileMenu}>
|
||||||
href="/owner/calendar"
|
|
||||||
onClick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
التقويم
|
{t("calendar")}
|
||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink
|
<MobileNavLink light={isHomePage} href="/owner/profits" onClick={closeMobileMenu}>
|
||||||
href="/owner/profits"
|
|
||||||
onClick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
الأرباح
|
{t("profits")}
|
||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
</>
|
</>
|
||||||
@ -632,20 +590,17 @@ export default function ClientLayout({ children }) {
|
|||||||
|
|
||||||
{!user && (
|
{!user && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t border-gray-200 my-2"></div>
|
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||||
<MobileNavLink href="/login" onClick={closeMobileMenu}>
|
<MobileNavLink light={isHomePage} href="/login" onClick={closeMobileMenu}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
تسجيل الدخول
|
{t("login")}
|
||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink
|
<MobileNavLink light={isHomePage} href="/auth/choose-role" onClick={closeMobileMenu}>
|
||||||
href="/auth/choose-role"
|
|
||||||
onClick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
إنشاء حساب
|
{t("createAccount")}
|
||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
</>
|
</>
|
||||||
@ -656,73 +611,108 @@ export default function ClientLayout({ children }) {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isHomePage && (
|
||||||
|
<div className="fixed inset-0 -z-10">
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{ backgroundImage: "url(/hero.jpg)" }}
|
||||||
|
initial={{ scale: 1.1 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-black/70 via-black/60 to-black/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
className={`${!isAuthPage && !isProfilePage && !isAuthenticated ? "pt-20" : ""} flex-1 flex flex-col ${isHomePage ? "bg-transparent" : "bg-gray-50"} ${currentLanguage === "ar" ? "text-right" : "text-left"} ${isHomePage ? "relative" : ""} ${isAuthenticated && !isAuthPage ? "pb-20" : ""}`}
|
||||||
>
|
>
|
||||||
{children}
|
{!isAuthPage && isAuthenticated && (
|
||||||
|
<div className={`flex items-center justify-between px-4 py-2 sticky top-0 z-20 bg-transparent backdrop-blur-3xl ${isHomePage ? "" : "border-b border-gray-200/20"}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold px-3 py-1 rounded-lg ${!isAuthenticated ? (isHomePage ? "bg-white/20 text-white" : "bg-gray-200 text-gray-600") : isOwner ? (isHomePage ? "bg-amber-500/30 text-amber-200" : "bg-amber-100 text-amber-700") : isHomePage ? "bg-blue-500/30 text-blue-200" : "bg-blue-100 text-blue-700"}`}
|
||||||
|
>
|
||||||
|
{!isAuthenticated ? t("visitor") : isOwner ? t("owner") : t("customer")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/favorites"
|
||||||
|
className={`relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 ${pathname === "/favorites" ? "text-amber-400" : "nav-icon"}`}
|
||||||
|
>
|
||||||
|
<Heart className="w-5 h-5" />
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||||
|
{t("favorites")}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className={`relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 ${pathname === "/notifications" ? "text-amber-400" : "nav-icon"}`}
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
{isMounted && unreadCount > 0 && (
|
||||||
|
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">{unreadCount > 9 ? "9+" : unreadCount}</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||||
|
{t("notifications")}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className={`relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 ${pathname === "/settings" ? "text-amber-400" : "nav-icon"}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||||
|
{t("settings")}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => changeLanguage(currentLanguage === "ar" ? "en" : "ar")}
|
||||||
|
className="relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 nav-icon"
|
||||||
|
>
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||||
|
{currentLanguage === "ar" ? t("switchToEnglish") : t("switchToArabic")}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
{isAuthenticated && !isAuthPage && <BottomNav isOwner={isOwner} isOwnerOrAgent={isOwnerOrAgent} />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isAuthPage && !isProfilePage && (
|
{pathname === "/" && (
|
||||||
<footer className="bg-gray-900 text-white py-12">
|
<footer className="py-20" style={{backgroundColor:"#0f172a"}}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div
|
<div className={`grid grid-cols-1 md:grid-cols-4 gap-8 ${currentLanguage === "ar" ? "text-right" : "text-left"}`}>
|
||||||
className={`grid grid-cols-1 md:grid-cols-4 gap-8 ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<div className={`flex items-center ${currentLanguage === "ar" ? "flex-row-reverse" : "space-x-3"}`}>
|
||||||
className={`flex items-center ${currentLanguage === "ar" ? "flex-row-reverse" : "space-x-3"}`}
|
|
||||||
>
|
|
||||||
<div className="relative w-10 h-10">
|
<div className="relative w-10 h-10">
|
||||||
<Image
|
<Image src="/logo.png" alt={t("logoAlt")} fill className="object-contain" sizes="40px" />
|
||||||
src="/logo.png"
|
|
||||||
alt={t("logoAlt")}
|
|
||||||
fill
|
|
||||||
className="object-contain"
|
|
||||||
sizes="40px"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-3xl font-bold">
|
<span className="text-3xl font-bold">
|
||||||
{t("brandNamePart1")}
|
{t("brandNamePart1")}
|
||||||
<span className="text-amber-400">
|
<span className="text-amber-400">{t("brandNamePart2")}</span>
|
||||||
{t("brandNamePart2")}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm">{t("footerDescription")}</p>
|
||||||
{t("footerDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">{t("quickLinks")}</h3>
|
||||||
{t("quickLinks")}
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href="/" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||||
href="/"
|
|
||||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
|
||||||
>
|
|
||||||
{t("home")}
|
{t("home")}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href="/properties" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||||
href="/properties"
|
|
||||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
|
||||||
>
|
|
||||||
{t("ourProducts")}
|
{t("ourProducts")}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
{isAdmin && (
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
|
||||||
>
|
|
||||||
الإدارة
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -730,7 +720,9 @@ export default function ClientLayout({ children }) {
|
|||||||
<ul className="space-y-3 text-gray-400">
|
<ul className="space-y-3 text-gray-400">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<Phone className="w-5 h-5" />
|
<Phone className="w-5 h-5" />
|
||||||
<span>{t("phone")}</span>
|
<span dir="ltr" className="text-right">
|
||||||
|
{t("phone")}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
@ -747,6 +739,7 @@ export default function ClientLayout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</>
|
<NotificationHandler />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
app/HelperFunction/ApiProperty.js
Normal file
57
app/HelperFunction/ApiProperty.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from "../enums";
|
||||||
|
export function mapApiProperty(t, 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(t("smoke-allowed"));
|
||||||
|
if (item.isVisitorAllow) features.push(t("visitors-allowed"));
|
||||||
|
if (item.specializedFor) features.push(t("specialized"));
|
||||||
|
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} ${t("rooms")}`);
|
||||||
|
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} ${t("bathrooms")}`);
|
||||||
|
|
||||||
|
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 || t("propertyWithId", { id: item.id || index + 1 }),
|
||||||
|
description: info.description || "",
|
||||||
|
type: propType,
|
||||||
|
price: price,
|
||||||
|
priceUSD: price,
|
||||||
|
priceUnit,
|
||||||
|
listingType: isRentListing ? "rent" : "sale",
|
||||||
|
location: {
|
||||||
|
city: extractCity(info.address) || "damascus",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
app/HelperFunction/UploadImage.js
Normal file
20
app/HelperFunction/UploadImage.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
export const handleImageUpload = (side, file,t,setIdImagePreviews,setIdImages) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
toast.error(t("register.selectValidImage"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error(t("register.imageSizeLimit"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setIdImagePreviews((prev) => ({ ...prev, [side]: reader.result }));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
setIdImages((prev) => ({ ...prev, [side]: file }));
|
||||||
|
toast.success(t("register.imageUploadSuccess"), { style: { background: "#dcfce7", color: "#166534" } });
|
||||||
|
};
|
||||||
283
app/account-verification/page.js
Normal file
283
app/account-verification/page.js
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Mail, Phone, Shield, ChevronLeft, Loader2, CheckCircle, XCircle, Send, Key } from "lucide-react";
|
||||||
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
|
import AuthService from "../services/AuthService";
|
||||||
|
import { sendEmailOTP, sendPhoneOTP, verifyEmail, verifyPhone } from "../utils/api";
|
||||||
|
import InteractiveBackground from "../components/Animation/Background";
|
||||||
|
|
||||||
|
export default function AccountVerificationPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const [emailCode, setEmailCode] = useState("");
|
||||||
|
const [phoneCode, setPhoneCode] = useState("");
|
||||||
|
const [sendingEmailOTP, setSendingEmailOTP] = useState(false);
|
||||||
|
const [sendingPhoneOTP, setSendingPhoneOTP] = useState(false);
|
||||||
|
const [verifyingEmail, setVerifyingEmail] = useState(false);
|
||||||
|
const [verifyingPhone, setVerifyingPhone] = useState(false);
|
||||||
|
const [emailVerified, setEmailVerified] = useState(false);
|
||||||
|
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||||
|
const [showEmailInput, setShowEmailInput] = useState(false);
|
||||||
|
const [showPhoneInput, setShowPhoneInput] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authUser = AuthService.getUser();
|
||||||
|
if (authUser) {
|
||||||
|
setUser(authUser);
|
||||||
|
setIsLoading(false);
|
||||||
|
} else {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSendEmailOTP = async () => {
|
||||||
|
setSendingEmailOTP(true);
|
||||||
|
try {
|
||||||
|
await sendEmailOTP();
|
||||||
|
toast.success(t("accountVerification.emailOtpSent"));
|
||||||
|
setShowEmailInput(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t("accountVerification.otpSendFailed"));
|
||||||
|
} finally {
|
||||||
|
setSendingEmailOTP(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmail = async () => {
|
||||||
|
if (!emailCode.trim()) {
|
||||||
|
toast.error(t("accountVerification.otpRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerifyingEmail(true);
|
||||||
|
try {
|
||||||
|
await verifyEmail(emailCode.trim());
|
||||||
|
setEmailVerified(true);
|
||||||
|
setShowEmailInput(false);
|
||||||
|
toast.success(t("accountVerification.emailVerifiedSuccess"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t("accountVerification.otpInvalid"));
|
||||||
|
} finally {
|
||||||
|
setVerifyingEmail(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendPhoneOTP = async () => {
|
||||||
|
setSendingPhoneOTP(true);
|
||||||
|
try {
|
||||||
|
await sendPhoneOTP();
|
||||||
|
toast.success(t("accountVerification.phoneOtpSent"));
|
||||||
|
setShowPhoneInput(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t("accountVerification.otpSendFailed"));
|
||||||
|
} finally {
|
||||||
|
setSendingPhoneOTP(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyPhone = async () => {
|
||||||
|
if (!phoneCode.trim()) {
|
||||||
|
toast.error(t("accountVerification.otpRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerifyingPhone(true);
|
||||||
|
try {
|
||||||
|
await verifyPhone(phoneCode.trim());
|
||||||
|
setPhoneVerified(true);
|
||||||
|
setShowPhoneInput(false);
|
||||||
|
toast.success(t("accountVerification.phoneVerifiedSuccess"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t("accountVerification.otpInvalid"));
|
||||||
|
} finally {
|
||||||
|
setVerifyingPhone(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-8" dir="rtl">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<InteractiveBackground />
|
||||||
|
<div className="relative z-10 container mx-auto px-4 max-w-2xl">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3 mb-8">
|
||||||
|
<Link href="/settings" className="p-2 rounded-xl hover:bg-gray-200 transition-colors text-gray-600">
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{t("accountVerification.title")}</h1>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-6">
|
||||||
|
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
|
||||||
|
<Mail className="w-5 h-5 text-amber-600" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">{t("accountVerification.emailSection")}</h2>
|
||||||
|
{emailVerified && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
{t("accountVerification.verified")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">{user?.email || t("accountVerification.emailNotRegistered")}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{emailVerified ? t("accountVerification.emailVerifiedDesc") : t("accountVerification.emailVerifyDesc")}</p>
|
||||||
|
</div>
|
||||||
|
{!emailVerified && !showEmailInput && (
|
||||||
|
<button
|
||||||
|
onClick={handleSendEmailOTP}
|
||||||
|
disabled={sendingEmailOTP}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sendingEmailOTP ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
{sendingEmailOTP ? t("accountVerification.sending") : t("accountVerification.sendOtp")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmailInput && (
|
||||||
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} className="space-y-3">
|
||||||
|
<p className="text-xs text-gray-500">{t("accountVerification.enter6DigitCode")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={emailCode}
|
||||||
|
onChange={(e) => setEmailCode(e.target.value)}
|
||||||
|
placeholder={t("accountVerification.otpPlaceholder")}
|
||||||
|
maxLength={6}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none text-center text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyEmail}
|
||||||
|
disabled={verifyingEmail}
|
||||||
|
className="px-6 py-3 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-colors text-sm font-medium disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{verifyingEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />}
|
||||||
|
{t("accountVerification.verify")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleSendEmailOTP} disabled={sendingEmailOTP} className="text-xs text-amber-600 hover:text-amber-700">
|
||||||
|
{t("accountVerification.resendCode")}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailVerified && (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 bg-green-50 px-4 py-3 rounded-xl">
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
<span className="text-sm font-medium">{t("accountVerification.emailVerifiedSuccess")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
|
||||||
|
<Phone className="w-5 h-5 text-amber-600" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">{t("accountVerification.phoneSection")}</h2>
|
||||||
|
{phoneVerified && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
{t("accountVerification.verified")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">{user?.phone || t("accountVerification.phoneNotRegistered")}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{phoneVerified ? t("accountVerification.phoneVerifiedDesc") : t("accountVerification.phoneVerifyDesc")}</p>
|
||||||
|
</div>
|
||||||
|
{!phoneVerified && !showPhoneInput && (
|
||||||
|
<button
|
||||||
|
onClick={handleSendPhoneOTP}
|
||||||
|
disabled={sendingPhoneOTP}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sendingPhoneOTP ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
{sendingPhoneOTP ? t("accountVerification.sending") : t("accountVerification.sendOtp")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPhoneInput && (
|
||||||
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} className="space-y-3">
|
||||||
|
<p className="text-xs text-gray-500">{t("accountVerification.enter6DigitCodePhone")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={phoneCode}
|
||||||
|
onChange={(e) => setPhoneCode(e.target.value)}
|
||||||
|
placeholder={t("accountVerification.otpPlaceholder")}
|
||||||
|
maxLength={6}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none text-center text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyPhone}
|
||||||
|
disabled={verifyingPhone}
|
||||||
|
className="px-6 py-3 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-colors text-sm font-medium disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{verifyingPhone ? <Loader2 className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />}
|
||||||
|
{t("accountVerification.verify")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleSendPhoneOTP} disabled={sendingPhoneOTP} className="text-xs text-amber-600 hover:text-amber-700">
|
||||||
|
{t("accountVerification.resendCode")}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phoneVerified && (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 bg-green-50 px-4 py-3 rounded-xl">
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
<span className="text-sm font-medium">{t("accountVerification.phoneVerifiedSuccess")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={itemVariants} className="bg-gradient-to-br from-amber-500 to-amber-600 rounded-2xl p-6 text-white">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Shield className="w-6 h-6" />
|
||||||
|
<h3 className="text-lg font-semibold">{t("accountVerification.whyVerify")}</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-sm " style={{ color: "#0f172a", fontWeight: "400" }}>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{t("accountVerification.reason1")}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{t("accountVerification.reason2")}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{t("accountVerification.reason3")}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,179 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Home,
|
|
||||||
Calendar,
|
|
||||||
Users,
|
|
||||||
DollarSign,
|
|
||||||
TrendingUp,
|
|
||||||
Bell
|
|
||||||
} from 'lucide-react';
|
|
||||||
import DashboardStats from '../components/admin/DashboardStats';
|
|
||||||
import PropertiesTable from '../components/admin/PropertiesTable';
|
|
||||||
import BookingRequests from '../components/admin/BookingRequests';
|
|
||||||
import UsersList from '../components/admin/UsersList';
|
|
||||||
import LedgerBook from '../components/admin/LedgerBook';
|
|
||||||
import AddPropertyForm from '../components/admin/AddPropertyForm';
|
|
||||||
import { PropertyProvider } from '../contexts/PropertyContext';
|
|
||||||
import '../i18n/config';
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [activeTab, setActiveTab] = useState('dashboard');
|
|
||||||
const [showAddProperty, setShowAddProperty] = useState(false);
|
|
||||||
const [notifications, setNotifications] = useState(3);
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home },
|
|
||||||
{ id: 'properties', label: 'العقارات', icon: Home },
|
|
||||||
{ id: 'bookings', label: 'طلبات الحجز', icon: Calendar, badge: notifications },
|
|
||||||
{ id: 'users', label: 'المستخدمين', icon: Users },
|
|
||||||
{ id: 'ledger', label: 'دفتر الحسابات', icon: DollarSign },
|
|
||||||
{ id: 'reports', label: 'التقارير', icon: TrendingUp }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PropertyProvider>
|
|
||||||
<div className={`min-h-screen bg-gray-50 p-4 md:p-6 ${i18n.language === 'ar' ? 'text-right' : 'text-left'}`}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-2">
|
|
||||||
{t('adminDashboard')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
إدارة العقارات، الحجوزات، والحسابات المالية
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="relative p-2 hover:bg-gray-100 rounded-lg">
|
|
||||||
<Bell className="w-6 h-6 text-gray-600" />
|
|
||||||
{notifications > 0 && (
|
|
||||||
<span className="absolute top-0 right-0 w-4 h-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
|
||||||
{notifications}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="mb-6 border-b border-gray-200">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`
|
|
||||||
px-4 py-3 font-medium text-sm rounded-t-lg transition-all relative
|
|
||||||
${activeTab === tab.id
|
|
||||||
? 'bg-white border-t border-x border-gray-300 text-blue-700'
|
|
||||||
: 'text-gray-700 hover:text-blue-600 hover:bg-gray-100'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
{tab.badge && (
|
|
||||||
<span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
|
|
||||||
{tab.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
|
||||||
{activeTab === 'dashboard' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<DashboardStats />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'properties' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold">إدارة العقارات</h2>
|
|
||||||
<p className="text-gray-600 text-sm">إضافة وتعديل العقارات مع تحديد نسب الأرباح</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddProperty(true)}
|
|
||||||
className="bg-blue-700 text-white px-4 py-2 rounded-lg hover:bg-blue-800"
|
|
||||||
>
|
|
||||||
إضافة عقار جديد
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<PropertiesTable />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'bookings' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<BookingRequests />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'users' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<UsersList />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'ledger' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<LedgerBook userType="admin" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'reports' && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
قريباً... تقارير متقدمة
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddProperty && (
|
|
||||||
<AddPropertyForm
|
|
||||||
onClose={() => setShowAddProperty(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
setShowAddProperty(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PropertyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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-gray-900 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Home, Building, ArrowLeft } from 'lucide-react';
|
import { Home, Building, Briefcase, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import InteractiveBackground from '@/app/components/Animation/Background';
|
||||||
|
|
||||||
export default function ChooseRolePage() {
|
export default function ChooseRolePage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
@ -35,7 +38,8 @@ export default function ChooseRolePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
<div dir={i18n.language === 'ar' ? 'rtl' : 'ltr'} className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
<InteractiveBackground/>
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
{[...Array(20)].map((_, i) => (
|
{[...Array(20)].map((_, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -77,7 +81,7 @@ export default function ChooseRolePage() {
|
|||||||
<motion.div whileHover={{ x: -5 }}>
|
<motion.div whileHover={{ x: -5 }}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<span>العودة للرئيسية</span>
|
<span>{t('backToHome')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -86,17 +90,17 @@ export default function ChooseRolePage() {
|
|||||||
className="text-center mb-12"
|
className="text-center mb-12"
|
||||||
>
|
>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
className="text-4xl md:text-5xl font-bold text-white mb-4"
|
className="text-4xl md:text-5xl font-bold text-amber-50 mb-4"
|
||||||
animate={floatingAnimation}
|
animate={floatingAnimation}
|
||||||
>
|
>
|
||||||
مرحباً بك في SweetHome
|
{t('chooseRole.welcomeToSweetHome')}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
<p className="text-xl text-gray-400">
|
<p className="text-xl text-gray-400">
|
||||||
اختر نوع الحساب المناسب لك
|
{t('chooseRole.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ scale: 1.05, y: -5 }}
|
whileHover={{ scale: 1.05, y: -5 }}
|
||||||
@ -123,23 +127,23 @@ export default function ChooseRolePage() {
|
|||||||
<Building className="w-10 h-10 text-white" />
|
<Building className="w-10 h-10 text-white" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold mb-3">صاحب عقار</h2>
|
<h2 className="text-3xl font-bold mb-3">{t('chooseRole.owner')}</h2>
|
||||||
<p className="text-amber-100 mb-6">
|
<p className="text-amber-100 mb-6">
|
||||||
تريد عرض عقار للإيجار أو البيع؟ انضم كمالك عقار وابدأ الآن
|
{t('chooseRole.ownerDesc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="space-y-2 text-sm text-amber-100">
|
<ul className="space-y-2 text-sm text-amber-100">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
إضافة عقارات غير محدودة
|
{t('chooseRole.ownerFeature1')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
إدارة الحجوزات بسهولة
|
{t('chooseRole.ownerFeature2')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
تواصل مباشر مع المستأجرين
|
{t('chooseRole.ownerFeature3')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@ -148,7 +152,7 @@ export default function ChooseRolePage() {
|
|||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
إنشاء حساب مالك
|
{t('chooseRole.createOwnerAccount')}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -181,23 +185,23 @@ export default function ChooseRolePage() {
|
|||||||
<Home className="w-10 h-10 text-white" />
|
<Home className="w-10 h-10 text-white" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold mb-3">مستأجر / مشتري</h2>
|
<h2 className="text-3xl font-bold mb-3">{t('chooseRole.tenant')}</h2>
|
||||||
<p className="text-blue-100 mb-6">
|
<p className="text-blue-100 mb-6">
|
||||||
تبحث عن عقار للإيجار أو الشراء؟ انضم كعميل وابحث عن منزل أحلامك
|
{t('chooseRole.tenantDesc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="space-y-2 text-sm text-blue-100">
|
<ul className="space-y-2 text-sm text-blue-100">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
آلاف العقارات المتاحة
|
{t('chooseRole.tenantFeature1')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
بحث متقدم بفلاتر ذكية
|
{t('chooseRole.tenantFeature2')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
حجز وإستئجار فوري
|
{t('chooseRole.tenantFeature3')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@ -206,7 +210,65 @@ export default function ChooseRolePage() {
|
|||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
إنشاء حساب مستأجر
|
{t('chooseRole.createTenantAccount')}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
whileHover={{ scale: 1.05, y: -5 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
>
|
||||||
|
<Link href="/register/agent">
|
||||||
|
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-3xl p-8 text-white shadow-2xl shadow-purple-500/20 relative overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="absolute -top-20 -right-20 w-40 h-40 bg-white/10 rounded-full"
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.2, 1],
|
||||||
|
rotate: [0, 90, 0],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 8, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<motion.div
|
||||||
|
className="w-20 h-20 bg-white/20 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm"
|
||||||
|
whileHover={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-10 h-10 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl font-bold mb-3">{t('chooseRole.agent')}</h2>
|
||||||
|
<p className="text-purple-100 mb-6">
|
||||||
|
{t('chooseRole.agentDesc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="space-y-2 text-sm text-purple-100">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
|
{t('chooseRole.agentFeature1')}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
|
{t('chooseRole.agentFeature2')}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||||
|
{t('chooseRole.agentFeature3')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
className="mt-8 w-full bg-white text-purple-600 py-3 rounded-xl font-bold hover:bg-purple-50 transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{t('chooseRole.createAgentAccount')}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -218,12 +280,12 @@ export default function ChooseRolePage() {
|
|||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="text-center text-gray-400 mt-8"
|
className="text-center text-gray-400 mt-8"
|
||||||
>
|
>
|
||||||
لديك حساب بالفعل؟{' '}
|
{t('chooseRole.alreadyHaveAccount')}{' '}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
تسجيل الدخول
|
{t('login')}
|
||||||
</Link>
|
</Link>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
152
app/blocked/page.js
Normal file
152
app/blocked/page.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ShieldAlert, LogOut, MessageSquare, Send, Loader2 } from "lucide-react";
|
||||||
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
|
import AuthService from "../services/AuthService";
|
||||||
|
import { sendGeneralReport } from "../utils/api";
|
||||||
|
import InteractiveBackground from "../components/Animation/Background";
|
||||||
|
|
||||||
|
export default function BlockedPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({ subject: "", body: "" });
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isSent, setIsSent] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
AuthService.deleteToken();
|
||||||
|
router.replace("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (field, value) => {
|
||||||
|
setForm((current) => ({ ...current, [field]: value }));
|
||||||
|
if (isSent) setIsSent(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!form.subject.trim() || !form.body.trim()) {
|
||||||
|
toast.error(t("blocked.fillAllFields"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendGeneralReport(form.subject.trim(), form.body.trim());
|
||||||
|
setIsSent(true);
|
||||||
|
setForm({ subject: "", body: "" });
|
||||||
|
toast.success(t("blocked.reportSent"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("blocked.sendError"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4" dir="rtl">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<InteractiveBackground />
|
||||||
|
<motion.div initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-5xl relative z-10">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<motion.div initial={{ scale: 0.9 }} animate={{ scale: 1 }} className="w-24 h-24 bg-red-100 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-red-100">
|
||||||
|
<ShieldAlert className="w-12 h-12 text-red-600" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-3">{t("blocked.title")}</h1>
|
||||||
|
<p className="text-gray-600 text-lg max-w-2xl mx-auto">{t("blocked.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 relative z-10 ">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -24 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="bg-white rounded-3xl shadow-sm border border-gray-200 p-8 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mb-6">
|
||||||
|
<LogOut className="w-7 h-7 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">{t("blocked.logoutTitle")}</h2>
|
||||||
|
<p className="text-gray-600 leading-7">{t("blocked.logoutDesc")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="mt-8 w-full cursor-pointer bg-red-600 hover:bg-red-700 text-white rounded-2xl py-4 font-bold transition-colors flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
{t("blocked.logoutButton")}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, x: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="bg-white rounded-3xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-14 h-14 bg-amber-50 rounded-2xl flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-7 h-7 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">{t("blocked.contactSupportTitle")}</h2>
|
||||||
|
<p className="text-gray-600 mt-1">{t("blocked.contactSupportDesc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">{t("subject")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.subject}
|
||||||
|
onChange={(event) => updateField("subject", event.target.value)}
|
||||||
|
placeholder={t("blocked.subjectPlaceholder")}
|
||||||
|
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">{t("blocked.messageLabel")}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.body}
|
||||||
|
onChange={(event) => updateField("body", event.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder={t("blocked.messagePlaceholder")}
|
||||||
|
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-white rounded-2xl py-4 font-bold transition-colors flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t("blocked.sending")}
|
||||||
|
</>
|
||||||
|
) : isSent ? (
|
||||||
|
<>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
{t("blocked.sentButton")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
{t("blocked.sendButton")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
app/booked-properties/page.js
Normal file
220
app/booked-properties/page.js
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Home, Star, MapPin, Calendar, Clock, Check, X, Loader2,
|
||||||
|
User, MessageCircle, ChevronDown, Image as ImageIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import { getUserReservations } from '../utils/api';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
import PropertyRatingForm from '../components/ratings/PropertyRatingForm';
|
||||||
|
|
||||||
|
const STATUS_MAP = ['pending', 'ownerConfirmed', 'depositPaid', 'depositConfirmed', 'completed', 'cancelled'];
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
pending: { color: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||||
|
ownerConfirmed: { color: 'bg-blue-100 text-blue-800 border-blue-300' },
|
||||||
|
depositPaid: { color: 'bg-orange-100 text-orange-800 border-orange-300' },
|
||||||
|
depositConfirmed: { color: 'bg-green-100 text-green-800 border-green-300' },
|
||||||
|
completed: { color: 'bg-teal-100 text-teal-800 border-teal-300' },
|
||||||
|
cancelled: { color: 'bg-red-100 text-red-800 border-red-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||||
|
|
||||||
|
function StatusBadge({ code }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const key = STATUS_MAP[code] || 'pending';
|
||||||
|
const cfg = STATUS_CONFIG[key];
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium border ${cfg.color}`}>
|
||||||
|
<Clock className="w-3 h-3" /> {t(`bookingStatus.${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReservationCard({ reservation: r, onRate }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isCompleted = STATUS_MAP[r.status] === 'completed';
|
||||||
|
const imgSrc = r.propertyImage || r._prop?.images?.[0] || (r.propertyInfo?.images?.[0]);
|
||||||
|
const imageUrl = imgSrc ? `${API_BASE}${imgSrc}` : null;
|
||||||
|
const address = r.propertyAddress || r._prop?.address || '';
|
||||||
|
const beds = r._prop?.numberOfBedRooms ?? r.numberOfBedRooms ?? 0;
|
||||||
|
const baths = r._prop?.numberOfBathRooms ?? r.numberOfBathRooms ?? 0;
|
||||||
|
|
||||||
|
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 space-y-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Home className="w-5 h-5 text-amber-500" />
|
||||||
|
<span className="font-semibold text-gray-900">{t('propertyId', { id: r.propertyId || r.id })}</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge code={r.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageUrl ? (
|
||||||
|
<div className="w-full h-44 rounded-xl overflow-hidden bg-gray-100">
|
||||||
|
<img src={imageUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-44 rounded-xl bg-gray-100 flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-12 h-12 text-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{address && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-500 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(beds > 0 || baths > 0) && (
|
||||||
|
<div className="flex gap-3 text-sm text-gray-600">
|
||||||
|
{beds > 0 && <span>{beds} {t('rooms')}</span>}
|
||||||
|
{baths > 0 && <span>{baths} {t('bathrooms')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
||||||
|
<div className="text-xs text-gray-500">{t('from')}</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1" />
|
||||||
|
<div className="text-xs text-gray-500">{t('to')}</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 p-3 rounded-xl text-center">
|
||||||
|
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString() ?? '—'}</div>
|
||||||
|
<div className="text-xs text-gray-500">{t('totalPrice')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
<span>{t('reservationNumber', { id: r.id })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<button onClick={onRate}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-white py-2.5 rounded-xl text-sm font-medium transition flex items-center justify-center gap-2">
|
||||||
|
<Star className="w-4 h-4" /> {t('rateProperty')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookedPropertiesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [reservations, setReservations] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [ratingReservation, setRatingReservation] = useState(null);
|
||||||
|
const [expandedId, setExpandedId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!AuthService.getToken()) {
|
||||||
|
toast.error(t('loginRequired'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadReservations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadReservations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getUserReservations();
|
||||||
|
setReservations(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(t('failedToLoadBookings'));
|
||||||
|
setReservations([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('bookedProperties')}</h1>
|
||||||
|
<p className="text-gray-600">{t('youHaveBookings', { count: reservations.length })}</p>
|
||||||
|
</div>
|
||||||
|
{reservations.length > 0 && (
|
||||||
|
<button onClick={() => setExpandedId(expandedId ? null : 'all')}
|
||||||
|
className="flex items-center gap-1 text-sm text-amber-600 hover:text-amber-700 transition">
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${expandedId ? 'rotate-180' : ''}`} />
|
||||||
|
{expandedId ? t('collapseAll') : t('showAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{reservations.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">
|
||||||
|
<Home className="w-16 h-16 text-amber-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noBookings')}</h3>
|
||||||
|
<p className="text-gray-500">{t('noBookingsDesc')}</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{reservations.map((r, i) => (
|
||||||
|
<ReservationCard key={r.id || i} reservation={r} onRate={() => setRatingReservation(r)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reservations.filter(r => STATUS_MAP[r.status] === 'completed').length > 0 && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||||
|
className="mt-8 bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<MessageCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-amber-800 font-medium">{t('rateProperties')}</p>
|
||||||
|
<p className="text-amber-600 text-sm">{t('ratePropertiesDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{ratingReservation && (
|
||||||
|
<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={() => setRatingReservation(null)}>
|
||||||
|
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||||
|
className="w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setRatingReservation(null)}
|
||||||
|
className="absolute left-2 top-2 z-10 bg-white/80 rounded-full p-1 hover:bg-white transition shadow">
|
||||||
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<PropertyRatingForm
|
||||||
|
reservationId={ratingReservation.id}
|
||||||
|
onSuccess={() => { setRatingReservation(null); toast.success(t('ratingSubmitted')); }}
|
||||||
|
onCancel={() => setRatingReservation(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
app/change-password/page.js
Normal file
172
app/change-password/page.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
|
import { Lock, Eye, EyeOff, ArrowLeft, Shield } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { changePassword } from "../utils/api";
|
||||||
|
import AuthService from "../services/AuthService";
|
||||||
|
import InteractiveBackground from "../components/Animation/Background";
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState({ oldPassword: "", newPassword: "", confirmPassword: "" });
|
||||||
|
const [show, setShow] = useState({ old: false, new: false, confirm: false });
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (field) => (e) => setForm({ ...form, [field]: e.target.value });
|
||||||
|
|
||||||
|
const toggleShow = (field) => setShow({ ...show, [field]: !show[field] });
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!AuthService.isAuthenticated()) {
|
||||||
|
toast.error(t("changePassword.loginRequired"));
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.newPassword !== form.confirmPassword) {
|
||||||
|
toast.error(t("changePassword.passwordsDoNotMatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.newPassword.length < 6) {
|
||||||
|
toast.error(t("validation.passwordMinLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await changePassword(form.oldPassword, form.newPassword);
|
||||||
|
toast.success(t("changePassword.changeSuccess"));
|
||||||
|
setTimeout(() => router.push("/profile"), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err?.message || t("changePassword.changeFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full pr-12 pl-4 py-3 bg-gray-50 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition-all";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen relative z-10 flex items-center justify-center p-4" style={{ backgroundColor: "#0f172a" }}>
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<InteractiveBackground />
|
||||||
|
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md relative z-10 ">
|
||||||
|
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-6 relative z-10">
|
||||||
|
<button onClick={() => router.push("/profile")} className="flex items-center relative z-10 gap-2 text-gray-600 hover:text-amber-600 transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>{t("changePassword.backToProfile")}</span>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className=" rounded-3xl shadow-xl overflow-hidden" style={{ backgroundColor: "#1e293b" }}>
|
||||||
|
<div className="bg-gradient-to-l from-amber-500 to-amber-600 p-8 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200 }}
|
||||||
|
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||||
|
>
|
||||||
|
<Shield className="w-8 h-8 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.h1 initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="text-3xl font-bold text-white mb-2">
|
||||||
|
{t("changePassword.title")}
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.1 }} className="text-amber-100">
|
||||||
|
{t("changePassword.subtitle")}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-8 space-y-6 " style={{ backgroundColor: "#0f172a" }}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t("changePassword.currentPasswordLabel")}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={show.old ? "text" : "password"}
|
||||||
|
value={form.oldPassword}
|
||||||
|
onChange={handleChange("oldPassword")}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={t("changePassword.currentPasswordPlaceholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => toggleShow("old")} className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||||
|
{show.old ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t("changePassword.newPasswordLabel")}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={show.new ? "text" : "password"}
|
||||||
|
value={form.newPassword}
|
||||||
|
onChange={handleChange("newPassword")}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={t("changePassword.newPasswordPlaceholder")}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => toggleShow("new")} className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||||
|
{show.new ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t("changePassword.confirmNewPasswordLabel")}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={show.confirm ? "text" : "password"}
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={handleChange("confirmPassword")}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={t("changePassword.confirmPasswordPlaceholder")}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => toggleShow("confirm")} className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||||
|
{show.confirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
whileHover={{ scale: isLoading ? 1 : 1.02 }}
|
||||||
|
whileTap={{ scale: isLoading ? 1 : 0.98 }}
|
||||||
|
className="w-full bg-gradient-to-l from-amber-500 to-amber-600 text-white py-3 rounded-xl font-bold text-lg hover:from-amber-600 hover:to-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-amber-500/25"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>{t("changePassword.saving")}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("changePassword.changePassword")
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
app/components/Animation/Background.js
Normal file
113
app/components/Animation/Background.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export default function InteractiveBackground() {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
let animationFrameId;
|
||||||
|
let width = (canvas.width = window.innerWidth);
|
||||||
|
let height = (canvas.height = window.innerHeight);
|
||||||
|
|
||||||
|
// تتبع حركة الماوس
|
||||||
|
const mouse = { x: null, y: null, radius: 180 };
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
mouse.x = e.clientX;
|
||||||
|
mouse.y = e.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
mouse.x = null;
|
||||||
|
mouse.y = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
width = canvas.width = window.innerWidth;
|
||||||
|
height = canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
const particles = Array.from({ length: 70 }, () => ({
|
||||||
|
x: Math.random() * width,
|
||||||
|
y: Math.random() * height,
|
||||||
|
vx: (Math.random() - 0.5) * 0.8,
|
||||||
|
vy: (Math.random() - 0.5) * 0.8,
|
||||||
|
radius: Math.random() * 2 + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
particles.forEach((p, i) => {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
|
||||||
|
if (p.x < 0 || p.x > width) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > height) p.vy *= -1;
|
||||||
|
|
||||||
|
|
||||||
|
if (mouse.x !== null && mouse.y !== null) {
|
||||||
|
const dx = mouse.x - p.x;
|
||||||
|
const dy = mouse.y - p.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (dist < mouse.radius) {
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const force = (mouse.radius - dist) / mouse.radius;
|
||||||
|
p.x -= Math.cos(angle) * force * 2;
|
||||||
|
p.y -= Math.sin(angle) * force * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = "rgba(99, 102, 241, 0.7)";
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
|
||||||
|
for (let j = i + 1; j < particles.length; j++) {
|
||||||
|
const p2 = particles[j];
|
||||||
|
const dist = Math.hypot(p.x - p2.x, p.y - p2.y);
|
||||||
|
if (dist < 140) {
|
||||||
|
const opacity = 1 - dist / 140;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p2.x, p2.y);
|
||||||
|
ctx.strokeStyle = `rgba(99, 102, 241, ${opacity * 0.25})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 pointer-events-none z-0 bg-slate-950 overflow-hidden">
|
||||||
|
<div className="absolute top-1/4 -left-20 w-96 h-96 bg-indigo-600/20 rounded-full blur-3xl animate-pulse" />
|
||||||
|
<div className="absolute bottom-1/4 -right-20 w-96 h-96 bg-blue-600/20 rounded-full blur-3xl animate-pulse delay-1000" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-purple-600/10 rounded-full blur-3xl" />
|
||||||
|
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
app/components/Animation/HeaderLoginAnimation.js
Normal file
70
app/components/Animation/HeaderLoginAnimation.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
export 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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemVariants = {
|
||||||
|
hidden: { y: 20, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: { type: "spring", stiffness: 100 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeaderAnimation() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
{particles.map((p) => (
|
||||||
|
<motion.div
|
||||||
|
key={p.id}
|
||||||
|
className="absolute rounded-full bg-amber-500/20"
|
||||||
|
style={{
|
||||||
|
left: `${p.x}%`,
|
||||||
|
top: `${p.y}%`,
|
||||||
|
width: p.size,
|
||||||
|
height: p.size,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
y: [0, -20, 0],
|
||||||
|
x: [0, 10, -10, 0],
|
||||||
|
opacity: [0.2, 0.4, 0.2],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: p.duration,
|
||||||
|
repeat: Infinity,
|
||||||
|
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] }}
|
||||||
|
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] }}
|
||||||
|
transition={{ duration: 15, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/components/Animation/OwnerPageAnimation.js
Normal file
26
app/components/Animation/OwnerPageAnimation.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export const fadeInUp = {
|
||||||
|
initial: { opacity: 0, y: 20 },
|
||||||
|
animate: { opacity: 1, y: 0 },
|
||||||
|
transition: { duration: 0.5 },
|
||||||
|
};
|
||||||
|
export const staggerContainer = {
|
||||||
|
animate: { transition: { staggerChildren: 0.1 } },
|
||||||
|
};
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const BackgroundElements = () => {
|
||||||
|
const circles = [
|
||||||
|
{ style: { top: "20%", right: "20%", width: "256px", height: "256px" }, className: "bg-amber-500/5" },
|
||||||
|
{ style: { bottom: "10%", left: "20%", width: "320px", height: "320px" }, className: "bg-blue-500/5" },
|
||||||
|
{ style: { bottom: "40%", left: "10%", width: "320px", height: "320px" }, className: "bg-blue-500/5" },
|
||||||
|
{ style: { bottom: "60%", left: "60%", width: "320px", height: "320px" }, className: "bg-blue-500/5" }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{circles.map((circle, index) => (
|
||||||
|
<div key={index} style={circle.style} className={`absolute rounded-full pointer-events-none ${circle.className}`} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
app/components/BottomNav.js
Normal file
110
app/components/BottomNav.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Home, Building2, Building, CalendarCheck, CalendarDays, ClipboardList, CreditCard, BookOpenCheck } from "lucide-react";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
export default function BottomNav({ isOwner, isOwnerOrAgent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const bookingsHref = isOwner ? "/owner/reservations" : "/reservations";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: "/", label: t("home"), icon: Home },
|
||||||
|
{ href: "/properties", label: t("ourProperties"), icon: Building2 },
|
||||||
|
|
||||||
|
...(isOwnerOrAgent
|
||||||
|
? [
|
||||||
|
{ href: "/owner/properties", label: t("myProperties"), icon: Building },
|
||||||
|
{ href: "/owner/bookings", label: t("Myseizedproperties"), icon: CalendarCheck },
|
||||||
|
{ href: "/reservations", label: t("Mybookings"), icon: CalendarDays },
|
||||||
|
]
|
||||||
|
: [{ href: "/booked-properties", label: t("Mybookings"), icon: CalendarDays }]),
|
||||||
|
|
||||||
|
{ href: bookingsHref, label: t("Orders"), icon: ClipboardList },
|
||||||
|
{ href: "/payments", label: t("payments"), icon: CreditCard },
|
||||||
|
|
||||||
|
...(isOwnerOrAgent ? [{ href: "/owner/account-book", label: t("accountBook"), icon: BookOpenCheck }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (href) => {
|
||||||
|
if (href === "/") return pathname === "/";
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 flex justify-center px-5 pb-4 pt-4 pointer-events-none">
|
||||||
|
<nav
|
||||||
|
className="
|
||||||
|
w-fit max-w-full
|
||||||
|
rounded-3xl
|
||||||
|
bg-gray-50
|
||||||
|
px-5 py-3 sm:px-4
|
||||||
|
shadow-[0_4px_24px_rgba(251,146,60,0.25)]
|
||||||
|
pointer-events-auto
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center sm:justify-center gap-0.5 sm:gap-1 overflow-x-auto max-w-full
|
||||||
|
[&::-webkit-scrollbar]:h-1.5
|
||||||
|
[&::-webkit-scrollbar-track]:bg-gray-100
|
||||||
|
[&::-webkit-scrollbar-thumb]:bg-gray-700
|
||||||
|
[&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-gray-700"
|
||||||
|
>
|
||||||
|
{items.map((it) => {
|
||||||
|
const Icon = it.icon;
|
||||||
|
const active = isActive(it.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={it.href}
|
||||||
|
href={it.href}
|
||||||
|
style={{ padding: "3px", width: "115px" }}
|
||||||
|
className={`
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
rounded-xl
|
||||||
|
transition-all
|
||||||
|
duration-200
|
||||||
|
ease-out
|
||||||
|
${active ? "bg-white shadow-sm text-amber-600" : "text-gray-700 hover:bg-white/80 hover:text-amber-600 hover:shadow-md"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Icon className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
|
||||||
|
{it.badge > 0 && (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute -right-2 -top-2
|
||||||
|
flex h-6 w-7 sm:h-5 sm:w-5
|
||||||
|
items-center justify-center
|
||||||
|
rounded-full
|
||||||
|
bg-red-500
|
||||||
|
text-[9px] sm:text-xs
|
||||||
|
font-bold
|
||||||
|
text-white
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{it.badge > 9 ? "9+" : it.badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 text-[10px] sm:mt-1 text-center sm:text-xs">{it.label}</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
app/components/FloatingSidebar.js
Normal file
160
app/components/FloatingSidebar.js
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Heart, Bell, CreditCard, Settings } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||||
|
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||||
|
import AuthService from '@/app/services/AuthService';
|
||||||
|
|
||||||
|
export default function FloatingSidebar({ isRTL }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!AuthService.isAuthenticated()) return null;
|
||||||
|
|
||||||
|
const positionStyle = {
|
||||||
|
left: '16px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
initial: { opacity: 0, x: 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 left-full mr-3 top-1/2 -translate-y-1/2 px-3 py-2 bg-gray-800 text-white text-sm rounded-lg whitespace-nowrap z-20 shadow-lg">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="fixed z-40 pointer-events-none"
|
||||||
|
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-4 px-3 flex flex-col gap-4 transition-all duration-300 hover:shadow-xl hover:bg-white/95 max-h-[75vh] overflow-y-auto pointer-events-auto">
|
||||||
|
<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-14 h-14 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Heart className="w-7 h-7 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-6 h-6 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', t('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-14 h-14 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Bell className="w-7 h-7 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-6 h-6 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', t('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-14 h-14 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="w-7 h-7 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||||
|
</Link>
|
||||||
|
{renderTooltip('payments', t('payments'))}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="relative group"
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="rest"
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
|
onMouseEnter={() => showTooltip('settings')}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center justify-center w-14 h-14 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-7 h-7 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||||
|
</Link>
|
||||||
|
{renderTooltip('settings', t('settings'))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
app/components/HavAccount.js
Normal file
17
app/components/HavAccount.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { fadeInUp } from "./Animation/OwnerPageAnimation";
|
||||||
|
import Link from "next/link";
|
||||||
|
export default function HavAccount() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
|
||||||
|
{t("register.haveAccount")}
|
||||||
|
<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">
|
||||||
|
{t("register.loginLink")}
|
||||||
|
</Link>
|
||||||
|
</motion.p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
app/components/LoginComponent/CreateNewAccount.js
Normal file
17
app/components/LoginComponent/CreateNewAccount.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { itemVariants } from '../Animation/HeaderLoginAnimation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
export default function CreateNewAccount() {
|
||||||
|
const {t}=useTranslation()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.p variants={itemVariants} className="text-center text-gray-400 mt-6">
|
||||||
|
{t("noAccount")}{" "}
|
||||||
|
<Link href="/auth/choose-role" className="text-amber-400 hover:text-amber-300 font-medium transition-colors">
|
||||||
|
{t("createNewAccount")}
|
||||||
|
</Link>
|
||||||
|
</motion.p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/components/LoginComponent/HeaderLogin.js
Normal file
45
app/components/LoginComponent/HeaderLogin.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { itemVariants } from "../Animation/HeaderLoginAnimation";
|
||||||
|
import { ArrowLeft, KeyRound,Home } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
//Back Link
|
||||||
|
export function BackLink() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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 }}>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</motion.div>
|
||||||
|
<span>{t("backToHome")}</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderForm({ step }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-gradient-to-r from-amber-700 to-amber-400 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={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.3, type: "spring" }} className="absolute -bottom-10 -left-10 w-40 h-40 bg-white/10 rounded-full" />
|
||||||
|
|
||||||
|
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} 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"
|
||||||
|
>
|
||||||
|
{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">{step === "otp" ? t("enterOTP") : t("welcomeBack")}</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
app/components/LoginComponent/Login.jsx
Normal file
127
app/components/LoginComponent/Login.jsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import AuthService from "../../services/AuthService";
|
||||||
|
import InteractiveBackground from "../Animation/Background"
|
||||||
|
import { BackLink, HeaderForm } from "./HeaderLogin";
|
||||||
|
import HeaderAnimation, { containerVariants, itemVariants } from "../Animation/HeaderLoginAnimation";
|
||||||
|
import TabsLogin from "./Tabs";
|
||||||
|
import LoginForm from "./LoginForm";
|
||||||
|
import CreateNewAccount from "./CreateNewAccount";
|
||||||
|
import PolicyAndPrivecy from "./PolicyAndPrivecy";
|
||||||
|
import OtpComponent from "./OtpComponent";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState("login");
|
||||||
|
const [loginMethod, setLoginMethod] = useState("email");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
credential: "", //email or phoneNumber
|
||||||
|
password: "",
|
||||||
|
rememberMe: false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
if (!formData.credential) {
|
||||||
|
newErrors.credential = loginMethod === "email" ? t("emailRequired") : t("phoneRequired");
|
||||||
|
}
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = t("passwordRequired");
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validateForm()) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrors({});
|
||||||
|
try {
|
||||||
|
const response = await AuthService.login({
|
||||||
|
loginMethod,
|
||||||
|
credential: formData.credential,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
switch (response.type) {
|
||||||
|
case "SUCCESS":
|
||||||
|
toast.success(t("loginSuccess"));
|
||||||
|
router.push("/");
|
||||||
|
break;
|
||||||
|
case "OTP_REQUIRED":
|
||||||
|
toast(t("otpRequired"));
|
||||||
|
setStep("otp");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast.error("Login failed the email or password is wrong");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Login] Error:", err);
|
||||||
|
toast.error(err.message || t("connectionError"), {
|
||||||
|
style: { background: "#fee2e2", color: "#991b1b" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-detect login method from input
|
||||||
|
const handleCredentialChange = (value) => {
|
||||||
|
setFormData({ ...formData, credential: value });
|
||||||
|
if (errors.credential) setErrors({ ...errors, credential: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handelTabs = (type) => {
|
||||||
|
setLoginMethod(type);
|
||||||
|
setFormData({ ...formData, credential: "" });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<InteractiveBackground />
|
||||||
|
{/* Particles */}
|
||||||
|
<HeaderAnimation />
|
||||||
|
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="relative w-full max-w-md z-10">
|
||||||
|
{/* Back link */}
|
||||||
|
<BackLink />
|
||||||
|
<motion.div variants={itemVariants} className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<HeaderForm step={step} />
|
||||||
|
<div className="p-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === "login" ? (
|
||||||
|
<motion.form key="login" initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} onSubmit={handleLogin} className="space-y-6">
|
||||||
|
{/* Login method tabs */}
|
||||||
|
<TabsLogin loginMethod={loginMethod} handelTabs={handelTabs} />
|
||||||
|
{/* Form Login */}
|
||||||
|
<LoginForm
|
||||||
|
loginMethod={loginMethod}
|
||||||
|
errors={errors}
|
||||||
|
formData={formData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSuccess={isSuccess}
|
||||||
|
setFormData={setFormData}
|
||||||
|
handleCredentialChange={handleCredentialChange}
|
||||||
|
/>
|
||||||
|
</motion.form>
|
||||||
|
) : (
|
||||||
|
// /* OTP Verification Step */
|
||||||
|
<OtpComponent isLoading={isLoading} setIsLoading={setIsLoading} isSuccess={isSuccess} setIsSuccess={setIsSuccess} loginMethod={loginMethod} formData={formData} setStep={setStep} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<CreateNewAccount />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<PolicyAndPrivecy />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
app/components/LoginComponent/LoginForm.js
Normal file
141
app/components/LoginComponent/LoginForm.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { EyeOff, Mail, Phone, Eye, LogIn, Lock, Loader2, CheckCircle } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function LoginForm({ loginMethod, errors, formData, isLoading, isSuccess, setFormData, handleCredentialChange }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">{loginMethod === "email" ? t("emailLabel") : t("phoneLabel")}</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
{loginMethod === "email" ? (
|
||||||
|
<Mail className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`} />
|
||||||
|
) : (
|
||||||
|
<Phone className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loginMethod === "email" ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
// 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-amber-50 placeholder-gray-500 transition-all ${
|
||||||
|
errors.credential ? "border-red-500" : "border-gray-700"
|
||||||
|
}`}
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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-amber-50 placeholder-gray-500 transition-all ${
|
||||||
|
errors.credential ? "border-red-500" : "border-gray-700"
|
||||||
|
}`}
|
||||||
|
placeholder={t("phonePlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.credential && (
|
||||||
|
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1">
|
||||||
|
{errors.credential}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">{t("password")}</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
password: e.target.value,
|
||||||
|
});
|
||||||
|
if (errors.password) setErrors({ ...errors, password: null });
|
||||||
|
}}
|
||||||
|
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${
|
||||||
|
errors.password ? "border-red-500" : "border-gray-700"
|
||||||
|
}`}
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" /> : <Eye className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1">
|
||||||
|
{errors.password}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember + Forgot */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
rememberMe: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">{t("rememberMe")}</span>
|
||||||
|
</label>
|
||||||
|
<Link href="/forgot-password" className="text-sm text-amber-400 hover:text-amber-300 transition-colors">
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || isSuccess}
|
||||||
|
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t("loggingIn")}{" "}
|
||||||
|
</>
|
||||||
|
) : isSuccess ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-5 h-5" /> {t("success")}{" "}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<LogIn className="w-5 h-5" />
|
||||||
|
{t("login")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
app/components/LoginComponent/OtpComponent.js
Normal file
143
app/components/LoginComponent/OtpComponent.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle, KeyRound, Loader2, Shield } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { sendEmailOTP, sendPhoneOTP, verifyEmail, verifyPhone, getOwnerByUserId, getCustomerByUserId } from "@/app/utils/api";
|
||||||
|
import AuthService from "@/app/services/AuthService";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
export default function OtpComponent({ isLoading, setIsLoading, isSuccess, setIsSuccess, loginMethod, formData, setStep }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const [otpError, setOtpError] = useState("");
|
||||||
|
const handleVerifyOTP = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!otpCode || otpCode.length < 4) {
|
||||||
|
setOtpError(t("otpRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setOtpError("");
|
||||||
|
try {
|
||||||
|
const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
|
||||||
|
const result = await verifyFn(otpCode);
|
||||||
|
if (result?.ok) {
|
||||||
|
try {
|
||||||
|
await AuthService.cacheCurrentUser(getOwnerByUserId, getCustomerByUserId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to cache user", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsSuccess(true);
|
||||||
|
toast.success(t("verifySuccess"));
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/");
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[OTP] Error:", err);
|
||||||
|
setOtpError(err.message || t("verifyError"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resendOTP = async () => {
|
||||||
|
try {
|
||||||
|
if (loginMethod === "email") {
|
||||||
|
await sendEmailOTP();
|
||||||
|
} else {
|
||||||
|
await sendPhoneOTP();
|
||||||
|
}
|
||||||
|
toast.success(t("resendSuccess"), {
|
||||||
|
style: { background: "#dcfce7", color: "#166534" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[OTP] Resend failed:", err);
|
||||||
|
toast.error(t("resendFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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-amber-300 text-sm">
|
||||||
|
{t("sendOTPTo")}{" "}
|
||||||
|
<span className="text-amber-50 font-medium" dir="ltr">
|
||||||
|
{formData?.credential}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">{t("otpLabel")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOtpCode(e.target.value);
|
||||||
|
if (otpError) setOtpError("");
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-4 bg-gray-800 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" />
|
||||||
|
{t("verifying")}
|
||||||
|
</>
|
||||||
|
) : isSuccess ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
{t("success")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<KeyRound className="w-5 h-5" />
|
||||||
|
{t("verify")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("login");
|
||||||
|
setOtpCode("");
|
||||||
|
setOtpError("");
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← {t("back")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={resendOTP} className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||||
|
{t("resendCode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/components/LoginComponent/PolicyAndPrivecy.js
Normal file
21
app/components/LoginComponent/PolicyAndPrivecy.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { itemVariants } from "../Animation/HeaderLoginAnimation";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
export default function PolicyAndPrivecy() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.p variants={itemVariants} className="text-center text-gray-500 text-xs mt-4">
|
||||||
|
{t("agreeTerms")}{" "}
|
||||||
|
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||||
|
{t("termsOfUse")}
|
||||||
|
</Link>{" "}
|
||||||
|
{t("and")}{" "}
|
||||||
|
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">
|
||||||
|
{t("privacyPolicy")}
|
||||||
|
</Link>
|
||||||
|
</motion.p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/components/LoginComponent/Tabs.js
Normal file
36
app/components/LoginComponent/Tabs.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Mail, Phone } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function TabsLogin({ loginMethod, handelTabs }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 bg-white/5 p-1 rounded-xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handelTabs("email");
|
||||||
|
}}
|
||||||
|
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" />
|
||||||
|
{t("emailMethod")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
handelTabs("phone");
|
||||||
|
}}
|
||||||
|
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" />
|
||||||
|
{t("phoneMethod")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { LogIn, UserPlus } from 'lucide-react';
|
import { LogIn, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
export function NavLink({ href, children }) {
|
export function NavLink({ href, children, light }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isActive = pathname === href || pathname.startsWith(href + '/');
|
const isActive = pathname === href || pathname.startsWith(href + '/');
|
||||||
return (
|
return (
|
||||||
@ -11,19 +11,19 @@ export function NavLink({ href, children }) {
|
|||||||
href={href}
|
href={href}
|
||||||
className={`px-5 py-3 rounded-lg font-semibold transition-all duration-300 text-lg relative group ${
|
className={`px-5 py-3 rounded-lg font-semibold transition-all duration-300 text-lg relative group ${
|
||||||
isActive
|
isActive
|
||||||
? 'text-amber-600 bg-amber-50'
|
? light ? 'text-amber-300 bg-white/10' : 'text-amber-600 bg-amber-50'
|
||||||
: 'text-gray-700 hover:text-amber-600 hover:bg-gray-100'
|
: light ? 'text-white/90 hover:text-amber-300 hover:bg-white/10' : 'text-gray-700 hover:text-amber-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span className={`absolute bottom-0 left-1/2 transform -translate-x-1/2 h-0.5 transition-all duration-300 ${
|
<span className={`absolute bottom-0 left-1/2 transform -translate-x-1/2 h-0.5 transition-all duration-300 ${
|
||||||
isActive ? 'w-4/5 bg-amber-600' : 'w-0 bg-amber-600 group-hover:w-4/5'
|
isActive ? 'w-4/5 bg-amber-400' : 'w-0 bg-amber-400 group-hover:w-4/5'
|
||||||
}`}></span>
|
}`}></span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileNavLink({ href, children, onClick }) {
|
export function MobileNavLink({ href, children, onClick, light }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isActive = pathname === href || pathname.startsWith(href + '/');
|
const isActive = pathname === href || pathname.startsWith(href + '/');
|
||||||
return (
|
return (
|
||||||
@ -32,8 +32,8 @@ export function MobileNavLink({ href, children, onClick }) {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'text-amber-600 bg-amber-50'
|
? light ? 'text-amber-300 bg-white/10' : 'text-amber-600 bg-amber-50'
|
||||||
: 'text-gray-700 hover:text-amber-600 hover:bg-gray-50'
|
: light ? 'text-white/80 hover:text-amber-300 hover:bg-white/10' : 'text-gray-700 hover:text-amber-600 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
151
app/components/NotificationHandler.js
Normal file
151
app/components/NotificationHandler.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"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";
|
||||||
|
import { setFCMToken } from "../utils/api";
|
||||||
|
|
||||||
|
//فاير بيز
|
||||||
|
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) {
|
||||||
|
await setFCMToken(fcmToken, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/components/OwnerComponents/ButtomStepOne.js
Normal file
25
app/components/OwnerComponents/ButtomStepOne.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
export default function ButtomStepOne({ type }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{t("register.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`flex-1 bg-gradient-to-r ${type === "owner" ? " from-amber-500 to-amber-600" : " from-blue-500 to-blue-600"} text-white py-3 px-4 rounded-xl font-medium ${type === "onwer" ? "hover:from-amber-600 hover:to-amber-700" : "hover:from-blue-600 hover:to-blue-700"} transition-all`}
|
||||||
|
>
|
||||||
|
{t("register.next")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/components/OwnerComponents/ButtomStepTwo.js
Normal file
27
app/components/OwnerComponents/ButtomStepTwo.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function ButtomStepTwo({ setStep, isLoading, formData, type }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
{t("register.previous")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !formData.agreeTerms}
|
||||||
|
className={`flex-1 bg-gradient-to-r${type === "owner" ? " from-amber-500 to-amber-600" : " from-blue-500 to-blue-600"} text-amber-50 py-3 px-4 rounded-xl font-medium${type === "onwer" ? "hover:from-amber-600 hover:to-amber-700" : "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>{t("register.registering")}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("register.createAccount")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
app/components/OwnerComponents/FormOwner.js
Normal file
6
app/components/OwnerComponents/FormOwner.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export default function FormOwner(){
|
||||||
|
return(<>
|
||||||
|
|
||||||
|
</>)
|
||||||
|
}
|
||||||
44
app/components/OwnerComponents/HeaderOfForm.js
Normal file
44
app/components/OwnerComponents/HeaderOfForm.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Building, Home } from "lucide-react";
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
colors: "from-amber-500 to-amber-600",
|
||||||
|
titleKey: "register.owner.step1Title",
|
||||||
|
title2Key: "register.owner.step1bTitle",
|
||||||
|
descKey: "register.owner.step1Desc",
|
||||||
|
desc2Key: "register.owner.step1bDesc",
|
||||||
|
icon: <Building className="w-10 h-10 text-white" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
colors: "from-blue-500 to-blue-600",
|
||||||
|
titleKey: "register.tenant.step1Title",
|
||||||
|
title2Key: "register.tenant.step2Title",
|
||||||
|
descKey: "register.tenant.step1Desc",
|
||||||
|
desc2Key: "register.tenant.step2Desc",
|
||||||
|
icon: <Home className="w-10 h-10 text-white" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export default function HeaderOfForm({ step = 1, num = 0 }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentData = data[num] || data[0];
|
||||||
|
return (
|
||||||
|
<div className={`bg-gradient-to-r ${currentData.colors} 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"
|
||||||
|
>
|
||||||
|
{data[num].icon}
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">{t(step === 1 ? t(currentData.titleKey) : t(currentData.title2Key))}</h1>
|
||||||
|
<p className="text-amber-100">{t(step === 1 ? t(currentData.descKey) : t(currentData.desc2Key))}</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
app/components/OwnerComponents/HeaderOfSteps.js
Normal file
28
app/components/OwnerComponents/HeaderOfSteps.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function HeaderOfSteps({ step, colors, numberStep }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<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>{t("forgotPassword.backToLogin")}</span>
|
||||||
|
</Link>
|
||||||
|
<span className="text-sm text-gray-200">{t("register.stepOf2", { step: step })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{numberStep.map((s) => (
|
||||||
|
<motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? colors : "bg-gray-800"}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
app/components/OwnerComponents/OtpDialog.js
Normal file
104
app/components/OwnerComponents/OtpDialog.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { KeyRound, Loader2, Shield } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
|
import { sendEmailOTP, verifyEmail } from "@/app/utils/api";
|
||||||
|
import AuthService from "@/app/services/AuthService";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
export default function OtpDialog({ formData, setShowOtpModal, type }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [otpCode, setOtpCode] = useState("");
|
||||||
|
const handleVerifyOTP = async () => {
|
||||||
|
if (!otpCode || otpCode.length < 4) {
|
||||||
|
toast.error(t("register.otpRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await verifyEmail(otpCode);
|
||||||
|
if (res.status === 200) {
|
||||||
|
AuthService.deleteToken();
|
||||||
|
toast.success(res.message || t("register.emailVerified"));
|
||||||
|
setShowOtpModal(false);
|
||||||
|
setTimeout(() => router.push("/login"), 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || res.data?.message || t("accountVerification.otpInvalid"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t("register.verificationError"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOTP = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await sendEmailOTP();
|
||||||
|
toast.success(t("register.newOtpSent"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("register.resendOtpFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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-amber-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Shield className={`w-8 h-8 ${type === "owner" ? "text-amber-500" : "text-blue-500"} `} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{t("register.otpTitle")}</h2>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">{t("register.otpSentTo")}</p>
|
||||||
|
<p className={`${type === "owner" ? "text-amber-500" : "text-blue-500"} font-medium text-sm`}>{formData.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">{t("register.otpLabel")}</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-amber-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 ${type === "owner" ? "from-amber-500 to-amber-600" : "from-blue-500 to-blue-600"} text-white py-3 rounded-xl font-medium ${type === "owner" ? "hover:from-amber-600 hover:to-amber-700" : "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>{t("register.verifying")}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("register.verify")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleResendOTP}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`w-full text-center ${type === "owner" ? "text-amber-400 hover:text-amber-300" : "text-blue-400 hover:text-blue-300"} text-sm mt-3 disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{t("register.resendOtp")}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
app/components/OwnerComponents/Owner.js
Normal file
162
app/components/OwnerComponents/Owner.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
|
import { addOwner, loginWithEmail } from "../../utils/api";
|
||||||
|
import AuthService from "../../services/AuthService";
|
||||||
|
import { OwnerType } from "../../enums";
|
||||||
|
import OtpDialog from "./OtpDialog";
|
||||||
|
import HeaderOfSteps from "./HeaderOfSteps";
|
||||||
|
import HeaderOfForm from "./HeaderOfForm";
|
||||||
|
import { BackgroundElements, fadeInUp, staggerContainer } from "../Animation/OwnerPageAnimation";
|
||||||
|
import { validateStep1, validateStep2 } from "../../validations/OwnerValidatoin";
|
||||||
|
import OwnerFormStepOne from "./OwnerFormStepOne";
|
||||||
|
import ButtomStepOne from "./ButtomStepOne";
|
||||||
|
import ButtomStepTwo from "./ButtomStepTwo";
|
||||||
|
import OwnerFormStepTwo from "./OwnerFormStepTwo";
|
||||||
|
import InteractiveBackground from "../Animation/Background";
|
||||||
|
|
||||||
|
export default function Owner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
whatsapp: "",
|
||||||
|
phone2: "",
|
||||||
|
nationalNumber: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
ownerType: OwnerType.PERSON,
|
||||||
|
agreeTerms: false,
|
||||||
|
});
|
||||||
|
const [idImages, setIdImages] = useState({ front: null, back: null, license: null });
|
||||||
|
const [idImagePreviews, setIdImagePreviews] = useState({ front: "", back: "", license: "" });
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
const isCompany = Number(formData.ownerType) === OwnerType.REAL_ESTATE_AGENCY;
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
if (validateStep1(formData, setErrors, t)) {
|
||||||
|
setStep(2);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
} else {
|
||||||
|
toast.error(t("validation.correctErrors"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validateStep2(idImages, setErrors, t)) {
|
||||||
|
toast.error(t("register.completeRequiredImages"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.agreeTerms) {
|
||||||
|
toast.error(t("validation.agreeToTerms"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
const payload = {
|
||||||
|
firstName: formData.firstName,
|
||||||
|
lastName: formData.lastName,
|
||||||
|
email: formData.email,
|
||||||
|
phoneNumber: formData.phone || "",
|
||||||
|
whatsAppNumber: formData.whatsapp,
|
||||||
|
phone: formData.phone2,
|
||||||
|
nationalNumber: formData.nationalNumber,
|
||||||
|
password: formData.password,
|
||||||
|
ownerType: formData.ownerType,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const licenseImage = isCompany ? idImages.license : null;
|
||||||
|
const res = await addOwner(payload, idImages.front, idImages.back,licenseImage);
|
||||||
|
if (res.status === 200 || res.ok) {
|
||||||
|
toast.success(res.message || t("register.accountCreated")||res.data[0].message , { duration: 4000 });
|
||||||
|
const loginRes = await loginWithEmail(formData.email, formData.password);
|
||||||
|
if (loginRes.status === 206) {
|
||||||
|
const otpToken = loginRes.data;
|
||||||
|
if (otpToken) AuthService.addToken(otpToken);
|
||||||
|
toast(loginRes.message || t("register.otpSentEmail"), { icon: "📧" });
|
||||||
|
setShowOtpModal(true);
|
||||||
|
} else if (loginRes.status === 200) {
|
||||||
|
const loginToken = loginRes.data;
|
||||||
|
if (loginToken) AuthService.addToken(loginToken);
|
||||||
|
toast.success(loginRes.message || t("register.loginSuccess"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(res.data[0].message || res.data?.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t("register.registrationError"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<BackgroundElements />
|
||||||
|
<InteractiveBackground />
|
||||||
|
</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-2xl">
|
||||||
|
<HeaderOfSteps step={step} numberStep={[1, 2]} colors={"bg-amber-500"} />
|
||||||
|
<motion.div
|
||||||
|
key={step}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"
|
||||||
|
>
|
||||||
|
<HeaderOfForm step={step} num={0} />
|
||||||
|
<div className="p-8">
|
||||||
|
<motion.form
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
onSubmit={
|
||||||
|
step === 1
|
||||||
|
? (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNextStep();
|
||||||
|
}
|
||||||
|
: handleSubmit
|
||||||
|
}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{step === 1 && (
|
||||||
|
<OwnerFormStepOne
|
||||||
|
setErrors={setErrors}
|
||||||
|
setFormData={setFormData}
|
||||||
|
formData={formData}
|
||||||
|
errors={errors}
|
||||||
|
isCompany={isCompany}
|
||||||
|
setIdImagePreviews={setIdImagePreviews}
|
||||||
|
setIdImages={setIdImages}
|
||||||
|
type={"owner"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<OwnerFormStepTwo idImagePreviews={idImagePreviews} errors={errors} setIdImagePreviews={setIdImagePreviews} setIdImages={setIdImages} setFormData={setFormData} formData={formData} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
|
||||||
|
{step === 1 ? ( <> <ButtomStepOne type={"owner"} /></>) : ( <> <ButtomStepTwo formData={formData} isLoading={isLoading} setStep={setStep} type={"owner"} /> </>)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
<AnimatePresence>{showOtpModal && <OtpDialog formData={formData} setShowOtpModal={setShowOtpModal} type={"owner"} />}</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
app/components/OwnerComponents/OwnerFormStepOne.js
Normal file
271
app/components/OwnerComponents/OwnerFormStepOne.js
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { fadeInUp } from "../Animation/OwnerPageAnimation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { User, Mail, Phone, Lock, Eye, EyeOff, MessageCircle, Camera, X, CheckCircle, XCircle } from "lucide-react";
|
||||||
|
import { CustomerTypeLabels, OwnerTypeLabels } from "@/app/enums";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { handleImageUpload } from "@/app/HelperFunction/UploadImage";
|
||||||
|
import UploadImage from "./UploadImage";
|
||||||
|
|
||||||
|
export default function OwnerFormStepOne({ formData, errors, setFormData, setErrors, isCompany, idImagePreviews, setIdImagePreviews, setIdImages, type }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const fileInputLicenseRef = useRef(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.firstName")} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<User className={`w-5 h-5 ${errors.firstName ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({ ...formData, firstName: e.target.value });
|
||||||
|
setErrors({ ...errors, firstName: null });
|
||||||
|
}}
|
||||||
|
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.firstName ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.firstName")}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
{t("register.lastName")} <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-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.lastName ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.lastName")}
|
||||||
|
/>
|
||||||
|
{errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.email")} <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-amber-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-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.email ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.emailPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.phone")} <span className="text-gray-500">({t("register.optional")})</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 text-gray-400 group-focus-within:text-amber-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 border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all"
|
||||||
|
placeholder={t("register.phoneOptionalPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.whatsapp")} <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">
|
||||||
|
<MessageCircle className={`w-5 h-5 ${errors.whatsapp ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={formData.whatsapp}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData({ ...formData, whatsapp: e.target.value });
|
||||||
|
setErrors({ ...errors, whatsapp: null });
|
||||||
|
}}
|
||||||
|
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.whatsapp ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.whatsappPlaceholder")}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
{t("register.phoneSecondary")} <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-amber-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-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.phone2 ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.phoneSecondaryPlaceholder")}
|
||||||
|
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">
|
||||||
|
{t("register.nationalNumber")} <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-amber-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-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.nationalNumber ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.nationalNumberPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
{type === "owner" ? (
|
||||||
|
<>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.ownerType")} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.ownerType.toString()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedType = parseInt(e.target.value, 10);
|
||||||
|
setFormData((prev) => ({ ...prev, ownerType: selectedType }));
|
||||||
|
}}
|
||||||
|
className="w-full py-3 px-4 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent appearance-none text-gray-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
{Object.entries(OwnerTypeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value} className="bg-gray-900 ">
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.customerType")} <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>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCompany && <UploadImage idImagePreviews={idImagePreviews} type={"license"} errors={errors.license} setIdImagePreviews={setIdImagePreviews} setIdImages={setIdImages} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.password")} <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-amber-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-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.password ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.passwordPlaceholder")}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeInUp}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{t("register.confirmPassword")} <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-amber-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-amber-500 focus:border-transparent text-amber-50 placeholder-gray-500 transition-all ${errors.confirmPassword ? "border-red-500" : "border-gray-700"}`}
|
||||||
|
placeholder={t("register.confirmPasswordPlaceholder")}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
{showConfirmPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
|
||||||
|
</button>
|
||||||
|
{formData.confirmPassword && (
|
||||||
|
<div className="absolute inset-y-0 left-12 flex items-center">
|
||||||
|
{formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/components/OwnerComponents/OwnerFormStepTwo.js
Normal file
12
app/components/OwnerComponents/OwnerFormStepTwo.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Policy from "./PolicyAgree";
|
||||||
|
import UploadImage from "./UploadImage";
|
||||||
|
export default function OwnerFormStepTwo({idImagePreviews,errors,setIdImagePreviews,setIdImages,setFormData,formData}) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UploadImage idImagePreviews={idImagePreviews} type={"front"} errors={errors.front} setIdImagePreviews={setIdImagePreviews} setIdImages={setIdImages} />
|
||||||
|
<UploadImage idImagePreviews={idImagePreviews} type={"back"} errors={errors.back} setIdImagePreviews={setIdImagePreviews} setIdImages={setIdImages} />
|
||||||
|
<Policy setFormData={setFormData} formData={formData} type={"owner"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/components/OwnerComponents/PolicyAgree.js
Normal file
31
app/components/OwnerComponents/PolicyAgree.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { fadeInUp } from "../Animation/OwnerPageAnimation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
export default function Policy({ formData, setFormData, type }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div variants={fadeInUp} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="terms"
|
||||||
|
checked={formData.agreeTerms}
|
||||||
|
onChange={(e) => setFormData({ ...formData, agreeTerms: e.target.checked })}
|
||||||
|
className={`w-4 h-4 rounded border-gray-600 bg-white/5 ${type === "owner" ? `text-amber-500 focus:ring-amber-500` : ` text-blue-500 focus:ring-blue-500`} `}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label htmlFor="terms" className="text-sm text-gray-300">
|
||||||
|
{t("register.agreeTerms")}{" "}
|
||||||
|
<Link href="/terms" className={`${type === "owner" ? `text-amber-500 focus:ring-amber-500` : ` text-blue-500 focus:ring-blue-500`}`}>
|
||||||
|
{t("register.termsOfUse")}
|
||||||
|
</Link>{" "}
|
||||||
|
{t("register.and")}{" "}
|
||||||
|
<Link href="/privacy" className={`${type === "owner" ? `text-amber-500 focus:ring-amber-500` : ` text-blue-500 focus:ring-blue-500`}`}>
|
||||||
|
{t("register.privacyPolicy")}
|
||||||
|
</Link>
|
||||||
|
</label>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/components/OwnerComponents/UploadImage.js
Normal file
85
app/components/OwnerComponents/UploadImage.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { fadeInUp } from "../Animation/OwnerPageAnimation";
|
||||||
|
import { handleImageUpload } from "@/app/HelperFunction/UploadImage";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Camera, X } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
export default function UploadImage({ idImagePreviews, type, errors, setIdImagePreviews, setIdImages }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const currentPreview = idImagePreviews?.[type];
|
||||||
|
const handleRemoveImage = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIdImages((prev) => ({ ...prev, [type]: null }));
|
||||||
|
setIdImagePreviews((prev) => ({ ...prev, [type]: "" }));
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabel = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "front":
|
||||||
|
return t("register.owner.frontIdLabel");
|
||||||
|
case "back":
|
||||||
|
return t("register.owner.backIdLabel");
|
||||||
|
case "license":
|
||||||
|
return t("register.owner.licenseImageLabel");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClickText = () => {
|
||||||
|
return type === "license" ? t("register.owner.clickUploadLicense") : t("register.owner.clickUploadImage");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAltText = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "front":
|
||||||
|
return t("register.uploadFrontAlt");
|
||||||
|
case "back":
|
||||||
|
return t("register.uploadBackAlt");
|
||||||
|
case "license":
|
||||||
|
return t("register.uploadLicenseAlt");
|
||||||
|
default:
|
||||||
|
return "Preview Image";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={fadeInUp} key={`${type}-upload-step`}>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
{getLabel()} {type === "license" ? <span className="text-gray-400">({t("register.optional")})</span> : <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{type === "license" && <div className="mb-2 text-xs text-gray-400">{t("register.owner.licenseHint")}</div>}
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${
|
||||||
|
currentPreview ? "border-green-500 bg-green-500/10" : errors ? "border-red-500 bg-red-500/10" : "border-gray-700 hover:border-amber-500 hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={(e) => handleImageUpload(type, e.target.files?.[0], t, setIdImagePreviews, setIdImages)} className="hidden" />
|
||||||
|
{currentPreview ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Image src={currentPreview} alt={getAltText()} width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||||
|
<button type="button" onClick={handleRemoveImage} className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600 transition-colors">
|
||||||
|
<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">{getClickText()}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • {t("register.upTo5MB")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors && <p className="text-red-500 text-sm mt-1">{errors}</p>}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
app/components/PropertyMapWithMarkers.js
Normal file
100
app/components/PropertyMapWithMarkers.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function PropertyMapWithMarkers({ properties = [], onPropertyClick }) {
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const mapInstanceRef = useRef(null);
|
||||||
|
const markersRef = useRef([]);
|
||||||
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
|
|
||||||
|
const map = L.map(mapRef.current).setView([33.5138, 38.9968], 7);
|
||||||
|
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
setMapLoaded(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current || !mapLoaded) return;
|
||||||
|
|
||||||
|
markersRef.current.forEach((marker) => marker.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
|
||||||
|
properties.forEach((property) => {
|
||||||
|
if (property.lat && property.lng) {
|
||||||
|
const marker = L.marker([property.lat, property.lng]).addTo(mapInstanceRef.current);
|
||||||
|
|
||||||
|
const popupContent = `
|
||||||
|
<div dir="rtl" style="text-align: right; padding: 12px; max-width: 250px;">
|
||||||
|
<h3 style="font-weight: bold; font-size: 16px; margin-bottom: 8px; color: #111;">${property.title || "عقار"}</h3>
|
||||||
|
<p style="font-size: 14px; color: #666; margin-bottom: 8px;">${property.address || property.location?.address || ""}</p>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<span style="font-weight: bold; font-size: 18px; color: #d97706;">${formatPrice(property)}</span>
|
||||||
|
</div>
|
||||||
|
${property.images && property.images.length > 0 ? `<img src="${property.images[0] ?? "https://tse4.mm.bing.net/th/id/OIP.-VT-J9Brp8KDSlb0Z_990wAAAA?r=0&rs=1&pid=ImgDetMain&o=7&rm=3"}" alt="${property.title}" style="width: 100%; height: 96px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" onerror="this.src='/property-placeholder.jpg'" />` : ""}
|
||||||
|
<div style="font-size: 12px; color: #888;">
|
||||||
|
${property.type ? `<p>النوع: ${property.type}</p>` : ""}
|
||||||
|
${property.bedrooms > 0 ? `<p>غرف نوم: ${property.bedrooms}</p>` : ""}
|
||||||
|
${property.bathrooms > 0 ? `<p>حمامات: ${property.bathrooms}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
|
marker.on("click", () => {
|
||||||
|
if (onPropertyClick) {
|
||||||
|
onPropertyClick(property);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (markersRef.current.length > 0) {
|
||||||
|
const group = L.featureGroup(markersRef.current);
|
||||||
|
mapInstanceRef.current.fitBounds(group.getBounds(), { padding: [50, 100] });
|
||||||
|
}
|
||||||
|
}, [properties, mapLoaded]);
|
||||||
|
|
||||||
|
const formatPrice = (property) => {
|
||||||
|
if (property.priceUnit === "monthly") {
|
||||||
|
return `${property.price?.toLocaleString() || 0} ل.س/شهر`;
|
||||||
|
} else if (property.priceUnit === "daily") {
|
||||||
|
return `${property.price?.toLocaleString() || 0} ل.س/يوم`;
|
||||||
|
} else {
|
||||||
|
return `${property.price?.toLocaleString() || 0} ل.س`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-[600px] rounded-xl overflow-hidden border-2 border-gray-200">
|
||||||
|
<div ref={mapRef} className="w-full h-full z-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,346 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
|
||||||
import { COMMISSION_TYPE, CITIES } from '@/app/utils/constants';
|
|
||||||
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function AddPropertyForm({ onClose, onSuccess }) {
|
|
||||||
const { addProperty } = useProperties();
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
city: '',
|
|
||||||
district: '',
|
|
||||||
address: '',
|
|
||||||
latitude: '',
|
|
||||||
longitude: '',
|
|
||||||
|
|
||||||
type: 'apartment',
|
|
||||||
bedrooms: 1,
|
|
||||||
bathrooms: 1,
|
|
||||||
area: 0,
|
|
||||||
floor: 1,
|
|
||||||
|
|
||||||
dailyPrice: 0,
|
|
||||||
commissionRate: 5,
|
|
||||||
commissionType: COMMISSION_TYPE.FROM_OWNER,
|
|
||||||
|
|
||||||
securityDeposit: 0,
|
|
||||||
|
|
||||||
images: [],
|
|
||||||
features: [],
|
|
||||||
|
|
||||||
status: 'available'
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState([]);
|
|
||||||
|
|
||||||
const featuresList = [
|
|
||||||
'swimmingPool', 'privateGarden', 'parking', 'superLuxFinish',
|
|
||||||
'equippedKitchen', 'centralHeating', 'balcony', 'securitySystem',
|
|
||||||
'largeGarden', 'receptionHall', 'maidRoom', 'garage',
|
|
||||||
'seaView', 'centralAC', 'fruitGarden', 'storage'
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const propertyData = {
|
|
||||||
...formData,
|
|
||||||
features: selectedFeatures,
|
|
||||||
priceDisplay: {
|
|
||||||
daily: formData.dailyPrice,
|
|
||||||
monthly: formData.dailyPrice * 30,
|
|
||||||
withCommission: calculateCommissionPrice(formData)
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
lat: formData.latitude,
|
|
||||||
lng: formData.longitude,
|
|
||||||
address: formData.address
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addProperty(propertyData);
|
|
||||||
onSuccess?.();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding property:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateCommissionPrice = (data) => {
|
|
||||||
const { dailyPrice, commissionRate, commissionType } = data;
|
|
||||||
const commission = (dailyPrice * commissionRate) / 100;
|
|
||||||
|
|
||||||
switch(commissionType) {
|
|
||||||
case COMMISSION_TYPE.FROM_TENANT:
|
|
||||||
return dailyPrice + commission;
|
|
||||||
case COMMISSION_TYPE.FROM_OWNER:
|
|
||||||
return dailyPrice;
|
|
||||||
case COMMISSION_TYPE.FROM_BOTH:
|
|
||||||
return dailyPrice + (commission / 2);
|
|
||||||
default:
|
|
||||||
return dailyPrice;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.9, y: 20 }}
|
|
||||||
animate={{ scale: 1, y: 0 }}
|
|
||||||
className="bg-white rounded-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 bg-white border-b p-4 flex justify-between items-center">
|
|
||||||
<h2 className="text-xl font-bold">إضافة عقار جديد</h2>
|
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
موقع العقار (سيظهر على الخريطة)
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">المدينة</label>
|
|
||||||
<select
|
|
||||||
value={formData.city}
|
|
||||||
onChange={(e) => setFormData({...formData, city: e.target.value})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">اختر المدينة</option>
|
|
||||||
{Object.values(CITIES).map(city => (
|
|
||||||
<option key={city} value={city}>{city}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">الحي</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.district}
|
|
||||||
onChange={(e) => setFormData({...formData, district: e.target.value})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-1">العنوان بالتفصيل</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(e) => setFormData({...formData, address: e.target.value})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">خط العرض (Latitude)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
value={formData.latitude}
|
|
||||||
onChange={(e) => setFormData({...formData, latitude: e.target.value})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">خط الطول (Longitude)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
value={formData.longitude}
|
|
||||||
onChange={(e) => setFormData({...formData, longitude: e.target.value})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-amber-50 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
السعر ونسبة الربح
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
السعر اليومي (ل.س)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.dailyPrice}
|
|
||||||
onChange={(e) => setFormData({...formData, dailyPrice: Number(e.target.value)})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
required
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
هذا السعر سيظهر على الخريطة
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
نسبة ربح المنصة (%)
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.commissionRate}
|
|
||||||
onChange={(e) => setFormData({...formData, commissionRate: Number(e.target.value)})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="0.1"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Percent className="w-4 h-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
مصدر العمولة (بموافقة الأدمن)
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="commissionType"
|
|
||||||
value={COMMISSION_TYPE.FROM_OWNER}
|
|
||||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_OWNER}
|
|
||||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
|
||||||
/>
|
|
||||||
<span>من المالك</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="commissionType"
|
|
||||||
value={COMMISSION_TYPE.FROM_TENANT}
|
|
||||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_TENANT}
|
|
||||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
|
||||||
/>
|
|
||||||
<span>من المستأجر</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="commissionType"
|
|
||||||
value={COMMISSION_TYPE.FROM_BOTH}
|
|
||||||
checked={formData.commissionType === COMMISSION_TYPE.FROM_BOTH}
|
|
||||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
|
||||||
/>
|
|
||||||
<span>من الاثنين</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2 bg-white p-3 rounded-lg">
|
|
||||||
<h4 className="font-medium mb-2">تفاصيل السعر بعد العمولة:</h4>
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">السعر الأصلي:</span>
|
|
||||||
<span className="block font-bold">{formData.dailyPrice} ل.س</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">العمولة:</span>
|
|
||||||
<span className="block font-bold">
|
|
||||||
{(formData.dailyPrice * formData.commissionRate / 100)} ل.س
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-600">السعر النهائي:</span>
|
|
||||||
<span className="block font-bold text-green-600">
|
|
||||||
{calculateCommissionPrice(formData)} ل.س
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">نوع العقار</label>
|
|
||||||
<select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) => setFormData({...formData, type: e.target.value})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
>
|
|
||||||
<option value="apartment">شقة</option>
|
|
||||||
<option value="house">بيت</option>
|
|
||||||
<option value="villa">فيلا</option>
|
|
||||||
<option value="studio">استوديو</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">مبلغ الضمان (ل.س)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.securityDeposit}
|
|
||||||
onChange={(e) => setFormData({...formData, securityDeposit: Number(e.target.value)})}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">المميزات</label>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{featuresList.map(feature => (
|
|
||||||
<label key={feature} className="flex items-center gap-2 p-2 border rounded-lg">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedFeatures.includes(feature)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedFeatures([...selectedFeatures, feature]);
|
|
||||||
} else {
|
|
||||||
setSelectedFeatures(selectedFeatures.filter(f => f !== feature));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{feature}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4 border-t">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
إضافة العقار
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,948 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Clock,
|
|
||||||
User,
|
|
||||||
Home,
|
|
||||||
Calendar,
|
|
||||||
DollarSign,
|
|
||||||
AlertCircle,
|
|
||||||
Key,
|
|
||||||
DoorOpen,
|
|
||||||
Shield,
|
|
||||||
Phone,
|
|
||||||
Mail,
|
|
||||||
MessageCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
FileText,
|
|
||||||
Download,
|
|
||||||
Printer,
|
|
||||||
History
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
const ReasonDialog = ({ isOpen, onClose, onConfirm, title, defaultReason = '' }) => {
|
|
||||||
const [reason, setReason] = useState(defaultReason);
|
|
||||||
const [otherReason, setOtherReason] = useState('');
|
|
||||||
|
|
||||||
const commonReasons = [
|
|
||||||
'أعمال صيانة في العقار',
|
|
||||||
'العقار غير متاح في هذه التواريخ',
|
|
||||||
'مشكلة في وثائق المستأجر',
|
|
||||||
'المالك غير متاح للتسليم',
|
|
||||||
'تأخر في دفع الضمان',
|
|
||||||
'سبب آخر'
|
|
||||||
];
|
|
||||||
|
|
||||||
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">{title}</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">يرجى تحديد سبب الرفض</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{commonReasons.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r}
|
|
||||||
onClick={() => {
|
|
||||||
if (r === 'سبب آخر') {
|
|
||||||
} else {
|
|
||||||
onConfirm(r);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full text-right p-3 border rounded-xl hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
{r}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
placeholder="اكتب سبباً آخر..."
|
|
||||||
value={otherReason}
|
|
||||||
onChange={(e) => setOtherReason(e.target.value)}
|
|
||||||
className="w-full p-3 border rounded-xl resize-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onConfirm(otherReason)}
|
|
||||||
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-colors"
|
|
||||||
disabled={!otherReason.trim()}
|
|
||||||
>
|
|
||||||
تأكيد الرفض
|
|
||||||
</button>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RequestDetailsDialog = ({ request, isOpen, onClose }) => {
|
|
||||||
if (!isOpen || !request) 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-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 bg-white border-b p-4 flex justify-between items-center">
|
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5 text-blue-600" />
|
|
||||||
تفاصيل الطلب #{request.id}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
<XCircle className="w-5 h-5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-4 rounded-xl">
|
|
||||||
<h3 className="font-bold mb-3 flex items-center gap-2 text-blue-800">
|
|
||||||
<User className="w-4 h-4" />
|
|
||||||
معلومات المستأجر
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">الاسم الكامل</label>
|
|
||||||
<div className="font-medium">{request.user}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">نوع الهوية</label>
|
|
||||||
<div className="font-medium">
|
|
||||||
{request.userType === 'syrian' ? '🇸🇾 هوية سورية' : '🛂 جواز سفر'}
|
|
||||||
<span className="text-xs text-gray-500 mr-2">{request.identityNumber}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">البريد الإلكتروني</label>
|
|
||||||
<div className="font-medium flex items-center gap-1">
|
|
||||||
<Mail className="w-3 h-3 text-gray-400" />
|
|
||||||
{request.userEmail}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">رقم الهاتف</label>
|
|
||||||
<div className="font-medium flex items-center gap-1">
|
|
||||||
<Phone className="w-3 h-3 text-gray-400" />
|
|
||||||
{request.userPhone}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-4 rounded-xl">
|
|
||||||
<h3 className="font-bold mb-3 flex items-center gap-2 text-green-800">
|
|
||||||
<Home className="w-4 h-4" />
|
|
||||||
معلومات العقار
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">العقار</label>
|
|
||||||
<div className="font-medium">{request.property}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">السعر اليومي</label>
|
|
||||||
<div className="font-medium text-green-600">{formatCurrency(request.dailyPrice)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 p-4 rounded-xl">
|
|
||||||
<h3 className="font-bold mb-3 flex items-center gap-2 text-amber-800">
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
تفاصيل الحجز
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">تاريخ البداية</label>
|
|
||||||
<div className="font-medium">{request.startDate}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">تاريخ النهاية</label>
|
|
||||||
<div className="font-medium">{request.endDate}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">عدد الأيام</label>
|
|
||||||
<div className="font-medium">{request.days} يوم</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">المبلغ الإجمالي</label>
|
|
||||||
<div className="font-medium text-green-600">{formatCurrency(request.totalAmount)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 p-4 rounded-xl">
|
|
||||||
<h3 className="font-bold mb-3 flex items-center gap-2 text-purple-800">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
المعلومات المالية
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">سلفة الضمان</label>
|
|
||||||
<div className="font-medium text-blue-600">{formatCurrency(request.securityDeposit)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">نسبة العمولة</label>
|
|
||||||
<div className="font-medium">{request.commissionRate}%</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">نوع العمولة</label>
|
|
||||||
<div className="font-medium text-amber-600">{request.commissionType}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-500">قيمة العمولة</label>
|
|
||||||
<div className="font-medium text-amber-600">{formatCurrency(request.commissionAmount)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="font-bold mb-3 flex items-center gap-2">
|
|
||||||
<History className="w-4 h-4" />
|
|
||||||
سجل الإجراءات
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span className="text-gray-600">تم إنشاء الطلب: {request.requestDate}</span>
|
|
||||||
</div>
|
|
||||||
{request.ownerApproved && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
||||||
<span className="text-gray-600">موافقة المالك</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{request.adminApproved && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span className="text-gray-600">موافقة الإدارة</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{request.ownerDelivered && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
|
|
||||||
<span className="text-gray-600">تم تسليم المفتاح من المالك</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{request.notes && (
|
|
||||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-600">{request.notes}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => window.print()}
|
|
||||||
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Printer className="w-5 h-5" />
|
|
||||||
طباعة التفاصيل
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="flex-1 bg-green-600 text-white py-3 rounded-xl font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
تصدير PDF
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RequestCard = ({ request, onAction, onViewDetails }) => {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
|
||||||
switch(status) {
|
|
||||||
case 'pending': return 'border-yellow-400 bg-yellow-50';
|
|
||||||
case 'owner_approved': return 'border-blue-400 bg-blue-50';
|
|
||||||
case 'admin_approved': return 'border-green-400 bg-green-50';
|
|
||||||
case 'active': return 'border-purple-400 bg-purple-50';
|
|
||||||
case 'completed': return 'border-gray-400 bg-gray-50';
|
|
||||||
case 'rejected': return 'border-red-400 bg-red-50';
|
|
||||||
default: return 'border-gray-200 bg-white';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
|
||||||
const styles = {
|
|
||||||
pending: 'bg-yellow-500 text-white',
|
|
||||||
owner_approved: 'bg-blue-500 text-white',
|
|
||||||
admin_approved: 'bg-green-500 text-white',
|
|
||||||
active: 'bg-purple-500 text-white',
|
|
||||||
completed: 'bg-gray-500 text-white',
|
|
||||||
rejected: 'bg-red-500 text-white'
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
pending: ' بانتظار الموافقة',
|
|
||||||
owner_approved: ' موافقة المالك',
|
|
||||||
admin_approved: ' موافقة الإدارة',
|
|
||||||
active: ' إيجار نشط',
|
|
||||||
completed: ' منتهي',
|
|
||||||
rejected: ' مرفوض'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium shadow-sm ${styles[status]}`}>
|
|
||||||
{labels[status]}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className={`bg-white border-2 rounded-2xl overflow-hidden transition-all hover:shadow-xl ${getStatusColor(request.status)}`}
|
|
||||||
>
|
|
||||||
<div className="p-4 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-lg font-bold text-gray-900">طلب #{request.id}</span>
|
|
||||||
{getStatusBadge(request.status)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-gray-500">{request.requestDate}</span>
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<User className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="truncate">{request.user}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Home className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="truncate">{request.property}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Calendar className="w-4 h-4 text-gray-400" />
|
|
||||||
<span>{request.days} أيام</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="font-bold text-green-600">{formatCurrency(request.totalAmount)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{expanded && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
className="border-t bg-white p-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-4">
|
|
||||||
<div className="bg-blue-50 p-2 rounded-lg">
|
|
||||||
<div className="text-xs text-gray-500">سلفة ضمان</div>
|
|
||||||
<div className="font-bold text-blue-600">{formatCurrency(request.securityDeposit)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-amber-50 p-2 rounded-lg">
|
|
||||||
<div className="text-xs text-gray-500">العمولة</div>
|
|
||||||
<div className="font-bold text-amber-600">{request.commissionRate}% ({request.commissionType})</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 p-2 rounded-lg">
|
|
||||||
<div className="text-xs text-gray-500">مدة الإيجار</div>
|
|
||||||
<div className="font-bold text-purple-600">{request.startDate} إلى {request.endDate}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(request.ownerApproved || request.adminApproved) && (
|
|
||||||
<div className="bg-green-50 p-3 rounded-lg mb-4">
|
|
||||||
<h4 className="font-bold text-sm mb-2 flex items-center gap-2 text-green-800">
|
|
||||||
<Phone className="w-4 h-4" />
|
|
||||||
معلومات الاتصال
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Mail className="w-3 h-3 text-gray-500" />
|
|
||||||
{request.userEmail}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Phone className="w-3 h-3 text-gray-500" />
|
|
||||||
{request.userPhone}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{request.status === 'pending' && (
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('owner_approve', request.id)}
|
|
||||||
className="flex-1 bg-green-600 text-white py-3 rounded-xl font-medium hover:bg-green-700 transition-all transform hover:scale-105 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-5 h-5" />
|
|
||||||
موافقة المالك
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('owner_reject', request.id)}
|
|
||||||
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-all transform hover:scale-105 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<XCircle className="w-5 h-5" />
|
|
||||||
رفض
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('view_details', request)}
|
|
||||||
className="px-4 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
<FileText className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{request.status === 'owner_approved' && (
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('admin_approve', request.id)}
|
|
||||||
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-medium hover:bg-blue-700 transition-all transform hover:scale-105 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-5 h-5" />
|
|
||||||
موافقة الإدارة
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('admin_reject', request.id)}
|
|
||||||
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-all transform hover:scale-105 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<XCircle className="w-5 h-5" />
|
|
||||||
رفض إداري
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('view_details', request)}
|
|
||||||
className="px-4 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
<FileText className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{request.status === 'admin_approved' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('deliver_key', { id: request.id, type: 'owner' })}
|
|
||||||
disabled={request.ownerDelivered}
|
|
||||||
className={`py-3 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
|
||||||
request.ownerDelivered
|
|
||||||
? 'bg-green-100 text-green-800 cursor-default'
|
|
||||||
: 'bg-blue-600 text-white hover:bg-blue-700 transform hover:scale-105'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Key className="w-5 h-5" />
|
|
||||||
{request.ownerDelivered ? '✓ تم تسليم المفتاح' : 'تسليم المفتاح'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('receive_property', { id: request.id, type: 'tenant' })}
|
|
||||||
disabled={request.tenantReceived}
|
|
||||||
className={`py-3 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
|
||||||
request.tenantReceived
|
|
||||||
? 'bg-green-100 text-green-800 cursor-default'
|
|
||||||
: 'bg-green-600 text-white hover:bg-green-700 transform hover:scale-105'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<DoorOpen className="w-5 h-5" />
|
|
||||||
{request.tenantReceived ? '✓ تم الاستلام' : 'استلام العقار'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{request.status === 'active' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('tenant_leave', { id: request.id, type: 'tenant' })}
|
|
||||||
disabled={request.tenantLeft}
|
|
||||||
className={`py-3 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
|
||||||
request.tenantLeft
|
|
||||||
? 'bg-green-100 text-green-800 cursor-default'
|
|
||||||
: 'bg-amber-600 text-white hover:bg-amber-700 transform hover:scale-105'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<DoorOpen className="w-5 h-5" />
|
|
||||||
{request.tenantLeft ? '✓ تم المغادرة' : 'مغادرة العقار'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onAction('owner_receive', { id: request.id, type: 'owner' })}
|
|
||||||
disabled={request.ownerReceived}
|
|
||||||
className={`py-3 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
|
||||||
request.ownerReceived
|
|
||||||
? 'bg-green-100 text-green-800 cursor-default'
|
|
||||||
: 'bg-purple-600 text-white hover:bg-purple-700 transform hover:scale-105'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Key className="w-5 h-5" />
|
|
||||||
{request.ownerReceived ? '✓ تم الاستلام' : 'استلام العقار'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{request.actualStartDate && (
|
|
||||||
<div className="bg-gray-100 p-3 rounded-lg">
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span>بدأ الإيجار: {request.actualStartDate}</span>
|
|
||||||
<span>المدة: {request.days} يوم</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-300 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-purple-600 h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: '45%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BookingRequests() {
|
|
||||||
const [requests, setRequests] = useState([
|
|
||||||
{
|
|
||||||
id: 'REQ001',
|
|
||||||
user: 'أحمد محمد',
|
|
||||||
userEmail: 'ahmed@example.com',
|
|
||||||
userPhone: '0938123456',
|
|
||||||
userType: 'syrian',
|
|
||||||
identityNumber: '123456789',
|
|
||||||
property: 'فيلا فاخرة في دمشق',
|
|
||||||
propertyId: 1,
|
|
||||||
startDate: '2024-03-01',
|
|
||||||
endDate: '2024-03-10',
|
|
||||||
days: 10,
|
|
||||||
totalAmount: 5000000,
|
|
||||||
dailyPrice: 500000,
|
|
||||||
commissionRate: 5,
|
|
||||||
commissionType: 'من المالك',
|
|
||||||
commissionAmount: 250000,
|
|
||||||
securityDeposit: 500000,
|
|
||||||
status: 'pending',
|
|
||||||
requestDate: '2024-02-25',
|
|
||||||
ownerApproved: false,
|
|
||||||
adminApproved: false,
|
|
||||||
ownerDelivered: false,
|
|
||||||
tenantReceived: false,
|
|
||||||
tenantLeft: false,
|
|
||||||
ownerReceived: false,
|
|
||||||
securityDepositReturned: null,
|
|
||||||
contractSigned: false,
|
|
||||||
notes: '',
|
|
||||||
actualStartDate: null,
|
|
||||||
actualEndDate: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'REQ002',
|
|
||||||
user: 'سارة أحمد',
|
|
||||||
userEmail: 'sara@example.com',
|
|
||||||
userPhone: '0945123789',
|
|
||||||
userType: 'passport',
|
|
||||||
identityNumber: 'AB123456',
|
|
||||||
property: 'شقة حديثة في حلب',
|
|
||||||
propertyId: 2,
|
|
||||||
startDate: '2024-03-05',
|
|
||||||
endDate: '2024-03-15',
|
|
||||||
days: 10,
|
|
||||||
totalAmount: 2500000,
|
|
||||||
dailyPrice: 250000,
|
|
||||||
commissionRate: 7,
|
|
||||||
commissionType: 'من المستأجر',
|
|
||||||
commissionAmount: 175000,
|
|
||||||
securityDeposit: 250000,
|
|
||||||
status: 'owner_approved',
|
|
||||||
requestDate: '2024-02-24',
|
|
||||||
ownerApproved: true,
|
|
||||||
adminApproved: false,
|
|
||||||
ownerDelivered: false,
|
|
||||||
tenantReceived: false,
|
|
||||||
tenantLeft: false,
|
|
||||||
ownerReceived: false,
|
|
||||||
securityDepositReturned: null,
|
|
||||||
contractSigned: false,
|
|
||||||
notes: '',
|
|
||||||
actualStartDate: null,
|
|
||||||
actualEndDate: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'REQ003',
|
|
||||||
user: 'محمد الحلبي',
|
|
||||||
userEmail: 'mohammed@example.com',
|
|
||||||
userPhone: '0956123456',
|
|
||||||
userType: 'syrian',
|
|
||||||
identityNumber: '987654321',
|
|
||||||
property: 'شقة بجانب البحر في اللاذقية',
|
|
||||||
propertyId: 3,
|
|
||||||
startDate: '2024-02-20',
|
|
||||||
endDate: '2024-03-20',
|
|
||||||
days: 30,
|
|
||||||
totalAmount: 9000000,
|
|
||||||
dailyPrice: 300000,
|
|
||||||
commissionRate: 5,
|
|
||||||
commissionType: 'من الاثنين',
|
|
||||||
commissionAmount: 450000,
|
|
||||||
securityDeposit: 500000,
|
|
||||||
status: 'active',
|
|
||||||
requestDate: '2024-02-15',
|
|
||||||
ownerApproved: true,
|
|
||||||
adminApproved: true,
|
|
||||||
ownerDelivered: true,
|
|
||||||
tenantReceived: true,
|
|
||||||
tenantLeft: false,
|
|
||||||
ownerReceived: false,
|
|
||||||
securityDepositReturned: null,
|
|
||||||
contractSigned: true,
|
|
||||||
notes: 'عقد موقع إلكترونياً',
|
|
||||||
actualStartDate: '2024-02-20',
|
|
||||||
actualEndDate: null
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [filter, setFilter] = useState('all');
|
|
||||||
const [reasonDialog, setReasonDialog] = useState({ isOpen: false, requestId: null, type: null });
|
|
||||||
const [detailsDialog, setDetailsDialog] = useState({ isOpen: false, request: null });
|
|
||||||
|
|
||||||
const handleAction = (action, data) => {
|
|
||||||
switch(action) {
|
|
||||||
case 'owner_approve':
|
|
||||||
handleOwnerApprove(data);
|
|
||||||
break;
|
|
||||||
case 'owner_reject':
|
|
||||||
setReasonDialog({ isOpen: true, requestId: data, type: 'owner' });
|
|
||||||
break;
|
|
||||||
case 'admin_approve':
|
|
||||||
handleAdminApprove(data);
|
|
||||||
break;
|
|
||||||
case 'admin_reject':
|
|
||||||
setReasonDialog({ isOpen: true, requestId: data, type: 'admin' });
|
|
||||||
break;
|
|
||||||
case 'deliver_key':
|
|
||||||
handleKeyDelivery(data.id, data.type);
|
|
||||||
break;
|
|
||||||
case 'receive_property':
|
|
||||||
handleKeyDelivery(data.id, data.type);
|
|
||||||
break;
|
|
||||||
case 'tenant_leave':
|
|
||||||
handleEndRental(data.id, data.type);
|
|
||||||
break;
|
|
||||||
case 'owner_receive':
|
|
||||||
handleEndRental(data.id, data.type);
|
|
||||||
break;
|
|
||||||
case 'view_details':
|
|
||||||
setDetailsDialog({ isOpen: true, request: data });
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRejectWithReason = (reason) => {
|
|
||||||
const { requestId, type } = reasonDialog;
|
|
||||||
|
|
||||||
setRequests(prev =>
|
|
||||||
prev.map(req =>
|
|
||||||
req.id === requestId
|
|
||||||
? {
|
|
||||||
...req,
|
|
||||||
status: 'rejected',
|
|
||||||
[type === 'owner' ? 'ownerApproved' : 'adminApproved']: false,
|
|
||||||
rejectionReason: reason,
|
|
||||||
rejectionType: type,
|
|
||||||
notes: `${type === 'owner' ? 'رفض من المالك' : 'رفض إداري'}: ${reason}`
|
|
||||||
}
|
|
||||||
: req
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
setReasonDialog({ isOpen: false, requestId: null, type: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOwnerApprove = (requestId) => {
|
|
||||||
setRequests(prev =>
|
|
||||||
prev.map(req =>
|
|
||||||
req.id === requestId
|
|
||||||
? {
|
|
||||||
...req,
|
|
||||||
ownerApproved: true,
|
|
||||||
status: 'owner_approved',
|
|
||||||
notes: 'تمت الموافقة من قبل المالك'
|
|
||||||
}
|
|
||||||
: req
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdminApprove = (requestId) => {
|
|
||||||
setRequests(prev =>
|
|
||||||
prev.map(req =>
|
|
||||||
req.id === requestId
|
|
||||||
? {
|
|
||||||
...req,
|
|
||||||
adminApproved: true,
|
|
||||||
status: 'admin_approved',
|
|
||||||
notes: 'تمت الموافقة من قبل الإدارة'
|
|
||||||
}
|
|
||||||
: req
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDelivery = (requestId, userType) => {
|
|
||||||
setRequests(prev =>
|
|
||||||
prev.map(req => {
|
|
||||||
if (req.id === requestId) {
|
|
||||||
const updates = {};
|
|
||||||
|
|
||||||
if (userType === 'owner') {
|
|
||||||
updates.ownerDelivered = true;
|
|
||||||
updates.notes = 'تم تسليم المفتاح من قبل المالك';
|
|
||||||
}
|
|
||||||
if (userType === 'tenant') {
|
|
||||||
updates.tenantReceived = true;
|
|
||||||
updates.notes = 'تم استلام العقار من قبل المستأجر';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((userType === 'owner' && req.tenantReceived) ||
|
|
||||||
(userType === 'tenant' && req.ownerDelivered)) {
|
|
||||||
updates.status = 'active';
|
|
||||||
updates.contractSigned = true;
|
|
||||||
updates.actualStartDate = new Date().toISOString().split('T')[0];
|
|
||||||
updates.notes = 'بدأت فترة الإيجار الفعلية';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...req, ...updates };
|
|
||||||
}
|
|
||||||
return req;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEndRental = (requestId, userType) => {
|
|
||||||
setRequests(prev =>
|
|
||||||
prev.map(req => {
|
|
||||||
if (req.id === requestId) {
|
|
||||||
const updates = {};
|
|
||||||
|
|
||||||
if (userType === 'tenant') {
|
|
||||||
updates.tenantLeft = true;
|
|
||||||
updates.notes = 'غادر المستأجر العقار';
|
|
||||||
}
|
|
||||||
if (userType === 'owner') {
|
|
||||||
updates.ownerReceived = true;
|
|
||||||
updates.notes = 'استلم المالك العقار';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((userType === 'tenant' && req.ownerReceived) ||
|
|
||||||
(userType === 'owner' && req.tenantLeft)) {
|
|
||||||
|
|
||||||
const actualEndDate = new Date();
|
|
||||||
const actualStartDate = new Date(req.actualStartDate || req.startDate);
|
|
||||||
const actualDays = Math.ceil((actualEndDate - actualStartDate) / (1000 * 60 * 60 * 24));
|
|
||||||
const actualAmount = req.dailyPrice * actualDays;
|
|
||||||
|
|
||||||
const damageDeduction = 0;
|
|
||||||
const refundAmount = req.securityDeposit - damageDeduction;
|
|
||||||
|
|
||||||
updates.status = 'completed';
|
|
||||||
updates.actualEndDate = actualEndDate.toISOString().split('T')[0];
|
|
||||||
updates.actualDays = actualDays;
|
|
||||||
updates.actualAmount = actualAmount;
|
|
||||||
updates.securityDepositReturned = refundAmount;
|
|
||||||
updates.damageDeduction = damageDeduction;
|
|
||||||
updates.notes = `انتهى الإيجار بعد ${actualDays} يوم - المبلغ الفعلي: ${actualAmount.toLocaleString()} ل.س - مسترد الضمان: ${refundAmount.toLocaleString()} ل.س`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...req, ...updates };
|
|
||||||
}
|
|
||||||
return req;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredRequests = requests.filter(req =>
|
|
||||||
filter === 'all' ? true : req.status === filter
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: requests.length,
|
|
||||||
pending: requests.filter(r => r.status === 'pending').length,
|
|
||||||
active: requests.filter(r => r.status === 'active').length,
|
|
||||||
completed: requests.filter(r => r.status === 'completed').length
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-2xl p-4 shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="text-3xl font-bold mb-1">{stats.total}</div>
|
|
||||||
<div className="text-sm opacity-90">إجمالي الطلبات</div>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="bg-gradient-to-br from-yellow-600 to-yellow-700 text-white rounded-2xl p-4 shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="text-3xl font-bold mb-1">{stats.pending}</div>
|
|
||||||
<div className="text-sm opacity-90">قيد الانتظار</div>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="bg-gradient-to-br from-purple-600 to-purple-700 text-white rounded-2xl p-4 shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="text-3xl font-bold mb-1">{stats.active}</div>
|
|
||||||
<div className="text-sm opacity-90">إيجارات نشطة</div>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className="bg-gradient-to-br from-green-600 to-green-700 text-white rounded-2xl p-4 shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="text-3xl font-bold mb-1">{stats.completed}</div>
|
|
||||||
<div className="text-sm opacity-90">منتهية</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-4 shadow-lg border border-white/20">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="font-bold text-gray-700">تصفية حسب الحالة</h3>
|
|
||||||
<span className="text-sm text-gray-500">{filteredRequests.length} طلب</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
{ id: 'all', label: 'الكل', color: 'gray' },
|
|
||||||
{ id: 'pending', label: 'قيد الانتظار', color: 'yellow' },
|
|
||||||
{ id: 'owner_approved', label: 'موافقة المالك', color: 'blue' },
|
|
||||||
{ id: 'admin_approved', label: 'موافقة الإدارة', color: 'green' },
|
|
||||||
{ id: 'active', label: 'إيجارات نشطة', color: 'purple' },
|
|
||||||
{ id: 'completed', label: 'منتهية', color: 'gray' }
|
|
||||||
].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setFilter(tab.id)}
|
|
||||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all transform hover:scale-105 ${
|
|
||||||
filter === tab.id
|
|
||||||
? `bg-${tab.color}-600 text-white shadow-lg`
|
|
||||||
: `bg-${tab.color}-100 text-${tab.color}-800 hover:bg-${tab.color}-200`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredRequests.map((request) => (
|
|
||||||
<RequestCard
|
|
||||||
key={request.id}
|
|
||||||
request={request}
|
|
||||||
onAction={handleAction}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredRequests.length === 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-gray-300"
|
|
||||||
>
|
|
||||||
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Clock className="w-12 h-12 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد طلبات حجز</h3>
|
|
||||||
<p className="text-gray-500">لا توجد طلبات حجز في هذه الفئة</p>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ReasonDialog
|
|
||||||
isOpen={reasonDialog.isOpen}
|
|
||||||
onClose={() => setReasonDialog({ isOpen: false, requestId: null, type: null })}
|
|
||||||
onConfirm={handleRejectWithReason}
|
|
||||||
title={reasonDialog.type === 'owner' ? 'رفض من المالك' : 'رفض إداري'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RequestDetailsDialog
|
|
||||||
request={detailsDialog.request}
|
|
||||||
isOpen={detailsDialog.isOpen}
|
|
||||||
onClose={() => setDetailsDialog({ isOpen: false, request: null })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { Users, Home, Calendar, DollarSign } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export default function DashboardStats() {
|
|
||||||
const [stats, setStats] = useState({
|
|
||||||
totalUsers: 0,
|
|
||||||
totalProperties: 0,
|
|
||||||
activeBookings: 0,
|
|
||||||
totalRevenue: 0,
|
|
||||||
pendingRequests: 0,
|
|
||||||
availableProperties: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStats({
|
|
||||||
totalUsers: 156,
|
|
||||||
totalProperties: 89,
|
|
||||||
activeBookings: 34,
|
|
||||||
totalRevenue: 12500000,
|
|
||||||
pendingRequests: 12,
|
|
||||||
availableProperties: 45
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatNumber = (num) => {
|
|
||||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return `${formatNumber(amount)} ل.س`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
title: 'إجمالي المستخدمين',
|
|
||||||
value: stats.totalUsers,
|
|
||||||
icon: Users,
|
|
||||||
color: 'from-blue-600 to-blue-700',
|
|
||||||
bgColor: 'bg-blue-100',
|
|
||||||
iconColor: 'text-blue-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'إجمالي العقارات',
|
|
||||||
value: stats.totalProperties,
|
|
||||||
icon: Home,
|
|
||||||
color: 'from-emerald-600 to-emerald-700',
|
|
||||||
bgColor: 'bg-emerald-100',
|
|
||||||
iconColor: 'text-emerald-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'الحجوزات النشطة',
|
|
||||||
value: stats.activeBookings,
|
|
||||||
icon: Calendar,
|
|
||||||
color: 'from-purple-600 to-purple-700',
|
|
||||||
bgColor: 'bg-purple-100',
|
|
||||||
iconColor: 'text-purple-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'الإيرادات',
|
|
||||||
value: formatCurrency(stats.totalRevenue),
|
|
||||||
icon: DollarSign,
|
|
||||||
color: 'from-amber-600 to-amber-700',
|
|
||||||
bgColor: 'bg-amber-100',
|
|
||||||
iconColor: 'text-amber-600'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{cards.map((card, index) => {
|
|
||||||
const Icon = card.icon;
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={card.title}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
|
||||||
className={`bg-gradient-to-br ${card.color} text-white rounded-xl shadow-lg p-5`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className={`p-3 ${card.bgColor} bg-opacity-20 rounded-lg`}>
|
|
||||||
<Icon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-bold">{card.value}</div>
|
|
||||||
<div className="text-sm opacity-90">{card.title}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs opacity-75">
|
|
||||||
آخر تحديث: الآن
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.4 }}
|
|
||||||
className="bg-white border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-gray-600 mb-1">طلبات حجز معلقة</div>
|
|
||||||
<div className="text-2xl font-bold text-yellow-600">{stats.pendingRequests}</div>
|
|
||||||
<div className="text-xs text-gray-500">بحاجة لموافقة</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="bg-white border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-gray-600 mb-1">عقارات متاحة</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{stats.availableProperties}</div>
|
|
||||||
<div className="text-xs text-gray-500">جاهزة للإيجار</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.6 }}
|
|
||||||
className="bg-white border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-gray-600 mb-1">نسبة الإشغال</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{Math.round((stats.activeBookings / stats.totalProperties) * 100)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">من إجمالي العقارات</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,352 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
DollarSign,
|
|
||||||
Calendar,
|
|
||||||
User,
|
|
||||||
Home,
|
|
||||||
Download,
|
|
||||||
Filter,
|
|
||||||
Search,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Wallet,
|
|
||||||
Shield
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { formatCurrency } from '@/app/utils/calculations';
|
|
||||||
|
|
||||||
export default function LedgerBook({ userType = 'admin' }) {
|
|
||||||
const [transactions, setTransactions] = useState([]);
|
|
||||||
const [filteredTransactions, setFilteredTransactions] = useState([]);
|
|
||||||
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [summary, setSummary] = useState({
|
|
||||||
totalRevenue: 0,
|
|
||||||
pendingPayments: 0,
|
|
||||||
securityDeposits: 0,
|
|
||||||
commissionEarned: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTransactions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
filterTransactions();
|
|
||||||
calculateSummary();
|
|
||||||
}, [transactions, dateRange, searchTerm]);
|
|
||||||
|
|
||||||
const loadTransactions = async () => {
|
|
||||||
const mockTransactions = [
|
|
||||||
{
|
|
||||||
id: 'T001',
|
|
||||||
date: '2024-02-20',
|
|
||||||
type: 'rent_payment',
|
|
||||||
description: 'دفعة إيجار - فيلا في دمشق',
|
|
||||||
amount: 500000,
|
|
||||||
commission: 25000,
|
|
||||||
fromUser: 'أحمد محمد',
|
|
||||||
toUser: 'مالك العقار',
|
|
||||||
propertyId: 1,
|
|
||||||
propertyName: 'luxuryVillaDamascus',
|
|
||||||
status: 'completed',
|
|
||||||
paymentMethod: 'cash'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'T002',
|
|
||||||
date: '2024-02-19',
|
|
||||||
type: 'security_deposit',
|
|
||||||
description: 'سلفة ضمان - شقة في حلب',
|
|
||||||
amount: 250000,
|
|
||||||
commission: 0,
|
|
||||||
fromUser: 'سارة أحمد',
|
|
||||||
toUser: 'مالك العقار',
|
|
||||||
propertyId: 2,
|
|
||||||
propertyName: 'modernApartmentAleppo',
|
|
||||||
status: 'pending_refund',
|
|
||||||
paymentMethod: 'cash'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'T003',
|
|
||||||
date: '2024-02-18',
|
|
||||||
type: 'commission',
|
|
||||||
description: 'عمولة منصة - فيلا في درعا',
|
|
||||||
amount: 30000,
|
|
||||||
commission: 30000,
|
|
||||||
fromUser: 'محمد الحلبي',
|
|
||||||
toUser: 'المنصة',
|
|
||||||
propertyId: 5,
|
|
||||||
propertyName: 'villaDaraa',
|
|
||||||
status: 'completed',
|
|
||||||
paymentMethod: 'cash'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
setTransactions(mockTransactions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterTransactions = () => {
|
|
||||||
let filtered = [...transactions];
|
|
||||||
|
|
||||||
if (dateRange.start && dateRange.end) {
|
|
||||||
filtered = filtered.filter(t =>
|
|
||||||
t.date >= dateRange.start && t.date <= dateRange.end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
filtered = filtered.filter(t =>
|
|
||||||
t.description.includes(searchTerm) ||
|
|
||||||
t.fromUser.includes(searchTerm) ||
|
|
||||||
t.toUser.includes(searchTerm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredTransactions(filtered);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateSummary = () => {
|
|
||||||
const summary = filteredTransactions.reduce((acc, t) => {
|
|
||||||
if (t.type === 'rent_payment' || t.type === 'commission') {
|
|
||||||
acc.totalRevenue += t.amount;
|
|
||||||
}
|
|
||||||
if (t.type === 'security_deposit' && t.status === 'pending_refund') {
|
|
||||||
acc.securityDeposits += t.amount;
|
|
||||||
}
|
|
||||||
if (t.commission) {
|
|
||||||
acc.commissionEarned += t.commission;
|
|
||||||
}
|
|
||||||
if (t.status === 'pending') {
|
|
||||||
acc.pendingPayments += t.amount;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {
|
|
||||||
totalRevenue: 0,
|
|
||||||
pendingPayments: 0,
|
|
||||||
securityDeposits: 0,
|
|
||||||
commissionEarned: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
setSummary(summary);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTransactionIcon = (type) => {
|
|
||||||
switch(type) {
|
|
||||||
case 'rent_payment':
|
|
||||||
return <Home className="w-4 h-4 text-blue-600" />;
|
|
||||||
case 'security_deposit':
|
|
||||||
return <Shield className="w-4 h-4 text-green-600" />;
|
|
||||||
case 'commission':
|
|
||||||
return <TrendingUp className="w-4 h-4 text-amber-600" />;
|
|
||||||
default:
|
|
||||||
return <DollarSign className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-xl p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<Wallet className="w-8 h-8 opacity-80" />
|
|
||||||
<span className="text-sm opacity-90">إجمالي الإيرادات</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold">{formatCurrency(summary.totalRevenue)}</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="bg-gradient-to-br from-amber-600 to-amber-700 text-white rounded-xl p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<TrendingUp className="w-8 h-8 opacity-80" />
|
|
||||||
<span className="text-sm opacity-90">أرباح المنصة</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold">{formatCurrency(summary.commissionEarned)}</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="bg-gradient-to-br from-green-600 to-green-700 text-white rounded-xl p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<Shield className="w-8 h-8 opacity-80" />
|
|
||||||
<span className="text-sm opacity-90">سلف الضمان</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold">{formatCurrency(summary.securityDeposits)}</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
className="bg-gradient-to-br from-red-600 to-red-700 text-white rounded-xl p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<TrendingDown className="w-8 h-8 opacity-80" />
|
|
||||||
<span className="text-sm opacity-90">المدفوعات المعلقة</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold">{formatCurrency(summary.pendingPayments)}</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateRange.start}
|
|
||||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
|
||||||
className="px-3 py-2 border rounded-lg"
|
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={exportToExcel}
|
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-lg flex items-center gap-2 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
تصدير
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">التاريخ</th>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">الوصف</th>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">من</th>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">إلى</th>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">المبلغ</th>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">العمولة</th>
|
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">الحالة</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{filteredTransactions.map((transaction, index) => (
|
|
||||||
<motion.tr
|
|
||||||
key={transaction.id}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
className="hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-gray-400" />
|
|
||||||
{transaction.date}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getTransactionIcon(transaction.type)}
|
|
||||||
<span className="text-sm font-medium">{transaction.description}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-sm">{transaction.fromUser}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-sm">{transaction.toUser}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold">
|
|
||||||
{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 ${
|
|
||||||
transaction.status === 'completed' ? 'bg-green-100 text-green-800' :
|
|
||||||
transaction.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
'bg-blue-100 text-blue-800'
|
|
||||||
}`}>
|
|
||||||
{transaction.status === 'completed' ? 'مكتمل' :
|
|
||||||
transaction.status === 'pending' ? 'معلق' : 'بإنتظار الرد'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</motion.tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredTransactions.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Wallet className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500">لا توجد معاملات في هذه الفترة</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{userType === 'owner' && (
|
|
||||||
<div className="bg-blue-50 rounded-xl p-5">
|
|
||||||
<h3 className="font-bold mb-4 flex items-center gap-2">
|
|
||||||
<User className="w-5 h-5" />
|
|
||||||
أرصدة المستأجرين
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
Eye,
|
|
||||||
MapPin,
|
|
||||||
Bed,
|
|
||||||
Bath,
|
|
||||||
Square,
|
|
||||||
DollarSign,
|
|
||||||
Percent,
|
|
||||||
MoreVertical
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export default function PropertiesTable() {
|
|
||||||
const [properties, setProperties] = useState([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'luxuryVillaDamascus',
|
|
||||||
type: 'villa',
|
|
||||||
location: 'دمشق, المزة',
|
|
||||||
price: 500000,
|
|
||||||
commission: 5,
|
|
||||||
commissionType: 'من المالك',
|
|
||||||
bedrooms: 5,
|
|
||||||
bathrooms: 4,
|
|
||||||
area: 450,
|
|
||||||
status: 'available',
|
|
||||||
bookings: 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'modernApartmentAleppo',
|
|
||||||
type: 'apartment',
|
|
||||||
location: 'حلب, الشهباء',
|
|
||||||
price: 250000,
|
|
||||||
commission: 7,
|
|
||||||
commissionType: 'من المستأجر',
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 2,
|
|
||||||
area: 180,
|
|
||||||
status: 'booked',
|
|
||||||
bookings: 1
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
|
||||||
const styles = {
|
|
||||||
available: 'bg-green-100 text-green-800',
|
|
||||||
booked: 'bg-red-100 text-red-800',
|
|
||||||
maintenance: 'bg-yellow-100 text-yellow-800'
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
available: 'متاح',
|
|
||||||
booked: 'محجوز',
|
|
||||||
maintenance: 'صيانة'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
|
|
||||||
{labels[status]}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">العقار</th>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">الموقع</th>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">السعر/يوم</th>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">العمولة</th>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">المصدر</th>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">التفاصيل</th>
|
|
||||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">الحالة</th>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-gray-900">الإجراءات</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{properties.map((property, index) => (
|
|
||||||
<motion.tr
|
|
||||||
key={property.id}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
className="hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="font-medium">{property.title}</div>
|
|
||||||
<div className="text-xs text-gray-500">{property.type}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<MapPin className="w-3 h-3 text-gray-400" />
|
|
||||||
{property.location}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-bold text-blue-600">
|
|
||||||
{formatCurrency(property.price)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Percent className="w-3 h-3 text-amber-500" />
|
|
||||||
{property.commission}%
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm">{property.commissionType}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<Bed className="w-3 h-3" /> {property.bedrooms}
|
|
||||||
<Bath className="w-3 h-3 mr-2" /> {property.bathrooms}
|
|
||||||
<Square className="w-3 h-3 mr-2" /> {property.area}m²
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{getStatusBadge(property.status)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<button className="p-1 hover:bg-blue-100 rounded text-blue-600">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button className="p-1 hover:bg-amber-100 rounded text-amber-600">
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button className="p-1 hover:bg-red-100 rounded text-red-600">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button className="p-1 hover:bg-gray-100 rounded">
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</motion.tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{properties.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Home className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500">لا توجد عقارات مضافة بعد</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
User,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
Calendar,
|
|
||||||
Home,
|
|
||||||
DollarSign,
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
Eye
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export default function UsersList() {
|
|
||||||
const [users, setUsers] = useState([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'أحمد محمد',
|
|
||||||
email: 'ahmed@example.com',
|
|
||||||
phone: '0938123456',
|
|
||||||
identityType: 'syrian',
|
|
||||||
identityNumber: '123456789',
|
|
||||||
joinDate: '2024-01-15',
|
|
||||||
totalBookings: 3,
|
|
||||||
activeBookings: 1,
|
|
||||||
totalSpent: 1500000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'سارة أحمد',
|
|
||||||
email: 'sara@example.com',
|
|
||||||
phone: '0945123789',
|
|
||||||
identityType: 'passport',
|
|
||||||
identityNumber: 'AB123456',
|
|
||||||
joinDate: '2024-02-10',
|
|
||||||
totalBookings: 2,
|
|
||||||
activeBookings: 0,
|
|
||||||
totalSpent: 500000
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [selectedUser, setSelectedUser] = useState(null);
|
|
||||||
|
|
||||||
const filteredUsers = users.filter(user =>
|
|
||||||
user.name.includes(searchTerm) ||
|
|
||||||
user.email.includes(searchTerm) ||
|
|
||||||
user.phone.includes(searchTerm)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex 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" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className="px-4 py-2 border rounded-lg flex items-center gap-2 hover:bg-gray-50">
|
|
||||||
<Filter className="w-4 h-4" />
|
|
||||||
تصفية
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filteredUsers.map((user, index) => (
|
|
||||||
<motion.div
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold">{user.name}</h3>
|
|
||||||
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-600">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Mail className="w-3 h-3" />
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Phone className="w-3 h-3" />
|
|
||||||
{user.phone}
|
|
||||||
</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="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-xs text-gray-500">حجوزات نشطة</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-bold text-amber-600">
|
|
||||||
{user.totalSpent.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">إجمالي المنصرف</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
عرض التفاصيل
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="bg-white rounded-xl w-full max-w-2xl p-6"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-xl font-bold">تفاصيل المستخدم</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedUser(null)}
|
|
||||||
className="p-1 hover:bg-gray-100 rounded"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500">الاسم</label>
|
|
||||||
<div className="font-medium">{selectedUser.name}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500">البريد الإلكتروني</label>
|
|
||||||
<div className="font-medium">{selectedUser.email}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500">رقم الهاتف</label>
|
|
||||||
<div className="font-medium">{selectedUser.phone}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500">نوع الهوية</label>
|
|
||||||
<div className="font-medium">
|
|
||||||
{selectedUser.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500">رقم الهوية</label>
|
|
||||||
<div className="font-medium">{selectedUser.identityNumber}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-gray-500">تاريخ التسجيل</label>
|
|
||||||
<div className="font-medium">{selectedUser.joinDate}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="font-bold mb-3">سجل الحجوزات</h3>
|
|
||||||
<p className="text-gray-500 text-center py-4">
|
|
||||||
لا توجد حجوزات سابقة
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
28
app/components/home/ArrowForScroll.js
Normal file
28
app/components/home/ArrowForScroll.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
export default function ArrowForScroll() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 cursor-pointer"
|
||||||
|
animate={{
|
||||||
|
y: [0, 10, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
window.scrollTo({
|
||||||
|
top: window.innerHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-amber-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/components/home/FeatureSection.js
Normal file
21
app/components/home/FeatureSection.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Lock, ShieldCheck, Zap } from "lucide-react";
|
||||||
|
import FeaturesOurCompany from "./FeaturesOurCompany";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
export default function FeatureSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: false }} transition={{ duration: 0.6 }} className="text-center mt-5">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4 tracking-tight">{t("whyChooseUsTitle")}</h2>
|
||||||
|
<p className="text-gray-600 max-w-2xl mx-auto text-lg">{t("whyChooseUsSubtitle")}</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-5 p-5">
|
||||||
|
<FeaturesOurCompany icon={<ShieldCheck className="w-6 h-6 text-amber-600" />} title={t("feature1Title")} discripction={t("feature1Description")} />
|
||||||
|
<FeaturesOurCompany icon={<Lock className="w-6 h-6 text-blue-600" />} title={t("feature2Title")} discripction={t("feature2Description")} />
|
||||||
|
<FeaturesOurCompany icon={<Zap className="w-6 h-6 text-green-600" />} title={t("feature3Title")} discripction={t("feature3Description")} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/components/home/FeaturesOurCompany.js
Normal file
21
app/components/home/FeaturesOurCompany.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
export default function FeaturesOurCompany({ icon, title, discripction }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: false }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center group-hover:bg-amber-200 transition-colors duration-300">{icon}</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed">{discripction}</p>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/components/home/FilterSelect.js
Normal file
32
app/components/home/FilterSelect.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export default function FilterSelect({ onChange, icon, value, option, label }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-amber-50 mb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{icon??""}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,192 +1,117 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
import { useState } from 'react';
|
import { motion } from "framer-motion";
|
||||||
import { motion } from 'framer-motion';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Search, MapPin, Home, DollarSign } from "lucide-react";
|
||||||
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
|
import { useFilterOptions } from "@/app/hooks/UseFilterOfHeroSearch";
|
||||||
|
import ShowLoginDialog from "./showLoginDialog";
|
||||||
|
import FilterSelect from "./FilterSelect";
|
||||||
export default function HeroSearch({ onSearch }) {
|
export default function HeroSearch({ onSearch }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState('rent');
|
const { cities, identityTypes, ownerSources, priceRanges, propertyTypes, rentPeriods } = useFilterOptions();
|
||||||
|
const [activeTab, setActiveTab] = useState("buy");
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
city: '',
|
city: "all",
|
||||||
propertyType: '',
|
propertyType: "all",
|
||||||
priceRange: '',
|
priceRange: "all",
|
||||||
identityType: 'syrian'
|
identityType: "syrian",
|
||||||
|
ownerSource: "all",
|
||||||
|
rentPeriod: "all",
|
||||||
|
availableToday: false,
|
||||||
});
|
});
|
||||||
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
const cities = [
|
const handleTabClick = (tab) => {
|
||||||
{ id: 'all', label: 'جميع المدن' },
|
setActiveTab(tab);
|
||||||
{ id: 'دمشق', label: 'دمشق' },
|
};
|
||||||
{ id: 'حلب', label: 'حلب' },
|
|
||||||
{ id: 'حمص', label: 'حمص' },
|
|
||||||
{ id: 'اللاذقية', label: 'اللاذقية' },
|
|
||||||
{ id: 'درعا', label: 'درعا' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const propertyTypes = [
|
|
||||||
{ id: 'all', label: 'الكل' },
|
|
||||||
{ id: 'apartment', label: 'شقة' },
|
|
||||||
{ id: 'villa', label: 'فيلا' },
|
|
||||||
{ id: 'house', label: 'بيت' },
|
|
||||||
{ id: 'studio', label: 'استوديو' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const priceRanges = [
|
|
||||||
{ id: 'all', label: 'جميع الأسعار' },
|
|
||||||
{ id: '0-500', label: 'أقل من 50$' },
|
|
||||||
{ id: '500-1000', label: '50$ - 100$' },
|
|
||||||
{ id: '1000-2000', label: '100$ - 200$' },
|
|
||||||
{ id: '2000-3000', label: '200$ - 300$' },
|
|
||||||
{ id: '3000+', label: 'أكثر من 300$' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const identityTypes = [
|
|
||||||
{ id: 'syrian', label: 'هوية سورية' },
|
|
||||||
{ id: 'passport', label: 'جواز سفر' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
onSearch({
|
onSearch({
|
||||||
...filters,
|
...filters,
|
||||||
propertyType: filters.propertyType || 'all',
|
mode: activeTab,
|
||||||
city: filters.city || 'all',
|
city: filters.city || "all",
|
||||||
priceRange: filters.priceRange || 'all'
|
propertyType: filters.propertyType || "all",
|
||||||
|
priceRange: filters.priceRange || "all",
|
||||||
|
ownerSource: filters.ownerSource || "all",
|
||||||
|
rentPeriod: filters.rentPeriod || "all",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<>
|
||||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
transition={{ duration: 0.8, delay: 0.5 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
transition={{ duration: 0.8, delay: 0.5 }}
|
||||||
<div className="flex flex-wrap gap-2 mb-8">
|
>
|
||||||
{['rent', 'buy', 'sell'].map((tab) => (
|
<div className="flex flex-wrap gap-2 mb-8">
|
||||||
<motion.button
|
{["rent", "buy", "sell"].map((tab) => {
|
||||||
key={tab}
|
const isActive = activeTab === tab;
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
return (
|
||||||
activeTab === tab
|
<button
|
||||||
? 'bg-amber-500 text-white'
|
key={tab}
|
||||||
: 'bg-white/20 text-white hover:bg-white/30'
|
onClick={() => handleTabClick(tab)}
|
||||||
}`}
|
className={`relative px-5 py-2.5 rounded-lg font-medium text-sm transition-colors duration-200 outline-none select-none ${isActive ? "text-white" : "text-amber-50 hover:text-white"}`}
|
||||||
whileHover={{ scale: 1.05 }}
|
>
|
||||||
whileTap={{ scale: 0.95 }}
|
{isActive && <motion.div layoutId="activeTabBackground" className="absolute inset-0 bg-amber-500 rounded-lg shadow-md" transition={{ type: "spring", stiffness: 500, damping: 35 }} />}
|
||||||
>
|
<span className="relative z-10">{t(`${tab}Tab`)}</span>
|
||||||
{t(`${tab}Tab`)}
|
</button>
|
||||||
</motion.button>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white mb-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
{t("cityStreetLabel")}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.city}
|
|
||||||
onChange={(e) => setFilters({...filters, city: 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cities.map(city => (
|
|
||||||
<option key={city.id} value={city.id}>{city.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* city */}
|
||||||
<label className="block text-sm font-medium text-white mb-2">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="flex items-center gap-1">
|
<FilterSelect icon={<MapPin className="w-4 h-4" />} value={filters.city} onChange={(e) => setFilters({ ...filters, city: e.target.value })} option={cities} label={t(t("cityStreetLabel"))} />
|
||||||
<Home className="w-4 h-4" />
|
{activeTab === "rent" && (
|
||||||
{t("rentTypeLabel")}
|
<FilterSelect
|
||||||
</div>
|
icon={<Home className="w-4 h-4" />}
|
||||||
</label>
|
value={filters.propertyType}
|
||||||
<select
|
onChange={(e) => setFilters({ ...filters, city: e.target.value })}
|
||||||
value={filters.propertyType}
|
option={propertyTypes}
|
||||||
onChange={(e) => setFilters({...filters, propertyType: e.target.value})}
|
label={t("rentTypeLabel")}
|
||||||
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")`,
|
{/* price */}
|
||||||
backgroundRepeat: 'no-repeat',
|
<FilterSelect
|
||||||
backgroundPosition: 'left 1rem center',
|
icon={<DollarSign className="w-4 h-4" />}
|
||||||
backgroundSize: '1rem',
|
|
||||||
paddingLeft: '2.5rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{propertyTypes.map(type => (
|
|
||||||
<option key={type.id} value={type.id}>{type.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white mb-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
{t("priceLabel")}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.priceRange}
|
value={filters.priceRange}
|
||||||
onChange={(e) => setFilters({...filters, priceRange: e.target.value})}
|
onChange={(e) => setFilters({ ...filters, priceRange: 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"
|
option={priceRanges}
|
||||||
style={{
|
label={t("priceLabel")}
|
||||||
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',
|
{/* type */}
|
||||||
backgroundPosition: 'left 1rem center',
|
<FilterSelect value={filters.identityType} onChange={(e) => setFilters({ ...filters, identityType: e.target.value })} option={identityTypes} label={t("identityTypeLabel")} />
|
||||||
backgroundSize: '1rem',
|
|
||||||
paddingLeft: '2.5rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{priceRanges.map(range => (
|
|
||||||
<option key={range.id} value={range.id}>{range.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||||
<label className="block text-sm font-medium text-white mb-2">
|
{/* owener */}
|
||||||
<div className="flex items-center gap-1">
|
<FilterSelect value={filters.ownerSource} onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })} option={ownerSources} label={t("ownerSourceLabel")} />
|
||||||
نوع الوثيقة
|
{activeTab === "rent" && <FilterSelect value={filters.rentPeriod} onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })} option={rentPeriods} label={t("rentTypeLabel")} />}
|
||||||
</div>
|
<div className={`${activeTab === "rent" ? "md:col-span-2" : "md:col-span-3"} flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5`}>
|
||||||
</label>
|
{/* checkbox */}
|
||||||
<select
|
<label className="mt-4 flex items-center gap-3 text-amber-50 text-sm">
|
||||||
value={filters.identityType}
|
<input
|
||||||
onChange={(e) => setFilters({...filters, identityType: e.target.value})}
|
type="checkbox"
|
||||||
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"
|
checked={filters.availableToday}
|
||||||
style={{
|
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||||
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")`,
|
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||||
backgroundRepeat: 'no-repeat',
|
/>
|
||||||
backgroundPosition: 'left 1rem center',
|
<span className="font-medium">{t("availableToday")}</span>
|
||||||
backgroundSize: '1rem',
|
</label>
|
||||||
paddingLeft: '2.5rem'
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{identityTypes.map(type => (
|
|
||||||
<option key={type.id} value={type.id}>{type.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-6">
|
||||||
<div className="mt-6">
|
<motion.button
|
||||||
<motion.button
|
onClick={handleSearch}
|
||||||
onClick={handleSearch}
|
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-4 px-6 rounded-xl transition-all duration-300 flex items-center justify-center text-base gap-3 shadow-lg hover:shadow-xl"
|
||||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-4 px-6 rounded-xl transition-all duration-300 flex items-center justify-center text-base gap-3 shadow-lg hover:shadow-xl"
|
whileHover={{ scale: 1.02 }}
|
||||||
whileHover={{ scale: 1.02 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
>
|
||||||
>
|
<Search className="w-5 h-5" />
|
||||||
<Search className="w-5 h-5" />
|
{t("searchButton")}
|
||||||
{t("searchButton")}
|
</motion.button>
|
||||||
</motion.button>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
{showLoginDialog && !isAuthenticated && <ShowLoginDialog setShowLoginDialog={setShowLoginDialog} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/components/home/HeroSection.js
Normal file
54
app/components/home/HeroSection.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
export default function HeroSection() {
|
||||||
|
const {t} =useTranslation()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-12"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.2 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-amber-50 mb-6 leading-tight tracking-tight"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("heroTitleLine1")}
|
||||||
|
<br />
|
||||||
|
<motion.span
|
||||||
|
className="text-amber-300"
|
||||||
|
animate={{
|
||||||
|
y: [0, -10, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("heroTitleLine2")}
|
||||||
|
</motion.span>
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className="text-base sm:text-lg text-amber-50 max-w-2xl mx-auto leading-relaxed"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("heroSubtitle")}
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/components/home/HomeClient.jsx
Normal file
71
app/components/home/HomeClient.jsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
import HeroSearch from "./HeroSearch";
|
||||||
|
import ShowIfOwner from "./ShowIfOwner";
|
||||||
|
import MapSection from "./MapSection";
|
||||||
|
import useApplyFilters from "../../hooks/useApplyFilters";
|
||||||
|
import HeroSection from "./HeroSection";
|
||||||
|
import FeatureSection from "./FeatureSection";
|
||||||
|
import ArrowForScroll from "./ArrowForScroll";
|
||||||
|
import useAuth from "@/app/hooks/useAuth";
|
||||||
|
|
||||||
|
export default function HomeClient() {
|
||||||
|
const mapSectionRef = useRef(null);
|
||||||
|
const [showMap, setShowMap] = useState(false);
|
||||||
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
|
const { filteredProperties, applyFilters, resetFilters } = useApplyFilters();
|
||||||
|
const { name, isOwner, isAuthenticated, role } = useAuth();
|
||||||
|
const handleSearch = (filters) => {
|
||||||
|
applyFilters(filters);
|
||||||
|
if (!showMap) {
|
||||||
|
setShowMap(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToMap();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
scrollToMap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scrollToMap = () => {
|
||||||
|
if (mapSectionRef.current) {
|
||||||
|
setIsScrolling(true);
|
||||||
|
mapSectionRef.current.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
setTimeout(() => setIsScrolling(false), 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resetSearch = () => {
|
||||||
|
setShowMap(false);
|
||||||
|
resetFilters();
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||||
|
<div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<HeroSection />
|
||||||
|
{!isOwner && <HeroSearch onSearch={handleSearch} />}
|
||||||
|
{isOwner && <ShowIfOwner name={name} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!showMap && !isOwner && <ArrowForScroll />}
|
||||||
|
</section>
|
||||||
|
{!isOwner && (
|
||||||
|
<>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{showMap && <MapSection mapSectionRef={mapSectionRef} isScrolling={isScrolling} resetSearch={resetSearch} filteredProperties={filteredProperties} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FeatureSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/components/home/MapSection.js
Normal file
80
app/components/home/MapSection.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import PropertyMapWithMarkers from "../PropertyMapWithMarkers";
|
||||||
|
export default function MapSection({ mapSectionRef, isScrolling, resetSearch, filteredProperties }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.section
|
||||||
|
ref={mapSectionRef}
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -50 }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
damping: 20,
|
||||||
|
stiffness: 100,
|
||||||
|
duration: 0.6,
|
||||||
|
}}
|
||||||
|
className="py-12 bg-gray-50 relative"
|
||||||
|
>
|
||||||
|
{isScrolling && <motion.div className="absolute top-0 left-0 right-0 h-1 bg-amber-500 z-10" initial={{ scaleX: 0 }} animate={{ scaleX: 1 }} transition={{ duration: 1, ease: "easeInOut" }} />}
|
||||||
|
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="text-center mb-8">
|
||||||
|
<div className="flex items-center justify-center gap-4 mb-2">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900">{filteredProperties.length > 0 ? t("searchResults") : t("no-properties")}</h2>
|
||||||
|
<motion.button
|
||||||
|
onClick={resetSearch}
|
||||||
|
className="px-4 py-2 bg-white border border-gray-300 rounded-full text-sm font-medium text-gray-700 hover:bg-gray-50 shadow-sm flex items-center gap-2"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
{t("newSearch")}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredProperties.length > 0 ? (
|
||||||
|
<p className="text-gray-600">{t("foundPropertiesCount", { count: filteredProperties.length })}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600">{t("noPropertiesMatchFilters")}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-200"
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3, type: "spring" }}
|
||||||
|
>
|
||||||
|
{filteredProperties.length > 0 ? (
|
||||||
|
<PropertyMapWithMarkers
|
||||||
|
properties={filteredProperties.map((p) => ({
|
||||||
|
...p,
|
||||||
|
lat: p.location.lat,
|
||||||
|
lng: p.location.lng,
|
||||||
|
address: p.location.address,
|
||||||
|
}))}
|
||||||
|
onPropertyClick={() => {}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-[400px] flex flex-col items-center justify-center bg-gray-50">
|
||||||
|
<div className="w-24 h-24 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-700 mb-2">{t("no-properties")}</h3>
|
||||||
|
<p className="text-gray-500">{t("try-changing-search-criteria")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/components/home/ShowIfOwner.js
Normal file
26
app/components/home/ShowIfOwner.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Building } from "lucide-react";
|
||||||
|
export default function ShowIfOwner({ name }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 text-center border border-white/20"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-amber-50 mb-2">
|
||||||
|
{t("ownerGreeting")} {name ?? " notFound "}!
|
||||||
|
</h2>
|
||||||
|
<p className="text-amber-50/80 mb-4">{t("ownerDescription")}</p>
|
||||||
|
<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" />
|
||||||
|
{t("manageMyProperties")}
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
app/components/home/showLoginDialog.js
Normal file
38
app/components/home/showLoginDialog.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
export default function ShowLoginDialog({setShowLoginDialog}) {
|
||||||
|
const {t}=useTranslation()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 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">{t("loginRequired")}</h3>
|
||||||
|
<p className="text-sm text-gray-600">{t("loginRequiredDesc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl bg-gray-50 p-4">
|
||||||
|
<p className="text-sm text-gray-700">{t("loginPrompt")}</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"
|
||||||
|
>
|
||||||
|
{t("close")}
|
||||||
|
</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">
|
||||||
|
{t("login")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/components/property/AddPropertyMap.js
Normal file
85
app/components/property/AddPropertyMap.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function AddPropertyMap({ mapCenter, mapZoom, selectedLocation, onMapClick, onMarkerDragEnd }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const markerRef = useRef(null);
|
||||||
|
const LRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function init() {
|
||||||
|
const L = await import('leaflet');
|
||||||
|
LRef.current = L;
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
const map = L.map(containerRef.current, {
|
||||||
|
center: mapCenter,
|
||||||
|
zoom: mapZoom,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
map.on('click', (e) => {
|
||||||
|
const pos = [e.latlng.lat, e.latlng.lng];
|
||||||
|
placeMarker(pos);
|
||||||
|
map.setView(pos, mapZoom);
|
||||||
|
onMapClick(pos);
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
setTimeout(() => map.invalidateSize(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function placeMarker(pos) {
|
||||||
|
const L = LRef.current;
|
||||||
|
if (!L || !mapRef.current) return;
|
||||||
|
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng(pos);
|
||||||
|
} else {
|
||||||
|
markerRef.current = L.marker(pos, { draggable: true }).addTo(mapRef.current);
|
||||||
|
markerRef.current.on('dragend', (ev) => {
|
||||||
|
const p = ev.target.getLatLng();
|
||||||
|
onMarkerDragEnd(p.lat, p.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
map.setView(mapCenter, mapZoom);
|
||||||
|
}, [mapCenter, mapZoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedLocation || !mapRef.current) return;
|
||||||
|
const pos = [selectedLocation.lat, selectedLocation.lng];
|
||||||
|
mapRef.current.setView(pos, mapZoom);
|
||||||
|
placeMarker(pos);
|
||||||
|
}, [selectedLocation]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="w-full h-full z-0" />;
|
||||||
|
}
|
||||||
246
app/components/property/DateSelectionCalendar.js
Normal file
246
app/components/property/DateSelectionCalendar.js
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
const MONTH_KEYS = [
|
||||||
|
"month.january",
|
||||||
|
"month.february",
|
||||||
|
"month.march",
|
||||||
|
"month.april",
|
||||||
|
"month.may",
|
||||||
|
"month.june",
|
||||||
|
"month.july",
|
||||||
|
"month.august",
|
||||||
|
"month.september",
|
||||||
|
"month.october",
|
||||||
|
"month.november",
|
||||||
|
"month.december",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_KEYS = [
|
||||||
|
"dayAbbr.sun",
|
||||||
|
"dayAbbr.mon",
|
||||||
|
"dayAbbr.tue",
|
||||||
|
"dayAbbr.wed",
|
||||||
|
"dayAbbr.thu",
|
||||||
|
"dayAbbr.fri",
|
||||||
|
"dayAbbr.sat",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DateSelectionCalendar({
|
||||||
|
mode,
|
||||||
|
availableDates,
|
||||||
|
selectedStart,
|
||||||
|
selectedEnd,
|
||||||
|
onSelectStart,
|
||||||
|
onSelectEnd,
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [calendarMonth, setCalendarMonth] = useState(() => new Date().getMonth());
|
||||||
|
const [calendarYear, setCalendarYear] = useState(() => new Date().getFullYear());
|
||||||
|
const [rangeError, setRangeError] = useState(null);
|
||||||
|
|
||||||
|
const isPastDate = useCallback((dateStr) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return new Date(dateStr) < today;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDateAvailable = useCallback(
|
||||||
|
(dateStr) => (availableDates ? availableDates.has(dateStr) : true),
|
||||||
|
[availableDates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMonthFullyAvailable = useCallback(
|
||||||
|
(year, monthIdx) => {
|
||||||
|
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const dateStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
if (isPastDate(dateStr) || !isDateAvailable(dateStr)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[isPastDate, isDateAvailable]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPastMonth = useCallback((year, monthIdx) => {
|
||||||
|
const now = new Date();
|
||||||
|
return (
|
||||||
|
year < now.getFullYear() ||
|
||||||
|
(year === now.getFullYear() && monthIdx < now.getMonth())
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDayClick = (dateStr) => {
|
||||||
|
setRangeError(null);
|
||||||
|
if (!selectedStart || selectedEnd) {
|
||||||
|
onSelectStart(dateStr);
|
||||||
|
onSelectEnd(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Date(dateStr) <= new Date(selectedStart)) {
|
||||||
|
onSelectStart(dateStr);
|
||||||
|
onSelectEnd(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = new Date(selectedStart);
|
||||||
|
const end = new Date(dateStr);
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const ds = d.toISOString().split("T")[0];
|
||||||
|
if (!isDateAvailable(ds)) {
|
||||||
|
setRangeError(t("unavailableInRange", { defaultValue: "توجد أيام غير متاحة ضمن الفترة المحددة" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSelectEnd(dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMonthClick = (year, monthIdx) => {
|
||||||
|
setRangeError(null);
|
||||||
|
const monthStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}`;
|
||||||
|
const firstDay = `${monthStr}-01`;
|
||||||
|
if (!selectedStart || selectedEnd || monthStr <= (selectedStart || "").substring(0, 7)) {
|
||||||
|
onSelectStart(firstDay);
|
||||||
|
onSelectEnd(null);
|
||||||
|
} else {
|
||||||
|
const lastDay = new Date(year, monthIdx + 1, 0).getDate();
|
||||||
|
onSelectEnd(`${monthStr}-${String(lastDay).padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateMonth = (delta) => {
|
||||||
|
let month = calendarMonth + delta;
|
||||||
|
let year = calendarYear;
|
||||||
|
if (month < 0) {
|
||||||
|
month = 11;
|
||||||
|
year--;
|
||||||
|
}
|
||||||
|
if (month > 11) {
|
||||||
|
month = 0;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
setCalendarMonth(month);
|
||||||
|
setCalendarYear(year);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEntryStep = !selectedStart || !!selectedEnd;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${isEntryStep ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-500"}`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${isEntryStep ? "bg-amber-500" : "bg-gray-400"}`} />
|
||||||
|
{t("selectStartDate")}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-xs">←</div>
|
||||||
|
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${!isEntryStep ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-500"}`}>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${!isEntryStep ? "bg-amber-500" : "bg-gray-400"}`} />
|
||||||
|
{t("selectEndDate")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "daily" ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<button onClick={() => navigateMonth(-1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{t(MONTH_KEYS[calendarMonth])} {calendarYear}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => navigateMonth(1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<ChevronLeft className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 mb-1">
|
||||||
|
{DAY_KEYS.map((dk, i) => (
|
||||||
|
<div key={i} className="text-center text-[10px] text-gray-400 font-medium py-1">
|
||||||
|
{t(dk)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const firstDay = new Date(calendarYear, calendarMonth, 1).getDay();
|
||||||
|
const daysInMonth = new Date(calendarYear, calendarMonth + 1, 0).getDate();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
cells.push(<div key={`e-${i}`} />);
|
||||||
|
}
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const dateStr = `${calendarYear}-${String(calendarMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
const past = isPastDate(dateStr);
|
||||||
|
const available = isDateAvailable(dateStr);
|
||||||
|
const isSelStart = dateStr === selectedStart;
|
||||||
|
const isSelEnd = dateStr === selectedEnd;
|
||||||
|
const inRange =
|
||||||
|
selectedStart &&
|
||||||
|
selectedEnd &&
|
||||||
|
new Date(dateStr) > new Date(selectedStart) &&
|
||||||
|
new Date(dateStr) < new Date(selectedEnd);
|
||||||
|
const disabled = past || !available;
|
||||||
|
cells.push(
|
||||||
|
<button
|
||||||
|
key={dateStr}
|
||||||
|
onClick={() => !disabled && handleDayClick(dateStr)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`text-center py-1.5 text-xs rounded-lg transition-all ${disabled ? "text-gray-300 cursor-not-allowed" : ""} ${isSelStart || isSelEnd ? "bg-amber-500 text-white font-bold shadow-sm" : ""} ${inRange ? "bg-amber-100 text-amber-800" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange && available ? "hover:bg-amber-50 text-gray-700" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange && !available ? "text-red-300" : ""}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="grid grid-cols-7 gap-0.5">{cells}</div>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<button onClick={() => setCalendarYear((prev) => prev - 1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-bold text-gray-900">{calendarYear}</span>
|
||||||
|
<button onClick={() => setCalendarYear((prev) => prev + 1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<ChevronLeft className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{MONTH_KEYS.map((mk, idx) => {
|
||||||
|
const monthStr = `${calendarYear}-${String(idx + 1).padStart(2, "0")}`;
|
||||||
|
const isSelStart = selectedStart && selectedStart.startsWith(monthStr);
|
||||||
|
const isSelEnd = selectedEnd && selectedEnd.startsWith(monthStr);
|
||||||
|
const inRange =
|
||||||
|
selectedStart &&
|
||||||
|
selectedEnd &&
|
||||||
|
monthStr > selectedStart.substring(0, 7) &&
|
||||||
|
monthStr < selectedEnd.substring(0, 7);
|
||||||
|
const disabled = isPastMonth(calendarYear, idx) || !isMonthFullyAvailable(calendarYear, idx);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => !disabled && handleMonthClick(calendarYear, idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`p-2.5 rounded-xl text-center text-xs font-medium transition-all border ${disabled ? "text-gray-300 border-gray-100 bg-gray-50 cursor-not-allowed" : ""} ${isSelStart || isSelEnd ? "bg-amber-500 text-white border-amber-500 shadow-sm" : ""} ${!disabled && inRange ? "bg-amber-100 text-amber-800 border-amber-200" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange ? "bg-white text-gray-700 border-gray-200 hover:border-amber-300" : ""}`}
|
||||||
|
>
|
||||||
|
{t(mk)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rangeError && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-2.5 rounded-xl text-xs mt-3">
|
||||||
|
{rangeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
app/components/ratings/CustomerRatingForm.js
Normal file
94
app/components/ratings/CustomerRatingForm.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import StarRating from './StarRating';
|
||||||
|
import { addCustomerRating } 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">{/* required text handled in parent */}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function CustomerRatingForm({ reservationId, onSuccess, onCancel }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [furnitureIntegrityRating, setFurnitureIntegrityRating] = useState(0);
|
||||||
|
const [termsComplianceRating, setTermsComplianceRating] = useState(0);
|
||||||
|
const [renterBehaviorRating, setRenterBehaviorRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (furnitureIntegrityRating === 0) return t('ratings.furnitureIntegrity');
|
||||||
|
if (termsComplianceRating === 0) return t('ratings.termsCompliance');
|
||||||
|
if (renterBehaviorRating === 0) return t('ratings.renterBehavior');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const missing = validate();
|
||||||
|
if (missing) {
|
||||||
|
toast.error(t('ratings.pleaseRate') + ': ' + missing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await addCustomerRating({
|
||||||
|
reservationId,
|
||||||
|
furnitureIntegrityRating,
|
||||||
|
termsComplianceRating,
|
||||||
|
renterBehaviorRating,
|
||||||
|
comment: comment.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success(t('ratings.customerRatingSubmitted'));
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('ratings.submitError'));
|
||||||
|
} 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">{t('ratings.customerRating')}</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={t('ratings.furnitureIntegrity')} value={furnitureIntegrityRating} onChange={setFurnitureIntegrityRating} />
|
||||||
|
<RatingField label={t('ratings.termsCompliance')} value={termsComplianceRating} onChange={setTermsComplianceRating} />
|
||||||
|
<RatingField label={t('ratings.renterBehavior')} value={renterBehaviorRating} onChange={setRenterBehaviorRating} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">{t('ratings.commentLabel')}</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={t('ratings.commentPlaceholder')}
|
||||||
|
/>
|
||||||
|
</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 ? t('ratings.sending') : t('ratings.submitRating')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</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 && average > 0 && (
|
||||||
|
<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;
|
||||||
127
app/contexts/FavoritesContext.js
Normal file
127
app/contexts/FavoritesContext.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
'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 = {};
|
||||||
|
if (typeof info.detailsJSON === 'object' && info.detailsJSON) {
|
||||||
|
details = info.detailsJSON;
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/contexts/ThemeContext.js
Normal file
32
app/contexts/ThemeContext.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
const [theme, setTheme] = useState("dark");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored === "dark" || (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||||
|
setTheme("dark");
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = theme === "light" ? "dark" : "light";
|
||||||
|
setTheme(next);
|
||||||
|
document.documentElement.classList.toggle("dark", next === "dark");
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
38
app/enums/BookingStatus.js
Normal file
38
app/enums/BookingStatus.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
const BookingStatus = Object.freeze({
|
||||||
|
PENDING: 'pending',
|
||||||
|
ownerConfirmed: 'ownerConfirmed',
|
||||||
|
depositPaid: 'depositPaid',
|
||||||
|
depositConfirmed: 'depositConfirmed',
|
||||||
|
completed: 'completed',
|
||||||
|
cancelled: 'cancelled',
|
||||||
|
});
|
||||||
|
|
||||||
|
const BookingStatusLabels = Object.freeze({
|
||||||
|
[BookingStatus.PENDING]: 'قيد الانتظار',
|
||||||
|
[BookingStatus.ownerConfirmed]: 'مؤكد من المالك',
|
||||||
|
[BookingStatus.depositPaid]: 'تم دفع السلفة',
|
||||||
|
[BookingStatus.depositConfirmed]: 'تم تأكيد الدفع',
|
||||||
|
[BookingStatus.completed]: 'منتهي',
|
||||||
|
[BookingStatus.cancelled]: 'ملغي',
|
||||||
|
});
|
||||||
|
|
||||||
|
const BookingStatusColors = Object.freeze({
|
||||||
|
[BookingStatus.PENDING]: 'yellow',
|
||||||
|
[BookingStatus.ownerConfirmed]: 'blue',
|
||||||
|
[BookingStatus.depositPaid]: 'orange',
|
||||||
|
[BookingStatus.depositConfirmed]: 'green',
|
||||||
|
[BookingStatus.completed]: 'teal',
|
||||||
|
[BookingStatus.cancelled]: 'red',
|
||||||
|
});
|
||||||
|
|
||||||
|
const BookingStatusTranslationKeys = Object.freeze({
|
||||||
|
[BookingStatus.PENDING]: 'bookingStatus.pending',
|
||||||
|
[BookingStatus.ownerConfirmed]: 'bookingStatus.ownerConfirmed',
|
||||||
|
[BookingStatus.depositPaid]: 'bookingStatus.depositPaid',
|
||||||
|
[BookingStatus.depositConfirmed]: 'bookingStatus.depositConfirmed',
|
||||||
|
[BookingStatus.completed]: 'bookingStatus.completed',
|
||||||
|
[BookingStatus.cancelled]: 'bookingStatus.cancelled',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { BookingStatus, BookingStatusLabels, BookingStatusColors, BookingStatusTranslationKeys };
|
||||||
72
app/enums/BuildingType.js
Normal file
72
app/enums/BuildingType.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* BuildingType Enum
|
||||||
|
* Backend values are numeric
|
||||||
|
* Used in: PropertyInformation.buildingType
|
||||||
|
*/
|
||||||
|
const BuildingType = Object.freeze({
|
||||||
|
APARTMENT: 0,
|
||||||
|
VILLA: 1,
|
||||||
|
HOUSE: 2,
|
||||||
|
SWEET: 3,
|
||||||
|
ROOM: 4,
|
||||||
|
STUDIO: 5,
|
||||||
|
OFFICE: 6,
|
||||||
|
FARMS: 7,
|
||||||
|
SHOP: 8,
|
||||||
|
WAREHOUSE: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const BuildingTypeLabels = Object.freeze({
|
||||||
|
[BuildingType.APARTMENT]: 'شقة',
|
||||||
|
[BuildingType.VILLA]: 'فيلا',
|
||||||
|
[BuildingType.HOUSE]: 'بيت',
|
||||||
|
[BuildingType.SWEET]: 'سويت',
|
||||||
|
[BuildingType.ROOM]: 'غرفة',
|
||||||
|
[BuildingType.STUDIO]: 'استوديو',
|
||||||
|
[BuildingType.OFFICE]: 'مكتب',
|
||||||
|
[BuildingType.FARMS]: 'مزرعة',
|
||||||
|
[BuildingType.SHOP]: 'متجر',
|
||||||
|
[BuildingType.WAREHOUSE]: 'مستودع',
|
||||||
|
});
|
||||||
|
|
||||||
|
const BuildingTypeKeys = Object.freeze({
|
||||||
|
[BuildingType.APARTMENT]: 'apartment',
|
||||||
|
[BuildingType.VILLA]: 'villa',
|
||||||
|
[BuildingType.HOUSE]: 'house',
|
||||||
|
[BuildingType.SWEET]: 'sweet',
|
||||||
|
[BuildingType.ROOM]: 'room',
|
||||||
|
[BuildingType.STUDIO]: 'studio',
|
||||||
|
[BuildingType.OFFICE]: 'office',
|
||||||
|
[BuildingType.FARMS]: 'farms',
|
||||||
|
[BuildingType.SHOP]: 'shop',
|
||||||
|
[BuildingType.WAREHOUSE]: 'warehouse',
|
||||||
|
});
|
||||||
|
|
||||||
|
const BuildingTypeByKey = Object.freeze({
|
||||||
|
apartment: BuildingType.APARTMENT,
|
||||||
|
villa: BuildingType.VILLA,
|
||||||
|
house: BuildingType.HOUSE,
|
||||||
|
sweet: BuildingType.SWEET,
|
||||||
|
suite: BuildingType.SWEET,
|
||||||
|
room: BuildingType.ROOM,
|
||||||
|
studio: BuildingType.STUDIO,
|
||||||
|
office: BuildingType.OFFICE,
|
||||||
|
farms: BuildingType.FARMS,
|
||||||
|
shop: BuildingType.SHOP,
|
||||||
|
warehouse: BuildingType.WAREHOUSE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const BuildingTypeTranslationKeys = Object.freeze({
|
||||||
|
[BuildingType.APARTMENT]: 'buildingType.apartment',
|
||||||
|
[BuildingType.VILLA]: 'buildingType.villa',
|
||||||
|
[BuildingType.HOUSE]: 'buildingType.house',
|
||||||
|
[BuildingType.SWEET]: 'buildingType.sweet',
|
||||||
|
[BuildingType.ROOM]: 'buildingType.room',
|
||||||
|
[BuildingType.STUDIO]: 'buildingType.studio',
|
||||||
|
[BuildingType.OFFICE]: 'buildingType.office',
|
||||||
|
[BuildingType.FARMS]: 'buildingType.farms',
|
||||||
|
[BuildingType.SHOP]: 'buildingType.shop',
|
||||||
|
[BuildingType.WAREHOUSE]: 'buildingType.warehouse',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey, BuildingTypeTranslationKeys };
|
||||||
19
app/enums/CancellationReason.js
Normal file
19
app/enums/CancellationReason.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const CancellationReason = Object.freeze({
|
||||||
|
NONE: 0,
|
||||||
|
DEPOSIT_NOT_PAID: 1,
|
||||||
|
OWNER_CANCELED: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CancellationReasonLabels = Object.freeze({
|
||||||
|
[CancellationReason.NONE]: 'بدون سبب',
|
||||||
|
[CancellationReason.DEPOSIT_NOT_PAID]: 'عدم دفع العربون',
|
||||||
|
[CancellationReason.OWNER_CANCELED]: 'إلغاء من المالك',
|
||||||
|
});
|
||||||
|
|
||||||
|
const CancellationReasonTranslationKeys = Object.freeze({
|
||||||
|
[CancellationReason.NONE]: 'cancellationReason.none',
|
||||||
|
[CancellationReason.DEPOSIT_NOT_PAID]: 'cancellationReason.noDeposit',
|
||||||
|
[CancellationReason.OWNER_CANCELED]: 'cancellationReason.ownerCancellation',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CancellationReason, CancellationReasonLabels, CancellationReasonTranslationKeys };
|
||||||
70
app/enums/City.js
Normal file
70
app/enums/City.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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: 'ريف دمشق',
|
||||||
|
});
|
||||||
|
export const CityEnum = Object.freeze({
|
||||||
|
DAMASCUS: 0,
|
||||||
|
ALEPPO: 1,
|
||||||
|
HOMS: 2,
|
||||||
|
LATAKIA: 3,
|
||||||
|
DARAA: 4,
|
||||||
|
TARTOUS: 5,
|
||||||
|
SUWEIDA: 6,
|
||||||
|
DEIR_EZZOR: 7,
|
||||||
|
RAQQA: 8,
|
||||||
|
IDLIB: 9,
|
||||||
|
HASAKAH: 10,
|
||||||
|
Qamishli: 11,
|
||||||
|
RURAL_DAMASCUS: 12,
|
||||||
|
});
|
||||||
|
// 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CityTranslationKeys = Object.freeze({
|
||||||
|
[City.DAMASCUS]: 'city.damascus',
|
||||||
|
[City.ALEPPO]: 'city.aleppo',
|
||||||
|
[City.HOMS]: 'city.homs',
|
||||||
|
[City.LATAKIA]: 'city.latakia',
|
||||||
|
[City.DARAA]: 'city.daraa',
|
||||||
|
[City.TARTOUS]: 'city.tartous',
|
||||||
|
[City.SUWEIDA]: 'city.suweida',
|
||||||
|
[City.DEIR_EZZOR]: 'city.deirEzzor',
|
||||||
|
[City.RAQQA]: 'city.raqqa',
|
||||||
|
[City.IDLIB]: 'city.idlib',
|
||||||
|
[City.HASAKAH]: 'city.hasakah',
|
||||||
|
[City.QAMISHLI]: 'city.qamishli',
|
||||||
|
[City.RURAL_DAMASCUS]: 'city.ruralDamascus',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export { City, CitiesList, extractCity, CityTranslationKeys, };
|
||||||
25
app/enums/CommissionType.js
Normal file
25
app/enums/CommissionType.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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]: 'من الاثنين',
|
||||||
|
});
|
||||||
|
|
||||||
|
const CommissionTypeTranslationKeys = Object.freeze({
|
||||||
|
[CommissionType.FROM_OWNER]: 'commissionType.fromOwner',
|
||||||
|
[CommissionType.FROM_TENANT]: 'commissionType.fromTenant',
|
||||||
|
[CommissionType.FROM_BOTH]: 'commissionType.fromBoth',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CommissionType, CommissionTypeLabels, CommissionTypeTranslationKeys };
|
||||||
33
app/enums/Currency.js
Normal file
33
app/enums/Currency.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Currency Enum
|
||||||
|
* Currency IDs used in the backend
|
||||||
|
*/
|
||||||
|
const Currency = Object.freeze({
|
||||||
|
SYP: 1,
|
||||||
|
USD: 2,
|
||||||
|
EUR: 3,
|
||||||
|
TRY: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CurrencyLabels = Object.freeze({
|
||||||
|
[Currency.SYP]: 'ليرة سورية',
|
||||||
|
[Currency.USD]: 'دولار أمريكي',
|
||||||
|
[Currency.EUR]: 'يورو',
|
||||||
|
[Currency.TRY]: 'ليرة تركية',
|
||||||
|
});
|
||||||
|
|
||||||
|
const CurrencySymbols = Object.freeze({
|
||||||
|
[Currency.SYP]: 'SYP',
|
||||||
|
[Currency.USD]: 'USD',
|
||||||
|
[Currency.EUR]: 'EUR',
|
||||||
|
[Currency.TRY]: 'TRY',
|
||||||
|
});
|
||||||
|
|
||||||
|
const CurrencyTranslationKeys = Object.freeze({
|
||||||
|
[Currency.SYP]: 'currency.syp',
|
||||||
|
[Currency.USD]: 'currency.usd',
|
||||||
|
[Currency.EUR]: 'currency.eur',
|
||||||
|
[Currency.TRY]: 'currency.try',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Currency, CurrencyLabels, CurrencySymbols, CurrencyTranslationKeys };
|
||||||
22
app/enums/CustomerType.js
Normal file
22
app/enums/CustomerType.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 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]: 'عائلي',
|
||||||
|
});
|
||||||
|
|
||||||
|
const CustomerTypeTranslationKeys = Object.freeze({
|
||||||
|
[CustomerType.PERSONAL]: 'customerType.personal',
|
||||||
|
[CustomerType.FAMILY]: 'customerType.family',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { CustomerType, CustomerTypeLabels, CustomerTypeTranslationKeys };
|
||||||
7
app/enums/DeviceType.js
Normal file
7
app/enums/DeviceType.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const DeviceType = Object.freeze({
|
||||||
|
ANDROID: 'Android',
|
||||||
|
IOS: 'Ios',
|
||||||
|
WEB: 'Web',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { DeviceType };
|
||||||
19
app/enums/DocumentType.js
Normal file
19
app/enums/DocumentType.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Document type enum for owner property submission.
|
||||||
|
* Values are intentionally English keys so the UI can store normalized identifiers.
|
||||||
|
*/
|
||||||
|
const DocumentType = Object.freeze({
|
||||||
|
PASSPORT: 'Passport',
|
||||||
|
IDCARD: 'IDCard',
|
||||||
|
BOTH: 'Both',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DocumentTypeLabels = Object.freeze({
|
||||||
|
[DocumentType.PASSPORT]: 'جواز سفر',
|
||||||
|
[DocumentType.IDCARD]: 'بطاقة شخصية',
|
||||||
|
[DocumentType.BOTH]: 'كلاهما',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DocumentTypeList = Object.freeze(Object.values(DocumentType));
|
||||||
|
|
||||||
|
export { DocumentType, DocumentTypeLabels, DocumentTypeList };
|
||||||
39
app/enums/Governorate.js
Normal file
39
app/enums/Governorate.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Syrian governorates enum using English normalized values.
|
||||||
|
* Labels are provided separately for Arabic display.
|
||||||
|
*/
|
||||||
|
const Governorate = Object.freeze({
|
||||||
|
DAMASCUS: 'Damascus',
|
||||||
|
ALEPPO: 'Aleppo',
|
||||||
|
LATAKIA: 'Latakia',
|
||||||
|
HAMA: 'Hama',
|
||||||
|
TARTOUS: 'Tartous',
|
||||||
|
DEIR_EZZOR: 'DeirEzzor',
|
||||||
|
RAQQA: 'Raqqa',
|
||||||
|
IDLIB: 'Idlib',
|
||||||
|
DRAA: 'Daraa',
|
||||||
|
SUWEIDA: 'Suweida',
|
||||||
|
HASAKAH: 'Hasakah',
|
||||||
|
QUNEITRA: 'Quneitra',
|
||||||
|
RURAL_DAMASCUS: 'RuralDamascus',
|
||||||
|
});
|
||||||
|
|
||||||
|
const GovernorateLabels = Object.freeze({
|
||||||
|
[Governorate.DAMASCUS]: 'دمشق',
|
||||||
|
[Governorate.ALEPPO]: 'حلب',
|
||||||
|
[Governorate.LATAKIA]: 'اللاذقية',
|
||||||
|
[Governorate.HAMA]: 'حماة',
|
||||||
|
[Governorate.TARTOUS]: 'طرطوس',
|
||||||
|
[Governorate.DEIR_EZZOR]: 'دير الزور',
|
||||||
|
[Governorate.RAQQA]: 'الرقة',
|
||||||
|
[Governorate.IDLIB]: 'إدلب',
|
||||||
|
[Governorate.DRAA]: 'درعا',
|
||||||
|
[Governorate.SUWEIDA]: 'السويداء',
|
||||||
|
[Governorate.HASAKAH]: 'الحسكة',
|
||||||
|
[Governorate.QUNEITRA]: 'القنيطرة',
|
||||||
|
[Governorate.RURAL_DAMASCUS]: 'ريف دمشق',
|
||||||
|
});
|
||||||
|
|
||||||
|
const GovernorateList = Object.freeze(Object.values(Governorate));
|
||||||
|
|
||||||
|
export { Governorate, GovernorateLabels, GovernorateList };
|
||||||
28
app/enums/IdentityType.js
Normal file
28
app/enums/IdentityType.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 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]: '🛂',
|
||||||
|
});
|
||||||
|
|
||||||
|
const IdentityTypeTranslationKeys = Object.freeze({
|
||||||
|
[IdentityType.SYRIAN]: 'identityType.syrian',
|
||||||
|
[IdentityType.PASSPORT]: 'identityType.passport',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags, IdentityTypeTranslationKeys };
|
||||||
11
app/enums/Language.js
Normal file
11
app/enums/Language.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const Language = Object.freeze({
|
||||||
|
ARABIC: 'Arabic',
|
||||||
|
ENGLISH: 'English',
|
||||||
|
});
|
||||||
|
|
||||||
|
const LanguageLabels = Object.freeze({
|
||||||
|
[Language.ARABIC]: 'العربية',
|
||||||
|
[Language.ENGLISH]: 'English',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Language, LanguageLabels };
|
||||||
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 };
|
||||||
22
app/enums/OwnerType.js
Normal file
22
app/enums/OwnerType.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 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]: 'وكالة عقارية',
|
||||||
|
});
|
||||||
|
|
||||||
|
const OwnerTypeTranslationKeys = Object.freeze({
|
||||||
|
[OwnerType.PERSON]: 'ownerType.person',
|
||||||
|
[OwnerType.REAL_ESTATE_AGENCY]: 'ownerType.agency',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { OwnerType, OwnerTypeLabels, OwnerTypeTranslationKeys };
|
||||||
57
app/enums/PropertyService.js
Normal file
57
app/enums/PropertyService.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
|
||||||
|
const PropertyServiceTranslationKeys = Object.freeze({
|
||||||
|
[PropertyService.ELECTRICITY]: 'propertyService.electricity',
|
||||||
|
[PropertyService.INTERNET]: 'propertyService.internet',
|
||||||
|
[PropertyService.HEATING]: 'propertyService.heating',
|
||||||
|
[PropertyService.WATER]: 'propertyService.water',
|
||||||
|
[PropertyService.POOL]: 'propertyService.pool',
|
||||||
|
[PropertyService.PRIVATE_GARDEN]: 'propertyService.privateGarden',
|
||||||
|
[PropertyService.PARKING]: 'propertyService.parking',
|
||||||
|
[PropertyService.SECURITY_247]: 'propertyService.security247',
|
||||||
|
[PropertyService.CENTRAL_HEATING]: 'propertyService.centralHeating',
|
||||||
|
[PropertyService.CENTRAL_AIR_CONDITIONING]: 'propertyService.centralAirConditioning',
|
||||||
|
[PropertyService.EQUIPPED_KITCHEN]: 'propertyService.equippedKitchen',
|
||||||
|
[PropertyService.MAIDS_ROOM]: 'propertyService.maidsRoom',
|
||||||
|
[PropertyService.ELEVATOR]: 'propertyService.elevator',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { PropertyService, PropertyServiceLabels, PropertyServicesList, PropertyServiceTranslationKeys };
|
||||||
36
app/enums/PropertyStatus.js
Normal file
36
app/enums/PropertyStatus.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* PropertyStatus Enum
|
||||||
|
* Backend values are numeric (0, 1, 2)
|
||||||
|
* Used in: PropertyInformation.status
|
||||||
|
*/
|
||||||
|
const PropertyStatus = Object.freeze({
|
||||||
|
AVAILABLE: 0,
|
||||||
|
NOT_AVAILABLE: 1,
|
||||||
|
BOOKED: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PropertyStatusLabels = Object.freeze({
|
||||||
|
[PropertyStatus.AVAILABLE]: 'متاح',
|
||||||
|
[PropertyStatus.NOT_AVAILABLE]: 'غير متاح',
|
||||||
|
[PropertyStatus.BOOKED]: 'محجوز',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PropertyStatusKeys = Object.freeze({
|
||||||
|
[PropertyStatus.AVAILABLE]: 'available',
|
||||||
|
[PropertyStatus.NOT_AVAILABLE]: 'notAvailable',
|
||||||
|
[PropertyStatus.BOOKED]: 'booked',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PropertyStatusByKey = Object.freeze({
|
||||||
|
available: PropertyStatus.AVAILABLE,
|
||||||
|
notAvailable: PropertyStatus.NOT_AVAILABLE,
|
||||||
|
booked: PropertyStatus.BOOKED,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PropertyStatusTranslationKeys = Object.freeze({
|
||||||
|
[PropertyStatus.AVAILABLE]: 'propertyStatus.available',
|
||||||
|
[PropertyStatus.NOT_AVAILABLE]: 'propertyStatus.notAvailable',
|
||||||
|
[PropertyStatus.BOOKED]: 'propertyStatus.booked',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey, PropertyStatusTranslationKeys };
|
||||||
27
app/enums/PropertyTerm.js
Normal file
27
app/enums/PropertyTerm.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
|
||||||
|
const PropertyTermTranslationKeys = Object.freeze({
|
||||||
|
[PropertyTerm.NO_SMOKING]: 'propertyTerm.noSmoking',
|
||||||
|
[PropertyTerm.NO_ANIMALS]: 'propertyTerm.noAnimals',
|
||||||
|
[PropertyTerm.NO_PARTIES]: 'propertyTerm.noParties',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { PropertyTerm, PropertyTermLabels, PropertyTermsList, PropertyTermTranslationKeys };
|
||||||
21
app/enums/RentPropertyCondition.js
Normal file
21
app/enums/RentPropertyCondition.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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]: 'غير مفروش',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RentPropertyConditionTranslationKeys = Object.freeze({
|
||||||
|
[RentPropertyCondition.WITH_FURNITURE]: 'rentCondition.furnished',
|
||||||
|
[RentPropertyCondition.WITHOUT_FURNITURE]: 'rentCondition.unfurnished',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { RentPropertyCondition, RentPropertyConditionLabels, RentPropertyConditionTranslationKeys };
|
||||||
23
app/enums/RentPropertyType.js
Normal file
23
app/enums/RentPropertyType.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 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]: 'مفروش جزئياً',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RentPropertyTypeTranslationKeys = Object.freeze({
|
||||||
|
[RentPropertyType.FURNISHED]: 'rentPropertyType.fullyFurnished',
|
||||||
|
[RentPropertyType.UNFURNISHED]: 'rentPropertyType.unfurnished',
|
||||||
|
[RentPropertyType.SEMI_FURNISHED]: 'rentPropertyType.partiallyFurnished',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { RentPropertyType, RentPropertyTypeLabels, RentPropertyTypeTranslationKeys };
|
||||||
21
app/enums/RentType.js
Normal file
21
app/enums/RentType.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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]: 'يومي',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RentTypeTranslationKeys = Object.freeze({
|
||||||
|
[RentType.MONTHLY]: 'rentType.monthly',
|
||||||
|
[RentType.DAILY]: 'rentType.daily',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { RentType, RentTypeLabels, RentTypeTranslationKeys };
|
||||||
16
app/enums/SalePropertiesStatus.js
Normal file
16
app/enums/SalePropertiesStatus.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const SalePropertiesStatus = Object.freeze({
|
||||||
|
PENDING: 0,
|
||||||
|
CONFIRMED: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const SalePropertiesStatusLabels = Object.freeze({
|
||||||
|
[SalePropertiesStatus.PENDING]: 'قيد الانتظار',
|
||||||
|
[SalePropertiesStatus.CONFIRMED]: 'مؤكد',
|
||||||
|
});
|
||||||
|
|
||||||
|
const SalePropertiesStatusTranslationKeys = Object.freeze({
|
||||||
|
[SalePropertiesStatus.PENDING]: 'saleStatus.pending',
|
||||||
|
[SalePropertiesStatus.CONFIRMED]: 'saleStatus.confirmed',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { SalePropertiesStatus, SalePropertiesStatusLabels, SalePropertiesStatusTranslationKeys };
|
||||||
16
app/enums/TransactionType.js
Normal file
16
app/enums/TransactionType.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const TransactionType = Object.freeze({
|
||||||
|
ONLINE: 0,
|
||||||
|
MANUAL: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TransactionTypeLabels = Object.freeze({
|
||||||
|
[TransactionType.ONLINE]: 'أونلاين',
|
||||||
|
[TransactionType.MANUAL]: 'يدوي',
|
||||||
|
});
|
||||||
|
|
||||||
|
const TransactionTypeTranslationKeys = Object.freeze({
|
||||||
|
[TransactionType.ONLINE]: 'transactionType.online',
|
||||||
|
[TransactionType.MANUAL]: 'transactionType.manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { TransactionType, TransactionTypeLabels, TransactionTypeTranslationKeys };
|
||||||
34
app/enums/UserRole.js
Normal file
34
app/enums/UserRole.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* UserRole Enum
|
||||||
|
* User account roles in the system
|
||||||
|
* Derived from JWT token claims
|
||||||
|
*/
|
||||||
|
const UserRole = Object.freeze({
|
||||||
|
GUEST: 'guest',
|
||||||
|
CUSTOMER: 'customer',
|
||||||
|
OWNER: 'owner',
|
||||||
|
AGENT: 'agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserRoleLabels = Object.freeze({
|
||||||
|
[UserRole.GUEST]: 'زائر',
|
||||||
|
[UserRole.CUSTOMER]: 'مستأجر',
|
||||||
|
[UserRole.OWNER]: 'مالك عقار',
|
||||||
|
[UserRole.AGENT]: 'وسيط عقاري',
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserRoleColors = Object.freeze({
|
||||||
|
[UserRole.GUEST]: 'gray',
|
||||||
|
[UserRole.CUSTOMER]: 'blue',
|
||||||
|
[UserRole.OWNER]: 'amber',
|
||||||
|
[UserRole.AGENT]: 'purple',
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserRoleTranslationKeys = Object.freeze({
|
||||||
|
[UserRole.GUEST]: 'userRole.guest',
|
||||||
|
[UserRole.CUSTOMER]: 'userRole.customer',
|
||||||
|
[UserRole.OWNER]: 'userRole.owner',
|
||||||
|
[UserRole.AGENT]: 'userRole.agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { UserRole, UserRoleLabels, UserRoleColors, UserRoleTranslationKeys };
|
||||||
28
app/enums/index.js
Normal file
28
app/enums/index.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 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 { DocumentType, DocumentTypeLabels, DocumentTypeList } from './DocumentType';
|
||||||
|
export { Governorate, GovernorateLabels, GovernorateList } from './Governorate';
|
||||||
|
export { Currency, CurrencyLabels, CurrencySymbols } from './Currency';
|
||||||
|
export { DeviceType } from './DeviceType';
|
||||||
|
export { SalePropertiesStatus, SalePropertiesStatusLabels } from './SalePropertiesStatus';
|
||||||
|
export { TransactionType, TransactionTypeLabels } from './TransactionType';
|
||||||
|
export { CancellationReason, CancellationReasonLabels } from './CancellationReason';
|
||||||
|
export { Language, LanguageLabels } from './Language';
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
app/faq/page.js
Normal file
121
app/faq/page.js
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { HelpCircle, ChevronDown, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function FAQPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [openIndex, setOpenIndex] = useState(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const faqs = [
|
||||||
|
{ question: t('faq.q1'), answer: t('faq.a1') },
|
||||||
|
{ question: t('faq.q2'), answer: t('faq.a2') },
|
||||||
|
{ question: t('faq.q3'), answer: t('faq.a3') },
|
||||||
|
{ question: t('faq.q4'), answer: t('faq.a4') },
|
||||||
|
{ question: t('faq.q5'), answer: t('faq.a5') },
|
||||||
|
{ question: t('faq.q6'), answer: t('faq.a6') },
|
||||||
|
{ question: t('faq.q7'), answer: t('faq.a7') },
|
||||||
|
{ question: t('faq.q8'), answer: t('faq.a8') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredFaqs = faqs.filter(
|
||||||
|
(faq) =>
|
||||||
|
faq.question.includes(searchTerm) ||
|
||||||
|
faq.answer.includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleFAQ = (index) => {
|
||||||
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-12" dir="rtl">
|
||||||
|
<div className="container mx-auto px-4 max-w-4xl">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-center mb-12"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-amber-100">
|
||||||
|
<HelpCircle className="w-10 h-10 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">{t('faq.pageTitle')}</h1>
|
||||||
|
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||||
|
{t('faq.pageDescription')}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="relative mb-8"
|
||||||
|
>
|
||||||
|
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('faq.searchPlaceholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pr-12 pl-4 py-4 bg-white border border-gray-200 rounded-2xl shadow-sm focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredFaqs.map((faq, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFAQ(index)}
|
||||||
|
className="w-full flex items-center justify-between p-5 text-right"
|
||||||
|
>
|
||||||
|
<span className="text-lg font-semibold text-gray-900 flex-1">
|
||||||
|
{faq.question}
|
||||||
|
</span>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: openIndex === index ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-500 shrink-0" />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{openIndex === index && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="px-5 pb-5 text-gray-600 leading-relaxed border-t border-gray-100 pt-4">
|
||||||
|
{faq.answer}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredFaqs.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-center py-12"
|
||||||
|
>
|
||||||
|
<p className="text-gray-500 text-lg">{t('faq.noResults')}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 59 KiB |
128
app/favorites/page.js
Normal file
128
app/favorites/page.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
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";
|
||||||
|
import Loading from "../loading";
|
||||||
|
|
||||||
|
export default function FavoritesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { favorites, isLoading: favoritesLoading, removeFavorite } = useFavorites();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Admin check removed
|
||||||
|
// if (AuthService.isAdmin()) {
|
||||||
|
// router.push('/');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// setIsAdmin(AuthService.isAdmin());
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
return amount?.toLocaleString() + " " + t("currency-syp-suffix");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (favoritesLoading && favorites.length === 0) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">{t("favorites")}</h1>
|
||||||
|
<p className="text-gray-600">{t("saved-properties")}</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">{t("no-favorites")}</h3>
|
||||||
|
<p className="text-gray-500 mb-6">{t("add-favorites-hint")}</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">
|
||||||
|
{t("browse-properties")}
|
||||||
|
</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" ? t("buildingType.apartment") : property.type === "villa" ? t("buildingType.villa") : t("buildingType.house")}
|
||||||
|
</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" ? t("day") : t("month")}</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">
|
||||||
|
{t("viewDetails")}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,30 +1,78 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Mail, ArrowLeft, CheckCircle } from 'lucide-react';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import { Mail, Key, Lock, CheckCircle, ArrowLeft, RefreshCw } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { requestForgetPasswordOtp, verifyForgetPasswordOtp } from '../utils/api';
|
||||||
|
import InteractiveBackground from '../components/Animation/Background';
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleRequestOtp = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
toast.error(t('forgotPassword.emailRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
setTimeout(() => {
|
await requestForgetPasswordOtp(email);
|
||||||
|
toast.success(t('forgotPassword.otpSent'));
|
||||||
|
setStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err?.message || t('forgotPassword.otpSendFailed'));
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsSubmitted(true);
|
}
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!code.trim()) {
|
||||||
|
toast.error(t('forgotPassword.otpRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
toast.error(t('validation.passwordMinLength'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await verifyForgetPasswordOtp(email, code, newPassword);
|
||||||
|
toast.success(t('forgotPassword.resetSuccess'));
|
||||||
|
setTimeout(() => router.push('/login'), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err?.message || t('forgotPassword.otpVerifyFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = "w-full pr-12 pl-4 py-3 bg-white/10 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition-all";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center p-4 relative overflow-hidden">
|
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<InteractiveBackground/>
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-purple-500 rounded-full opacity-20 blur-3xl animate-pulse"></div>
|
<div className="absolute -top-40 -right-40 w-80 h-80 bg-amber-400 rounded-full opacity-20 blur-3xl animate-pulse"></div>
|
||||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-pink-500 rounded-full opacity-20 blur-3xl animate-pulse delay-1000"></div>
|
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-orange-400 rounded-full opacity-20 blur-3xl animate-pulse delay-1000"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -33,97 +81,168 @@ export default function ForgotPasswordPage() {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="relative w-full max-w-md"
|
className="relative w-full max-w-md"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
className="absolute -top-16 left-0"
|
className="absolute -top-16 left-0"
|
||||||
>
|
>
|
||||||
<Link href="/login" className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors group">
|
<Link href="/login" className="flex items-center gap-2 text-gray-600 hover:text-amber-600 transition-colors group">
|
||||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>العودة لتسجيل الدخول</span>
|
<span>{t('forgotPassword.backToLogin')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/20 overflow-hidden">
|
<div className="bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/60 overflow-hidden">
|
||||||
<div className="bg-gradient-to-r from-purple-500 to-pink-500 p-8 text-center">
|
<div className="bg-gradient-to-l from-amber-500 to-amber-600 p-8 text-center">
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ y: 20, opacity: 0 }}
|
initial={{ y: 20, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
className="text-3xl font-bold text-white mb-2"
|
className="text-3xl font-bold text-white mb-2"
|
||||||
>
|
>
|
||||||
استعادة كلمة المرور
|
{t('forgotPassword.title')}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ y: 20, opacity: 0 }}
|
initial={{ y: 20, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="text-purple-100"
|
className="text-amber-100"
|
||||||
>
|
>
|
||||||
سنرسل لك رابط لإعادة تعيين كلمة المرور
|
{step === 1 ? t('forgotPassword.step1Instruction') : t('forgotPassword.step2Instruction')}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
{!isSubmitted ? (
|
<AnimatePresence mode="wait">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{step === 1 && (
|
||||||
<div>
|
<motion.form
|
||||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
key="step1"
|
||||||
البريد الإلكتروني
|
initial={{ opacity: 0, x: -30 }}
|
||||||
</label>
|
animate={{ opacity: 1, x: 0 }}
|
||||||
<div className="relative group">
|
exit={{ opacity: 0, x: 30 }}
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
onSubmit={handleRequestOtp}
|
||||||
<Mail className="w-5 h-5 text-gray-400 group-focus-within:text-purple-500 transition-colors" />
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('forgotPassword.emailLabel')}
|
||||||
|
</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 text-gray-400 group-focus-within:text-amber-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||||
|
dir="ltr"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full pr-12 pl-4 py-3 bg-white/5 border border-gray-600 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent text-white placeholder-gray-400 transition-all"
|
|
||||||
placeholder="أدخل بريدك الإلكتروني"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<motion.button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 text-white py-3 rounded-xl font-bold text-lg hover:from-purple-600 hover:to-pink-600 transition-all transform hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-purple-500/25"
|
whileHover={{ scale: isLoading ? 1 : 1.02 }}
|
||||||
|
whileTap={{ scale: isLoading ? 1 : 0.98 }}
|
||||||
|
className="w-full bg-gradient-to-l from-amber-500 to-amber-600 text-white py-3 rounded-xl font-bold text-lg hover:from-amber-600 hover:to-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-amber-500/25"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>{t('forgotPassword.sending')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('forgotPassword.sendOtp')
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<motion.form
|
||||||
|
key="step2"
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -30 }}
|
||||||
|
onSubmit={handleVerifyOtp}
|
||||||
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<div>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
{t('forgotPassword.otpLabel')}
|
||||||
<span>جاري الإرسال...</span>
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Key className="w-5 h-5 text-gray-400 group-focus-within:text-amber-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={t('forgotPassword.otpPlaceholder')}
|
||||||
|
dir="ltr"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
'إرسال رابط الاستعادة'
|
|
||||||
)}
|
<div>
|
||||||
</button>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
</form>
|
{t('forgotPassword.newPasswordLabel')}
|
||||||
) : (
|
</label>
|
||||||
<motion.div
|
<div className="relative group">
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
<Lock className="w-5 h-5 text-gray-400 group-focus-within:text-amber-500 transition-colors" />
|
||||||
className="text-center py-6"
|
</div>
|
||||||
>
|
<input
|
||||||
<div className="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
type="password"
|
||||||
<CheckCircle className="w-10 h-10 text-green-500" />
|
value={newPassword}
|
||||||
</div>
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
<h3 className="text-xl font-bold text-white mb-2">تم الإرسال بنجاح!</h3>
|
className={inputClass}
|
||||||
<p className="text-gray-300 mb-6">
|
placeholder={t('forgotPassword.newPasswordPlaceholder')}
|
||||||
تم إرسال رابط استعادة كلمة المرور إلى {email}
|
required
|
||||||
</p>
|
minLength={6}
|
||||||
<Link
|
/>
|
||||||
href="/login"
|
</div>
|
||||||
className="inline-block px-6 py-3 bg-white/10 text-white rounded-xl hover:bg-white/20 transition-colors"
|
</div>
|
||||||
>
|
|
||||||
العودة لتسجيل الدخول
|
<motion.button
|
||||||
</Link>
|
type="submit"
|
||||||
</motion.div>
|
disabled={isLoading}
|
||||||
)}
|
whileHover={{ scale: isLoading ? 1 : 1.02 }}
|
||||||
|
whileTap={{ scale: isLoading ? 1 : 0.98 }}
|
||||||
|
className="w-full bg-gradient-to-l from-amber-500 to-amber-600 text-white py-3 rounded-xl font-bold text-lg hover:from-amber-600 hover:to-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-amber-500/25"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>{t('forgotPassword.verifying')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('forgotPassword.resetPassword')
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className="text-sm text-amber-600 hover:text-amber-700 transition-colors flex items-center justify-center gap-1 mx-auto"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
<span>{t('forgotPassword.changeEmail')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
108
app/globals.css
108
app/globals.css
@ -1,5 +1,80 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ─── Madani Arabic Font ─── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Thin.woff2') format('woff2');
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Extra Light.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani-Arabic-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Medium.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Semi Bold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani-Arabic-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Extra Bold.woff2') format('woff2');
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Madani Arabic';
|
||||||
|
src: url('/fonts/Madani Arabic Black.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ede6e6;
|
--background: #ede6e6;
|
||||||
--foreground: #156874;
|
--foreground: #156874;
|
||||||
@ -19,10 +94,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #0f172a;
|
||||||
|
--foreground: #f5e6d3;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-white: #1e293b;
|
||||||
|
--color-gray-50: #0f172a;
|
||||||
|
--color-gray-100: #1e293b;
|
||||||
|
--color-gray-200: #334155;
|
||||||
|
--color-gray-300: #475569;
|
||||||
|
--color-gray-400: #64748b;
|
||||||
|
--color-gray-500: #94a3b8;
|
||||||
|
--color-gray-600: #cbd5e1;
|
||||||
|
--color-gray-700: #e2e8f0;
|
||||||
|
--color-gray-800: #f1f5f9;
|
||||||
|
--color-gray-900: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
@ -58,6 +157,13 @@ body {
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
.dark .nav-icon {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.property-tooltip {
|
.property-tooltip {
|
||||||
background: white !important;
|
background: white !important;
|
||||||
color: #1f2937 !important;
|
color: #1f2937 !important;
|
||||||
|
|||||||
57
app/hooks/UseFilterOfHeroSearch.js
Normal file
57
app/hooks/UseFilterOfHeroSearch.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const useFilterOptions = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const cities = [
|
||||||
|
{ id: "all", label: t("allCities") },
|
||||||
|
{ id: "دمشق", label: t("damascus") },
|
||||||
|
{ id: "حلب", label: t("aleppo") },
|
||||||
|
{ id: "حمص", label: t("homs") },
|
||||||
|
{ id: "اللاذقية", label: t("latakia") },
|
||||||
|
{ id: "درعا", label: t("daraa") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const propertyTypes = [
|
||||||
|
{ id: "all", label: t("all") },
|
||||||
|
{ id: "apartment", label: t("residentialApartments") },
|
||||||
|
{ id: "studio", label: t("studio") },
|
||||||
|
{ id: "commercial", label: t("commercialProperty") },
|
||||||
|
{ id: "villa", label: t("villaFarm") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const priceRanges = [
|
||||||
|
{ id: "all", label: t("allPrices") },
|
||||||
|
{ id: "0-500", label: t("priceRange1") },
|
||||||
|
{ id: "500-1000", label: t("priceRange2") },
|
||||||
|
{ id: "1000-2000", label: t("priceRange3") },
|
||||||
|
{ id: "2000-3000", label: t("priceRange4") },
|
||||||
|
{ id: "3000+", label: t("priceRange5") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const identityTypes = [
|
||||||
|
{ id: "syrian", label: t("syrianId") },
|
||||||
|
{ id: "passport", label: t("passport") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ownerSources = [
|
||||||
|
{ id: "all", label: t("all") },
|
||||||
|
{ id: "owner", label: t("fromOwner") },
|
||||||
|
{ id: "agency", label: t("fromAgency") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rentPeriods = [
|
||||||
|
{ id: "all", label: t("all") },
|
||||||
|
{ id: "daily", label: t("dailyRent") },
|
||||||
|
{ id: "monthly", label: t("monthlyRent") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
cities,
|
||||||
|
propertyTypes,
|
||||||
|
priceRanges,
|
||||||
|
identityTypes,
|
||||||
|
ownerSources,
|
||||||
|
rentPeriods,
|
||||||
|
};
|
||||||
|
};
|
||||||
99
app/hooks/useApplyFilters.js
Normal file
99
app/hooks/useApplyFilters.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getRentProperties, getSaleProperties } from ".././utils/api";
|
||||||
|
import { mapApiProperty } from "../HelperFunction/ApiProperty";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
export default function useApplyFilters() {
|
||||||
|
const [allProperties, setAllProperties] = useState([]);
|
||||||
|
const [rentProperties, setRentProperties] = useState([]);
|
||||||
|
const [saleProperties, setSaleProperties] = useState([]);
|
||||||
|
const [searchFilters, setSearchFilters] = useState(null);
|
||||||
|
const [filteredProperties, setFilteredProperties] = useState([]);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
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 mappedRent = rentList.map((p, i) => mapApiProperty(t, p, i));
|
||||||
|
const mappedSale = saleList.map((p, i) => mapApiProperty(t, p, rentList.length + i));
|
||||||
|
setRentProperties(mappedRent);
|
||||||
|
setSaleProperties(mappedSale);
|
||||||
|
setAllProperties([...mappedRent, ...mappedSale]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Home] Failed to fetch properties:", err);
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProperties();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyFilters = (filters) => {
|
||||||
|
setSearchFilters(filters);
|
||||||
|
let propertiesToFilter = [];
|
||||||
|
if (filters.mode === "rent") {
|
||||||
|
propertiesToFilter = rentProperties;
|
||||||
|
} else if (filters.mode === "buy" || filters.mode === "sell") {
|
||||||
|
propertiesToFilter = saleProperties;
|
||||||
|
} else {
|
||||||
|
propertiesToFilter = allProperties;
|
||||||
|
}
|
||||||
|
const filtered = propertiesToFilter.filter((property) => {
|
||||||
|
if (filters.city && filters.city !== "all" && property.location.city !== filters.city) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.propertyType && filters.propertyType !== "all" && property.type !== filters.propertyType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.priceRange && filters.priceRange !== "all") {
|
||||||
|
const priceUSD = property.priceUSD;
|
||||||
|
switch (filters.priceRange) {
|
||||||
|
case "0-500":
|
||||||
|
if (priceUSD > 500) return false;
|
||||||
|
break;
|
||||||
|
case "500-1000":
|
||||||
|
if (priceUSD < 501 || priceUSD > 1000) return false;
|
||||||
|
break;
|
||||||
|
case "1000-2000":
|
||||||
|
if (priceUSD < 1001 || priceUSD > 2000) return false;
|
||||||
|
break;
|
||||||
|
case "2000-3000":
|
||||||
|
if (priceUSD < 2001 || priceUSD > 300) return false;
|
||||||
|
break;
|
||||||
|
case "3000+":
|
||||||
|
if (priceUSD < 3001) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.ownerSource && filters.ownerSource !== "all") {
|
||||||
|
if (filters.ownerSource === "owner" && property.ownerSource !== "owner") return false;
|
||||||
|
if (filters.ownerSource === "agency" && property.ownerSource !== "agency") return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.rentPeriod && filters.rentPeriod !== "all" && property.listingType === "rent") {
|
||||||
|
if (filters.rentPeriod === "daily" && !property.priceDisplay.daily) return false;
|
||||||
|
if (filters.rentPeriod === "monthly" && !property.priceDisplay.monthly) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.availableToday) {
|
||||||
|
if (property.status !== "available") return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.identityType && property.allowedIdentities) {
|
||||||
|
if (!property.allowedIdentities.includes(filters.identityType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setFilteredProperties(filtered);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setSearchFilters(null);
|
||||||
|
setFilteredProperties([]);
|
||||||
|
};
|
||||||
|
return { filteredProperties, searchFilters, applyFilters, resetFilters };
|
||||||
|
}
|
||||||
18
app/hooks/useAuth.js
Normal file
18
app/hooks/useAuth.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// app/hooks/useAuth.js
|
||||||
|
import AuthService from "../services/AuthService";
|
||||||
|
|
||||||
|
export default function useAuth() {
|
||||||
|
const authUser = AuthService.getUser();
|
||||||
|
const role = authUser?.roles;
|
||||||
|
return {
|
||||||
|
user: authUser,
|
||||||
|
name: authUser?.name ?? "Not found",
|
||||||
|
email: authUser?.email ?? "Not found",
|
||||||
|
isOwner: role?.includes("Owner"),
|
||||||
|
isAgent: role?.includes("RealEstateAgent",""),
|
||||||
|
isCustomer: role?.includes("Customer"),
|
||||||
|
role: role,
|
||||||
|
isGuest: !authUser,
|
||||||
|
isAuthenticated: !!authUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
3523
app/i18n/config.js
3523
app/i18n/config.js
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,9 @@
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ClientLayout from "./ClientLayout";
|
import ClientLayout from "./ClientLayout";
|
||||||
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
|
import { NotificationsProvider } from "./contexts/NotificationsContext";
|
||||||
|
import { FavoritesProvider } from "./contexts/FavoritesContext";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -24,12 +27,41 @@ export const metadata = {
|
|||||||
|
|
||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ar" dir="rtl">
|
<html lang="ar" dir="rtl" className="overflow-x-hidden">
|
||||||
<head />
|
<head>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="font"
|
||||||
|
href="/fonts/Madani-Arabic-Regular.woff2"
|
||||||
|
type="font/woff2"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="font"
|
||||||
|
href="/fonts/Madani-Arabic-Bold.woff2"
|
||||||
|
type="font/woff2"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="font"
|
||||||
|
href="/fonts/Madani Arabic Medium.woff2"
|
||||||
|
type="font/woff2"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
|
||||||
>
|
>
|
||||||
<ClientLayout>{children}</ClientLayout>
|
<ThemeProvider>
|
||||||
|
<NotificationsProvider>
|
||||||
|
<FavoritesProvider>
|
||||||
|
<ClientLayout>{children}</ClientLayout>
|
||||||
|
</FavoritesProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user