Compare commits
190 Commits
be14250a08
...
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 |
@ -6,11 +6,11 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: Rahaf/SweetHome
|
||||
repository: amer2004/SweetHome
|
||||
github-server-url: http://45.93.137.91:3000
|
||||
|
||||
- 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,8 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
|
||||
import FloatingSidebar from '@/app/components/FloatingSidebar';
|
||||
import BottomNav from "./components/BottomNav";
|
||||
import {
|
||||
Globe,
|
||||
LogIn,
|
||||
@ -17,24 +16,17 @@ import {
|
||||
Building,
|
||||
PlusCircle,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Settings,
|
||||
Edit,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Camera,
|
||||
Shield,
|
||||
Bell,
|
||||
Home,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
X,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Users,
|
||||
DollarSign,
|
||||
Star,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@ -43,6 +35,9 @@ import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||
import "./i18n/config";
|
||||
import NotificationHandler from "./components/NotificationHandler";
|
||||
|
||||
import { useNotifications } from "@/app/contexts/NotificationsContext";
|
||||
import Loading from "./loading";
|
||||
|
||||
export default function ClientLayout({ children }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
@ -54,6 +49,8 @@ export default function ClientLayout({ children }) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const savedLanguage = localStorage.getItem("language") || "ar";
|
||||
@ -77,9 +74,7 @@ export default function ClientLayout({ children }) {
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
phone: authUser.phone,
|
||||
role: AuthService.isAdmin() ? UserRole.ADMIN
|
||||
: AuthService.isOwner() ? UserRole.OWNER
|
||||
: UserRole.CUSTOMER,
|
||||
role: AuthService.isOwner() ? UserRole.OWNER : AuthService.isAgent() ? UserRole.AGENT : UserRole.CUSTOMER,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
@ -125,17 +120,14 @@ export default function ClientLayout({ children }) {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const isAuthPage = [
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/auth/choose-role",
|
||||
].includes(pathname);
|
||||
const isAuthPage = ["/login", "/blocked", "/register", "/forgot-password", "/auth/choose-role"].includes(pathname);
|
||||
|
||||
const isProfilePage = pathname === "/profile";
|
||||
const isHomePage = pathname === "/";
|
||||
|
||||
const isOwner = user?.role === UserRole.OWNER;
|
||||
const isAdmin = user?.role === UserRole.ADMIN;
|
||||
const isAgent = user?.role === UserRole.AGENT;
|
||||
const isOwnerOrAgent = isOwner || isAgent;
|
||||
const isCustomer = user?.role === UserRole.CUSTOMER;
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
@ -148,122 +140,99 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
<Loading/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAuthPage && (
|
||||
<nav className="fixed top-0 left-0 right-0 bg-white/95 backdrop-blur-sm border-b border-gray-200 z-50 transition-all duration-300 shadow-sm">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{!isAuthPage && !isAuthenticated && (
|
||||
<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={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}>
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<div className="relative w-[150px] h-[60px]">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={t("logoAlt")}
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<Image src="/logo.png" alt={t("logoAlt")} fill className="object-contain" priority />
|
||||
</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")}
|
||||
<span className="text-amber-600">
|
||||
{t("brandNamePart2")}
|
||||
</span>
|
||||
<span className={isHomePage ? "text-amber-400" : "text-amber-600"}>{t("brandNamePart2")}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<div
|
||||
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}>
|
||||
{/* Download App Dropdown */}
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-3 py-2 text-gray-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all">
|
||||
<button
|
||||
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"}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z"/>
|
||||
<path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z"/>
|
||||
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z" />
|
||||
<path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">{t("downloadApp")}</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">تحميل التطبيق</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 translate-y-2 group-hover:translate-y-0">
|
||||
<div className="p-2">
|
||||
<a href="/files/SweetHome.apk" download
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<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"/>
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">Android</p>
|
||||
<p className="text-xs text-green-600">تحميل APK</p>
|
||||
<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"/>
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-400 text-sm">iOS</p>
|
||||
<p className="text-xs text-gray-400">قريباً</p>
|
||||
<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>
|
||||
|
||||
<NavLink href="/">الرئيسية</NavLink>
|
||||
<NavLink href="/properties">عقاراتنا</NavLink>
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink href="/admin">
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
الإدارة
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink light={isHomePage} href="/">
|
||||
{t("home")}
|
||||
</NavLink>
|
||||
<NavLink light={isHomePage} href="/properties">
|
||||
{t("ourProperties")}
|
||||
</NavLink>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<NavLink href="/owner/properties">
|
||||
<NavLink light={isHomePage} href="/owner/properties">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building className="w-4 h-4" />
|
||||
عقاراتي
|
||||
{t("myProperties")}
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/bookings">
|
||||
<NavLink light={isHomePage} href="/owner/reservations">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
الحجوزات
|
||||
{t("reservations")}
|
||||
</span>
|
||||
</NavLink>
|
||||
{/* <NavLink href="/owner/calendar">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
التقويم
|
||||
</span>
|
||||
</NavLink> */}
|
||||
<NavLink href="/owner/profits">
|
||||
<NavLink light={isHomePage} href="/owner/profits">
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
الأرباح
|
||||
{t("profits")}
|
||||
</span>
|
||||
</NavLink>
|
||||
</>
|
||||
@ -271,16 +240,16 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
{!user && !isAuthPage && (
|
||||
<>
|
||||
<NavLink href="/login">
|
||||
<NavLink light={isHomePage} href="/login">
|
||||
<span className="flex items-center gap-2">
|
||||
<LogIn className="w-4 h-4" />
|
||||
تسجيل الدخول
|
||||
{t("login")}
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/auth/choose-role">
|
||||
<NavLink light={isHomePage} href="/auth/choose-role">
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
إنشاء حساب
|
||||
{t("createAccount")}
|
||||
</span>
|
||||
</NavLink>
|
||||
</>
|
||||
@ -319,49 +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="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">
|
||||
{getUserInitial()}
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center text-xl font-bold">{getUserInitial()}</div>
|
||||
<div>
|
||||
<p className="font-bold">
|
||||
{user?.name || "مستخدم"}
|
||||
</p>
|
||||
<p className="text-xs text-amber-100">
|
||||
{user?.email || ""}
|
||||
</p>
|
||||
<p className="text-xs text-amber-100 mt-1">
|
||||
{UserRoleLabels[user?.role] || 'زائر'}
|
||||
</p>
|
||||
<p className="font-bold">{user?.name || t("userLabel")}</p>
|
||||
<p className="text-xs text-amber-100">{user?.email || ""}</p>
|
||||
<p className="text-xs text-amber-100 mt-1">{t(UserRoleLabels[user?.role] ? `userRole.${user?.role.toLowerCase()}` : "visitor")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<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)}
|
||||
>
|
||||
<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)}>
|
||||
<UserCircle className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الملف الشخصي</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
عرض وتعديل معلوماتك
|
||||
</p>
|
||||
<p className="font-medium">{t("profile")}</p>
|
||||
<p className="text-xs text-gray-500">{t("viewAndEditInfo")}</p>
|
||||
</div>
|
||||
</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>
|
||||
</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"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Heart className="w-5 h-5 text-amber-500" />
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">المفضلة</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
العقارات المحفوظة
|
||||
</p>
|
||||
<p className="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="/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>
|
||||
</Link>
|
||||
|
||||
@ -376,10 +353,8 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<Building className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">عقاراتي</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة عقاراتك
|
||||
</p>
|
||||
<p className="font-medium">{t("myProperties")}</p>
|
||||
<p className="text-xs text-gray-500">{t("manageYourProperties")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -390,24 +365,20 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<PlusCircle className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">إضافة عقار</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
أضف عقاراً جديداً
|
||||
</p>
|
||||
<p className="font-medium">{t("addProperty")}</p>
|
||||
<p className="text-xs text-gray-500">{t("addNewProperty")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/owner/bookings"
|
||||
href="/owner/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الحجوزات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة حجوزاتك
|
||||
</p>
|
||||
<p className="font-medium">{t("reservations")}</p>
|
||||
<p className="text-xs text-gray-500">{t("manageReservations")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -418,10 +389,8 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">التقويم</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
جدول توفر العقارات
|
||||
</p>
|
||||
<p className="font-medium">{t("calendar")}</p>
|
||||
<p className="text-xs text-gray-500">{t("propertyAvailability")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -432,86 +401,20 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<TrendingUp className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الأرباح</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إحصائيات وأرباح
|
||||
</p>
|
||||
</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>
|
||||
<p className="font-medium">{t("profits")}</p>
|
||||
<p className="text-xs text-gray-500">{t("statisticsAndProfits")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/users"
|
||||
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"
|
||||
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)}
|
||||
>
|
||||
<DollarSign className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">دفتر الحسابات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة المعاملات المالية
|
||||
</p>
|
||||
<p className="font-medium">{t("accountBook")}</p>
|
||||
<p className="text-xs text-gray-500">{t("financialRecords")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -522,16 +425,46 @@ export default function ClientLayout({ children }) {
|
||||
<div className="border-t border-gray-100 my-2"></div>
|
||||
|
||||
<Link
|
||||
href="/tenant/bookings"
|
||||
href="/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">حجوزاتي</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
عرض حجوزاتك
|
||||
</p>
|
||||
<p className="font-medium">{t("myBookings")}</p>
|
||||
<p className="text-xs text-gray-500">{t("viewBookings")}</p>
|
||||
</div>
|
||||
</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>
|
||||
</Link>
|
||||
</>
|
||||
@ -539,16 +472,11 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
<div className="border-t border-gray-100 my-2"></div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<LogOut className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="font-medium">تسجيل الخروج</p>
|
||||
<p className="text-xs text-red-400">
|
||||
إنهاء الجلسة الحالية
|
||||
</p>
|
||||
<p className="font-medium">{t("logout")}</p>
|
||||
<p className="text-xs text-red-400">{t("endSession")}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -563,9 +491,7 @@ export default function ClientLayout({ children }) {
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 360 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() =>
|
||||
changeLanguage(currentLanguage === "en" ? "ar" : "en")
|
||||
}
|
||||
onClick={() => changeLanguage(currentLanguage === "en" ? "ar" : "en")}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<Globe className="w-5 h-5 text-gray-700" />
|
||||
@ -586,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"
|
||||
>
|
||||
<span className="sr-only">{t("openMainMenu")}</span>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -601,79 +523,66 @@ export default function ClientLayout({ children }) {
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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">
|
||||
<MobileNavLink href="/" onClick={closeMobileMenu}>
|
||||
<MobileNavLink light={isHomePage} href="/" onClick={closeMobileMenu}>
|
||||
{t("home")}
|
||||
</MobileNavLink>
|
||||
<MobileNavLink href="/properties" onClick={closeMobileMenu}>
|
||||
<MobileNavLink light={isHomePage} href="/properties" onClick={closeMobileMenu}>
|
||||
{t("ourProducts")}
|
||||
</MobileNavLink>
|
||||
|
||||
{/* Download App - Mobile */}
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">تحميل التطبيق</p>
|
||||
<a href="/files/SweetHome.apk" download onClick={closeMobileMenu}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-green-600 hover:bg-green-50 transition-colors">
|
||||
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||
<p className={`px-3 py-1 text-xs font-medium ${isHomePage ? "text-white/50" : "text-gray-400"}`}>{t("downloadApp")}</p>
|
||||
<a
|
||||
href="/files/SweetHome.apk"
|
||||
download
|
||||
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"/>
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Android - تحميل APK</span>
|
||||
<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"/>
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z" />
|
||||
</svg>
|
||||
<span className="text-sm">iOS - قريباً</span>
|
||||
<span className="text-sm">
|
||||
{t("ios")} - {t("comingSoon")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
|
||||
{isAdmin && (
|
||||
<MobileNavLink href="/admin" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
الإدارة
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
)}
|
||||
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<MobileNavLink
|
||||
href="/owner/properties"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<MobileNavLink light={isHomePage} href="/owner/properties" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Building className="w-4 h-4" />
|
||||
عقاراتي
|
||||
{t("myProperties")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/owner/bookings"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<MobileNavLink light={isHomePage} href="/owner/reservations" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
الحجوزات
|
||||
{t("reservations")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/owner/calendar"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<MobileNavLink light={isHomePage} href="/owner/calendar" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
التقويم
|
||||
{t("calendar")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/owner/profits"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<MobileNavLink light={isHomePage} href="/owner/profits" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
الأرباح
|
||||
{t("profits")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
</>
|
||||
@ -681,20 +590,17 @@ export default function ClientLayout({ children }) {
|
||||
|
||||
{!user && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-2"></div>
|
||||
<MobileNavLink href="/login" onClick={closeMobileMenu}>
|
||||
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||
<MobileNavLink light={isHomePage} href="/login" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<LogIn className="w-4 h-4" />
|
||||
تسجيل الدخول
|
||||
{t("login")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/auth/choose-role"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<MobileNavLink light={isHomePage} href="/auth/choose-role" onClick={closeMobileMenu}>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
إنشاء حساب
|
||||
{t("createAccount")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
</>
|
||||
@ -705,76 +611,108 @@ export default function ClientLayout({ children }) {
|
||||
</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
|
||||
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" : ""}`}
|
||||
>
|
||||
<FavoritesProvider>
|
||||
{children}
|
||||
{!isAdmin && <FloatingSidebar isRTL={currentLanguage === 'ar'} />}
|
||||
</FavoritesProvider>
|
||||
{!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>
|
||||
|
||||
{!isAuthPage && !isProfilePage && (
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{pathname === "/" && (
|
||||
<footer className="py-20" style={{backgroundColor:"#0f172a"}}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-4 gap-8 ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||
>
|
||||
<div className={`grid grid-cols-1 md:grid-cols-4 gap-8 ${currentLanguage === "ar" ? "text-right" : "text-left"}`}>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`flex items-center ${currentLanguage === "ar" ? "flex-row-reverse" : "space-x-3"}`}
|
||||
>
|
||||
<div className={`flex items-center ${currentLanguage === "ar" ? "flex-row-reverse" : "space-x-3"}`}>
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={t("logoAlt")}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="40px"
|
||||
/>
|
||||
<Image src="/logo.png" alt={t("logoAlt")} fill className="object-contain" sizes="40px" />
|
||||
</div>
|
||||
<span className="text-3xl font-bold">
|
||||
{t("brandNamePart1")}
|
||||
<span className="text-amber-400">
|
||||
{t("brandNamePart2")}
|
||||
</span>
|
||||
<span className="text-amber-400">{t("brandNamePart2")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{t("footerDescription")}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">{t("footerDescription")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("quickLinks")}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("quickLinks")}</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
||||
>
|
||||
<Link href="/" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||
{t("home")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/properties"
|
||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
||||
>
|
||||
<Link href="/properties" className="text-gray-400 hover:text-white transition-colors block py-1">
|
||||
{t("ourProducts")}
|
||||
</Link>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-gray-400 hover:text-white transition-colors block py-1"
|
||||
>
|
||||
الإدارة
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -782,7 +720,9 @@ export default function ClientLayout({ children }) {
|
||||
<ul className="space-y-3 text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="w-5 h-5" />
|
||||
<span dir="ltr" className="text-right">{t("phone")}</span>
|
||||
<span dir="ltr" className="text-right">
|
||||
{t("phone")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
@ -800,6 +740,6 @@ export default function ClientLayout({ children }) {
|
||||
</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,27 +0,0 @@
|
||||
'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,14 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Home,
|
||||
Calendar,
|
||||
Users,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Bell,
|
||||
Frown
|
||||
} 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 AuthService from '../services/AuthService';
|
||||
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 [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
// ─── 404 for non-admins ───
|
||||
if (checked && !isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center max-w-md"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<svg viewBox="0 0 200 180" className="w-72 h-52 mx-auto">
|
||||
<circle cx="100" cy="70" r="60" fill="#fef3c7" />
|
||||
<circle cx="80" cy="60" r="8" fill="#92400e" />
|
||||
<circle cx="120" cy="60" r="8" fill="#92400e" />
|
||||
<path d="M80 85 Q100 75 120 85" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
<text x="100" y="140" textAnchor="middle" fontSize="16" fontWeight="bold" fill="#6b7280">عذراً!</text>
|
||||
<text x="100" y="160" textAnchor="middle" fontSize="12" fill="#9ca3af">الصفحة غير موجودة</text>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">404 - الصفحة غير موجودة</h2>
|
||||
<p className="text-gray-500 mb-8">عذراً، لا يمكنك الوصول إلى هذه الصفحة</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ 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">
|
||||
<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" />
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,9 +2,12 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
@ -35,7 +38,8 @@ export default function ChooseRolePage() {
|
||||
};
|
||||
|
||||
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">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div
|
||||
@ -77,7 +81,7 @@ export default function ChooseRolePage() {
|
||||
<motion.div whileHover={{ x: -5 }}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
<span>العودة للرئيسية</span>
|
||||
<span>{t('backToHome')}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
@ -86,17 +90,17 @@ export default function ChooseRolePage() {
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<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}
|
||||
>
|
||||
مرحباً بك في SweetHome
|
||||
{t('chooseRole.welcomeToSweetHome')}
|
||||
</motion.h1>
|
||||
<p className="text-xl text-gray-400">
|
||||
اختر نوع الحساب المناسب لك
|
||||
{t('chooseRole.subtitle')}
|
||||
</p>
|
||||
</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
|
||||
variants={itemVariants}
|
||||
whileHover={{ scale: 1.05, y: -5 }}
|
||||
@ -123,23 +127,23 @@ export default function ChooseRolePage() {
|
||||
<Building className="w-10 h-10 text-white" />
|
||||
</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">
|
||||
تريد عرض عقار للإيجار أو البيع؟ انضم كمالك عقار وابدأ الآن
|
||||
{t('chooseRole.ownerDesc')}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2 text-sm text-amber-100">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
إضافة عقارات غير محدودة
|
||||
{t('chooseRole.ownerFeature1')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
إدارة الحجوزات بسهولة
|
||||
{t('chooseRole.ownerFeature2')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
تواصل مباشر مع المستأجرين
|
||||
{t('chooseRole.ownerFeature3')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -148,7 +152,7 @@ export default function ChooseRolePage() {
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
إنشاء حساب مالك
|
||||
{t('chooseRole.createOwnerAccount')}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
@ -181,23 +185,23 @@ export default function ChooseRolePage() {
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
</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">
|
||||
تبحث عن عقار للإيجار أو الشراء؟ انضم كعميل وابحث عن منزل أحلامك
|
||||
{t('chooseRole.tenantDesc')}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2 text-sm text-blue-100">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
آلاف العقارات المتاحة
|
||||
{t('chooseRole.tenantFeature1')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
بحث متقدم بفلاتر ذكية
|
||||
{t('chooseRole.tenantFeature2')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
حجز وإستئجار فوري
|
||||
{t('chooseRole.tenantFeature3')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -206,7 +210,65 @@ export default function ChooseRolePage() {
|
||||
whileHover={{ scale: 1.02 }}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -218,12 +280,12 @@ export default function ChooseRolePage() {
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-400 mt-8"
|
||||
>
|
||||
لديك حساب بالفعل؟{' '}
|
||||
{t('chooseRole.alreadyHaveAccount')}{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
{t('login')}
|
||||
</Link>
|
||||
</motion.p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -3,11 +3,16 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { Heart, Bell, CreditCard } from 'lucide-react';
|
||||
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;
|
||||
|
||||
@ -22,15 +27,16 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const side = isRTL ? 'left' : 'right';
|
||||
const positionStyle = {
|
||||
[side]: 0,
|
||||
if (!AuthService.isAuthenticated()) return null;
|
||||
|
||||
const positionStyle = {
|
||||
left: '16px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
initial: { opacity: 0, x: isRTL ? -20 : 20 },
|
||||
initial: { opacity: 0, x: 20 },
|
||||
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: 'easeOut' } },
|
||||
};
|
||||
|
||||
@ -40,15 +46,24 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
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-50"
|
||||
className="fixed z-40 pointer-events-none"
|
||||
style={positionStyle}
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/50 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/90">
|
||||
<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}
|
||||
@ -60,35 +75,22 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
>
|
||||
<Link
|
||||
href="/favorites"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
className="flex items-center justify-center w-14 h-14 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
<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-5 h-5 bg-gradient-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
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>
|
||||
{tooltip === 'favorites' && (
|
||||
<div
|
||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||
>
|
||||
<span className="relative">
|
||||
المفضلة
|
||||
<span
|
||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{renderTooltip('favorites', t('favorites'))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
@ -101,33 +103,22 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
className="flex items-center justify-center w-14 h-14 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-gradient-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
3
|
||||
</motion.span>
|
||||
<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>
|
||||
{tooltip === 'notifications' && (
|
||||
<div
|
||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||
>
|
||||
<span className="relative">
|
||||
الإشعارات
|
||||
<span
|
||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{renderTooltip('notifications', t('notifications'))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
@ -140,26 +131,30 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
>
|
||||
<Link
|
||||
href="/payments"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
className="flex items-center justify-center w-14 h-14 rounded-xl transition-colors"
|
||||
>
|
||||
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
<CreditCard className="w-7 h-7 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
</Link>
|
||||
{tooltip === 'payments' && (
|
||||
<div
|
||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||
>
|
||||
<span className="relative">
|
||||
المدفوعات
|
||||
<span
|
||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{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 { LogIn, UserPlus } from 'lucide-react';
|
||||
|
||||
export function NavLink({ href, children }) {
|
||||
export function NavLink({ href, children, light }) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href || pathname.startsWith(href + '/');
|
||||
return (
|
||||
@ -11,19 +11,19 @@ export function NavLink({ href, children }) {
|
||||
href={href}
|
||||
className={`px-5 py-3 rounded-lg font-semibold transition-all duration-300 text-lg relative group ${
|
||||
isActive
|
||||
? 'text-amber-600 bg-amber-50'
|
||||
: 'text-gray-700 hover:text-amber-600 hover:bg-gray-100'
|
||||
? light ? 'text-amber-300 bg-white/10' : 'text-amber-600 bg-amber-50'
|
||||
: light ? 'text-white/90 hover:text-amber-300 hover:bg-white/10' : 'text-gray-700 hover:text-amber-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
<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>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavLink({ href, children, onClick }) {
|
||||
export function MobileNavLink({ href, children, onClick, light }) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href || pathname.startsWith(href + '/');
|
||||
return (
|
||||
@ -32,8 +32,8 @@ export function MobileNavLink({ href, children, onClick }) {
|
||||
onClick={onClick}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-amber-600 bg-amber-50'
|
||||
: 'text-gray-700 hover:text-amber-600 hover:bg-gray-50'
|
||||
? light ? 'text-amber-300 bg-white/10' : 'text-amber-600 bg-amber-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}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"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",
|
||||
@ -15,6 +16,7 @@ const firebaseConfig = {
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
//عم نعمل تهيئة للاعدادات
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
export default function NotificationHandler() {
|
||||
@ -23,22 +25,40 @@ export default function NotificationHandler() {
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
function checkAuth() {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
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);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// 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() {
|
||||
@ -52,21 +72,7 @@ export default function NotificationHandler() {
|
||||
});
|
||||
|
||||
if (fcmToken) {
|
||||
console.log("[FCM] Token:", fcmToken.substring(0, 20) + "...");
|
||||
|
||||
const authToken = AuthService.getToken();
|
||||
if (authToken) {
|
||||
const apiBase = "https://45.93.137.91.nip.io/api";
|
||||
await fetch(`${apiBase}/User/SetFCMToken`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token: fcmToken, deviceType: 2 }),
|
||||
});
|
||||
console.log("[FCM] Token sent to backend");
|
||||
}
|
||||
await setFCMToken(fcmToken, 2);
|
||||
}
|
||||
|
||||
onMessage(messaging, (payload) => {
|
||||
@ -85,7 +91,6 @@ export default function NotificationHandler() {
|
||||
|
||||
// This MUST be synchronous from a user gesture
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log("[FCM] Permission result:", permission);
|
||||
|
||||
if (permission === "granted") {
|
||||
await setupFCM();
|
||||
@ -143,4 +148,4 @@ export default function NotificationHandler() {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
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,356 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
||||
import { CommissionType, City, CitiesList } from '@/app/enums';
|
||||
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: CommissionType.FROM_OWNER,
|
||||
|
||||
securityDeposit: 0,
|
||||
|
||||
images: [],
|
||||
features: [],
|
||||
|
||||
status: 'available'
|
||||
});
|
||||
|
||||
const [selectedFeatures, setSelectedFeatures] = useState([]);
|
||||
|
||||
const featuresList = [
|
||||
'مسبح',
|
||||
'حديقة خاصة',
|
||||
'موقف سيارات',
|
||||
'مطبخ مجهز',
|
||||
'تدفئة مركزية',
|
||||
'بلكونة',
|
||||
'نظام أمني',
|
||||
'حديقة كبيرة',
|
||||
'صالة استقبال',
|
||||
'غرفة خادمة',
|
||||
'كراج',
|
||||
'إطلالة بحرية',
|
||||
'تكييف مركزي',
|
||||
'مخزن'
|
||||
];
|
||||
|
||||
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 CommissionType.FROM_TENANT:
|
||||
return dailyPrice + commission;
|
||||
case CommissionType.FROM_OWNER:
|
||||
return dailyPrice;
|
||||
case CommissionType.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={CommissionType.FROM_OWNER}
|
||||
checked={formData.commissionType === CommissionType.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={CommissionType.FROM_TENANT}
|
||||
checked={formData.commissionType === CommissionType.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={CommissionType.FROM_BOTH}
|
||||
checked={formData.commissionType === CommissionType.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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,607 +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,
|
||||
FileText,
|
||||
Printer,
|
||||
X,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { formatCurrency } from '@/app/utils/calculations';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export default function LedgerBook({ userType = 'admin' }) {
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
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
|
||||
});
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
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 = async () => {
|
||||
if (filteredTransactions.length === 0) {
|
||||
toast.error('لا توجد معاملات للتصدير');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
toast.loading('جاري تصدير البيانات...', { id: 'export' });
|
||||
|
||||
try {
|
||||
const exportData = filteredTransactions.map(t => ({
|
||||
'رقم العملية': t.id,
|
||||
'التاريخ': t.date,
|
||||
'نوع العملية': t.type === 'rent_payment' ? 'دفعة إيجار' :
|
||||
t.type === 'security_deposit' ? 'سلفة ضمان' :
|
||||
t.type === 'commission' ? 'عمولة' : 'أخرى',
|
||||
'الوصف': t.description,
|
||||
'من': t.fromUser,
|
||||
'إلى': t.toUser,
|
||||
'المبلغ (ل.س)': t.amount,
|
||||
'العمولة (ل.س)': t.commission || 0,
|
||||
'الحالة': t.status === 'completed' ? 'مكتمل' :
|
||||
t.status === 'pending' ? 'معلق' :
|
||||
t.status === 'pending_refund' ? 'بإنتظار الاسترداد' : 'مؤكد',
|
||||
}));
|
||||
|
||||
const summaryRow = {
|
||||
'رقم العملية': '',
|
||||
'التاريخ': '',
|
||||
'نوع العملية': '',
|
||||
'الوصف': '',
|
||||
'من': '',
|
||||
'إلى': '',
|
||||
'المبلغ (ل.س)': summary.totalRevenue,
|
||||
'العمولة (ل.س)': summary.commissionEarned,
|
||||
'الحالة': ''
|
||||
};
|
||||
|
||||
exportData.push(summaryRow);
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
|
||||
const columnWidths = [
|
||||
{ wch: 12 }, // رقم العملية
|
||||
{ wch: 12 }, // التاريخ
|
||||
{ wch: 12 }, // نوع العملية
|
||||
{ wch: 30 }, // الوصف
|
||||
{ wch: 20 }, // من
|
||||
{ wch: 20 }, // إلى
|
||||
{ wch: 15 }, // المبلغ
|
||||
{ wch: 15 }, // العمولة
|
||||
{ wch: 12 }, // الحالة
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref']);
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const address = XLSX.utils.encode_col(C) + '1';
|
||||
if (!worksheet[address]) continue;
|
||||
worksheet[address].s = {
|
||||
font: { bold: true, sz: 12 },
|
||||
fill: { fgColor: { rgb: "F59E0B" } },
|
||||
alignment: { horizontal: "center", vertical: "center" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'دفتر الحسابات');
|
||||
|
||||
const fileName = `دفتر_الحسابات_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
|
||||
toast.success(`تم تصدير ${filteredTransactions.length} معاملة بنجاح!`, { id: 'export' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting to Excel:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير البيانات', { id: 'export' });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const printReport = () => {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>تقرير دفتر الحسابات</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Cairo', Arial, sans-serif;
|
||||
padding: 20px;
|
||||
direction: rtl;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #f59e0b;
|
||||
}
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.summary-card {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #f59e0b;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
th {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="title">تقرير دفتر الحسابات</div>
|
||||
<div class="subtitle">الفترة: ${dateRange.start || 'بداية السجلات'} - ${dateRange.end || 'حتى الآن'}</div>
|
||||
<div class="subtitle">تاريخ التقرير: ${new Date().toLocaleDateString('ar-SA')}</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div>إجمالي الإيرادات</div>
|
||||
<div class="summary-value">${formatCurrency(summary.totalRevenue)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>أرباح المنصة</div>
|
||||
<div class="summary-value">${formatCurrency(summary.commissionEarned)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>سلف الضمان</div>
|
||||
<div class="summary-value">${formatCurrency(summary.securityDeposits)}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div>المدفوعات المعلقة</div>
|
||||
<div class="summary-value">${formatCurrency(summary.pendingPayments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>التاريخ</th>
|
||||
<th>الوصف</th>
|
||||
<th>من</th>
|
||||
<th>إلى</th>
|
||||
<th>المبلغ</th>
|
||||
<th>العمولة</th>
|
||||
<th>الحالة</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${filteredTransactions.map(t => `
|
||||
<tr>
|
||||
<td>${t.date}</td>
|
||||
<td>${t.description}</td>
|
||||
<td>${t.fromUser}</td>
|
||||
<td>${t.toUser}</td>
|
||||
<td>${formatCurrency(t.amount)}</td>
|
||||
<td>${t.commission ? formatCurrency(t.commission) : '-'}</td>
|
||||
<td>${t.status === 'completed' ? 'مكتمل' : t.status === 'pending' ? 'معلق' : 'بإنتظار الرد'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<p>تقرير صادر عن نظام SweetHome لإدارة العقارات</p>
|
||||
<p>جميع الحقوق محفوظة © ${new Date().getFullYear()}</p>
|
||||
</div>
|
||||
|
||||
<div class="no-print" style="text-align: center; margin-top: 20px;">
|
||||
<button onclick="window.print()" style="padding: 10px 20px; background: #f59e0b; color: white; border: none; border-radius: 8px; cursor: pointer;">
|
||||
طباعة التقرير
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
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-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث في المعاملات..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-500 self-center">إلى</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
|
||||
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
disabled={isExporting || filteredTransactions.length === 0}
|
||||
className="px-5 py-3 bg-green-600 text-white rounded-xl flex items-center gap-2 hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
جاري التصدير...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير Excel
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={printReport}
|
||||
disabled={filteredTransactions.length === 0}
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-xl flex items-center gap-2 hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
طباعة
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(dateRange.start || dateRange.end || searchTerm) && (
|
||||
<div className="mt-4 pt-4 border-t flex justify-between items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="font-medium">{filteredTransactions.length}</span> معاملة من إجمالي <span className="font-medium">{transactions.length}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDateRange({ start: '', end: '' });
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className="text-sm text-red-500 hover:text-red-600 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
إلغاء الفلترة
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<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 text-green-600">
|
||||
{formatCurrency(transaction.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-amber-600">
|
||||
{transaction.commission ? formatCurrency(transaction.commission) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
transaction.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
transaction.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{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">
|
||||
<p className="text-gray-500 text-sm">لا توجد أرصدة حالياً</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,636 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
MapPin,
|
||||
Bed,
|
||||
Bath,
|
||||
Square,
|
||||
DollarSign,
|
||||
Percent,
|
||||
MoreVertical,
|
||||
X,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
User,
|
||||
Home,
|
||||
Building,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">تأكيد الحذف</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
هل أنت متأكد من حذف العقار: <span className="font-bold text-gray-700">"{propertyTitle}"</span>؟
|
||||
</p>
|
||||
<p className="text-xs text-red-500 mt-1">هذا الإجراء لا يمكن التراجع عنه</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
نعم، احذف
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyViewModal = ({ property, isOpen, onClose }) => {
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{property.title}</h2>
|
||||
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Home className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.type === 'villa' ? 'فيلا' : property.type === 'apartment' ? 'شقة' : 'بيت'}</div>
|
||||
<div className="text-xs text-gray-500">نوع العقار</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">السعر اليومي</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Percent className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.commission}%</div>
|
||||
<div className="text-xs text-gray-500">نسبة العمولة</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Calendar className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bookings || 0}</div>
|
||||
<div className="text-xs text-gray-500">عدد الحجوزات</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-amber-500" />
|
||||
الموقع
|
||||
</h3>
|
||||
<p className="text-gray-700">{property.location}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3">المواصفات</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.bedrooms}</div>
|
||||
<div className="text-xs text-gray-500">غرف نوم</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.bathrooms}</div>
|
||||
<div className="text-xs text-gray-500">حمامات</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-lg font-bold">{property.area}</div>
|
||||
<div className="text-xs text-gray-500">م²</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2">
|
||||
<Percent className="w-5 h-5" />
|
||||
معلومات العمولة
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">نسبة العمولة</label>
|
||||
<div className="font-bold text-amber-600">{property.commission}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">مصدر العمولة</label>
|
||||
<div className="font-bold text-amber-600">{property.commissionType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">قيمة العمولة</label>
|
||||
<div className="font-bold text-amber-600">
|
||||
{formatCurrency((property.price * property.commission) / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">حالة العقار</label>
|
||||
<div className={`inline-block px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
property.status === 'available'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{property.status === 'available' ? 'متاح' : 'محجوز'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyEditModal = ({ property, isOpen, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({ ...property });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
onSave(formData);
|
||||
setIsSaving(false);
|
||||
onClose();
|
||||
toast.success('تم تحديث العقار بنجاح');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">تعديل العقار</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-amber-100 text-sm mt-1">يمكنك تعديل معلومات العقار</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
اسم العقار
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع العقار
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({...formData, type: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="villa">فيلا</option>
|
||||
<option value="apartment">شقة</option>
|
||||
<option value="house">بيت</option>
|
||||
<option value="studio">استوديو</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
الموقع
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({...formData, location: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
السعر اليومي (ل.س)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({...formData, price: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نسبة العمولة (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.commission}
|
||||
onChange={(e) => setFormData({...formData, commission: parseFloat(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
مصدر العمولة
|
||||
</label>
|
||||
<select
|
||||
value={formData.commissionType}
|
||||
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="من المالك">من المالك</option>
|
||||
<option value="من المستأجر">من المستأجر</option>
|
||||
<option value="من الاثنين">من الاثنين</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الغرف
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.bedrooms}
|
||||
onChange={(e) => setFormData({...formData, bedrooms: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الحمامات
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.bathrooms}
|
||||
onChange={(e) => setFormData({...formData, bathrooms: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
المساحة (م²)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.area}
|
||||
onChange={(e) => setFormData({...formData, area: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حالة العقار
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="available">متاح</option>
|
||||
<option value="booked">محجوز</option>
|
||||
<option value="maintenance">صيانة</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex-1 bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MoreActionsMenu = ({ property, isOpen, onClose, onViewBookings, onViewReports }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="absolute left-0 mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50"
|
||||
>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function PropertiesTable() {
|
||||
const [properties, setProperties] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
type: 'villa',
|
||||
location: 'دمشق, المزة',
|
||||
price: 500000,
|
||||
commission: 5,
|
||||
commissionType: 'من المالك',
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
status: 'available',
|
||||
bookings: 3
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
type: 'apartment',
|
||||
location: 'حلب, الشهباء',
|
||||
price: 250000,
|
||||
commission: 7,
|
||||
commissionType: 'من المستأجر',
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
status: 'booked',
|
||||
bookings: 1
|
||||
}
|
||||
]);
|
||||
|
||||
const [viewModal, setViewModal] = useState({ isOpen: false, property: null });
|
||||
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, property: null });
|
||||
const [moreMenu, setMoreMenu] = useState({ isOpen: false, property: null, anchorEl: null });
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const handleView = (property) => {
|
||||
setViewModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const handleEdit = (property) => {
|
||||
setEditModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const handleDelete = (property) => {
|
||||
setDeleteModal({ isOpen: true, property });
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteModal.property) {
|
||||
setProperties(prev => prev.filter(p => p.id !== deleteModal.property.id));
|
||||
setDeleteModal({ isOpen: false, property: null });
|
||||
toast.success('تم حذف العقار بنجاح');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = (updatedProperty) => {
|
||||
setProperties(prev => prev.map(p =>
|
||||
p.id === updatedProperty.id ? updatedProperty : p
|
||||
));
|
||||
toast.success('تم تحديث العقار بنجاح');
|
||||
};
|
||||
|
||||
const handleMoreClick = (event, property) => {
|
||||
event.stopPropagation();
|
||||
setMoreMenu({ isOpen: true, property, anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
const handleViewBookings = (property) => {
|
||||
toast.success(`جاري عرض حجوزات ${property.title}`);
|
||||
};
|
||||
|
||||
const handleViewReports = (property) => {
|
||||
toast.success(`جاري عرض تقرير أرباح ${property.title}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<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 === 'villa' ? 'فيلا' :
|
||||
property.type === 'apartment' ? 'شقة' :
|
||||
property.type === 'house' ? 'بيت' : 'استوديو'}
|
||||
</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 relative">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(property)}
|
||||
className="p-1 hover:bg-blue-100 rounded text-blue-600 transition-colors"
|
||||
title="عرض التفاصيل"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(property)}
|
||||
className="p-1 hover:bg-amber-100 rounded text-amber-600 transition-colors"
|
||||
title="تعديل العقار"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(property)}
|
||||
className="p-1 hover:bg-red-100 rounded text-red-600 transition-colors"
|
||||
title="حذف العقار"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
{moreMenu.isOpen && moreMenu.property?.id === property.id && (
|
||||
<MoreActionsMenu
|
||||
property={property}
|
||||
isOpen={moreMenu.isOpen}
|
||||
onClose={() => setMoreMenu({ isOpen: false, property: null, anchorEl: null })}
|
||||
onViewBookings={handleViewBookings}
|
||||
onViewReports={handleViewReports}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<PropertyViewModal
|
||||
property={viewModal.property}
|
||||
isOpen={viewModal.isOpen}
|
||||
onClose={() => setViewModal({ isOpen: false, property: null })}
|
||||
/>
|
||||
|
||||
<PropertyEditModal
|
||||
property={editModal.property}
|
||||
isOpen={editModal.isOpen}
|
||||
onClose={() => setEditModal({ isOpen: false, property: null })}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, property: null })}
|
||||
onConfirm={confirmDelete}
|
||||
propertyTitle={deleteModal.property?.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,773 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Calendar,
|
||||
Home,
|
||||
DollarSign,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
Users,
|
||||
Award,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const FilterDialog = ({ isOpen, onClose, filters, onApplyFilters, onResetFilters }) => {
|
||||
const [localFilters, setLocalFilters] = useState({ ...filters });
|
||||
|
||||
const identityTypes = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'syrian', label: 'هوية سورية' },
|
||||
{ id: 'passport', label: 'جواز سفر' }
|
||||
];
|
||||
|
||||
const bookingRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: '0-5', label: '0 - 5 حجوزات' },
|
||||
{ id: '5-10', label: '5 - 10 حجوزات' },
|
||||
{ id: '10-20', label: '10 - 20 حجوزات' },
|
||||
{ id: '20+', label: 'أكثر من 20 حجز' }
|
||||
];
|
||||
|
||||
const spendingRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: '0-500000', label: 'أقل من 500,000 ل.س' },
|
||||
{ id: '500000-1000000', label: '500,000 - 1,000,000 ل.س' },
|
||||
{ id: '1000000-5000000', label: '1,000,000 - 5,000,000 ل.س' },
|
||||
{ id: '5000000+', label: 'أكثر من 5,000,000 ل.س' }
|
||||
];
|
||||
|
||||
const dateRanges = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'today', label: 'اليوم' },
|
||||
{ id: 'week', label: 'آخر 7 أيام' },
|
||||
{ id: 'month', label: 'آخر 30 يوم' },
|
||||
{ id: 'year', label: 'آخر 12 شهر' }
|
||||
];
|
||||
|
||||
const applyFilters = () => {
|
||||
onApplyFilters(localFilters);
|
||||
onClose();
|
||||
toast.success('تم تطبيق الفلاتر بنجاح');
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
const resetData = {
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
};
|
||||
setLocalFilters(resetData);
|
||||
onResetFilters();
|
||||
onClose();
|
||||
toast.success('تم إعادة تعيين الفلاتر');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
تصفية متقدمة
|
||||
</h2>
|
||||
<p className="text-blue-100 text-sm mt-1">حدد معايير التصفية المطلوبة</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع الهوية
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{identityTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setLocalFilters({...localFilters, identityType: type.id})}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
localFilters.identityType === type.id
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الحجوزات
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="من"
|
||||
value={localFilters.minBookings}
|
||||
onChange={(e) => setLocalFilters({...localFilters, minBookings: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="إلى"
|
||||
value={localFilters.maxBookings}
|
||||
onChange={(e) => setLocalFilters({...localFilters, maxBookings: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bookingRanges.slice(1).map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => {
|
||||
const [min, max] = range.id.split('-');
|
||||
setLocalFilters({
|
||||
...localFilters,
|
||||
minBookings: min,
|
||||
maxBookings: max === '5' ? '5' : max === '10' ? '10' : max === '20' ? '20' : '1000'
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
إجمالي الإنفاق (ل.س)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="من"
|
||||
value={localFilters.minSpending}
|
||||
onChange={(e) => setLocalFilters({...localFilters, minSpending: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="إلى"
|
||||
value={localFilters.maxSpending}
|
||||
onChange={(e) => setLocalFilters({...localFilters, maxSpending: e.target.value})}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{spendingRanges.slice(1).map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => {
|
||||
const [min, max] = range.id.split('-');
|
||||
setLocalFilters({
|
||||
...localFilters,
|
||||
minSpending: min,
|
||||
maxSpending: max === '500000' ? '500000' : max === '1000000' ? '1000000' : max === '5000000' ? '5000000' : '999999999'
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
فترة التسجيل
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{dateRanges.map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => setLocalFilters({...localFilters, dateRange: range.id})}
|
||||
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
localFilters.dateRange === range.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localFilters.activeOnly}
|
||||
onChange={(e) => setLocalFilters({...localFilters, activeOnly: e.target.checked, inactiveOnly: false})}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">مستخدمون لديهم حجوزات نشطة فقط</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localFilters.inactiveOnly}
|
||||
onChange={(e) => setLocalFilters({...localFilters, inactiveOnly: e.target.checked, activeOnly: false})}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">مستخدمون بدون حجوزات نشطة</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إعادة تعيين
|
||||
</button>
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
تطبيق الفلاتر
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserDetailsModal = ({ user, isOpen, onClose }) => {
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const userBookings = [
|
||||
{
|
||||
id: 'BK001',
|
||||
property: 'فيلا فاخرة في المزة',
|
||||
startDate: '2024-03-10',
|
||||
endDate: '2024-03-15',
|
||||
amount: 2500000,
|
||||
status: 'completed'
|
||||
},
|
||||
{
|
||||
id: 'BK002',
|
||||
property: 'شقة حديثة في الشهباء',
|
||||
startDate: '2024-02-20',
|
||||
endDate: '2024-02-25',
|
||||
amount: 1250000,
|
||||
status: 'completed'
|
||||
},
|
||||
{
|
||||
id: 'BK003',
|
||||
property: 'بيت عائلي في بابا عمرو',
|
||||
startDate: '2024-04-01',
|
||||
endDate: '2024-04-10',
|
||||
amount: 3500000,
|
||||
status: 'confirmed'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-blue-700 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
تفاصيل المستخدم
|
||||
</h2>
|
||||
<p className="text-blue-100 text-sm mt-1">{user.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-blue-500" />
|
||||
معلومات شخصية
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">الاسم الكامل:</span>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">البريد الإلكتروني:</span>
|
||||
<span className="font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">رقم الهاتف:</span>
|
||||
<span className="font-medium">{user.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">تاريخ التسجيل:</span>
|
||||
<span className="font-medium">{user.joinDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-500" />
|
||||
معلومات الهوية
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">نوع الهوية:</span>
|
||||
<span className="font-medium">
|
||||
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">رقم الهوية:</span>
|
||||
<span className="font-medium">{user.identityNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي الحجوزات</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{user.activeBookings}</div>
|
||||
<div className="text-sm text-gray-600">حجوزات نشطة</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-amber-600">{formatCurrency(user.totalSpent)}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي المنصرف</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-blue-500" />
|
||||
سجل الحجوزات
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{userBookings.map((booking) => (
|
||||
<div key={booking.id} className="bg-gray-50 p-4 rounded-xl flex flex-col md:flex-row justify-between items-start md:items-center gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.property}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mt-1">
|
||||
<CalendarDays className="w-3 h-3" />
|
||||
{booking.startDate} - {booking.endDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.amount)}</div>
|
||||
<div className="text-xs text-gray-500">المبلغ الإجمالي</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
booking.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{booking.status === 'completed' ? 'مكتمل' : 'مؤكد'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{userBookings.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
||||
<p>لا توجد حجوزات سابقة</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function UsersList() {
|
||||
const [users, setUsers] = useState([
|
||||
{
|
||||
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 [showFilterDialog, setShowFilterDialog] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
});
|
||||
|
||||
const applyFilters = (newFilters) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
identityType: 'all',
|
||||
minBookings: '',
|
||||
maxBookings: '',
|
||||
minSpending: '',
|
||||
maxSpending: '',
|
||||
dateRange: 'all',
|
||||
activeOnly: false,
|
||||
inactiveOnly: false
|
||||
});
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user => {
|
||||
if (searchTerm && !user.name.includes(searchTerm) && !user.email.includes(searchTerm) && !user.phone.includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.identityType !== 'all' && user.identityType !== filters.identityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.minBookings && user.totalBookings < parseInt(filters.minBookings)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxBookings && user.totalBookings > parseInt(filters.maxBookings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.minSpending && user.totalSpent < parseInt(filters.minSpending)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxSpending && user.totalSpent > parseInt(filters.maxSpending)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.activeOnly && user.activeBookings === 0) {
|
||||
return false;
|
||||
}
|
||||
if (filters.inactiveOnly && user.activeBookings > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.dateRange !== 'all') {
|
||||
const joinDate = new Date(user.joinDate);
|
||||
const today = new Date();
|
||||
const diffDays = Math.floor((today - joinDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
switch(filters.dateRange) {
|
||||
case 'today':
|
||||
if (joinDate.toDateString() !== today.toDateString()) return false;
|
||||
break;
|
||||
case 'week':
|
||||
if (diffDays > 7) return false;
|
||||
break;
|
||||
case 'month':
|
||||
if (diffDays > 30) return false;
|
||||
break;
|
||||
case 'year':
|
||||
if (diffDays > 365) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const filterStats = {
|
||||
total: filteredUsers.length,
|
||||
filtered: filteredUsers.length !== users.length
|
||||
};
|
||||
|
||||
const getActiveFiltersCount = () => {
|
||||
let count = 0;
|
||||
if (filters.identityType !== 'all') count++;
|
||||
if (filters.minBookings || filters.maxBookings) count++;
|
||||
if (filters.minSpending || filters.maxSpending) count++;
|
||||
if (filters.dateRange !== 'all') count++;
|
||||
if (filters.activeOnly || filters.inactiveOnly) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث عن مستخدم بالاسم أو البريد أو الهاتف..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pr-12 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilterDialog(true)}
|
||||
className={`px-5 py-3 rounded-xl font-medium flex items-center gap-2 transition-all ${
|
||||
getActiveFiltersCount() > 0
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
تصفية متقدمة
|
||||
{getActiveFiltersCount() > 0 && (
|
||||
<span className="ml-1 bg-white text-blue-600 rounded-full w-5 h-5 text-xs flex items-center justify-center">
|
||||
{getActiveFiltersCount()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{filterStats.filtered && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="px-5 py-3 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
إعادة تعيين
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getActiveFiltersCount() > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-blue-50 rounded-xl">
|
||||
<span className="text-sm text-blue-800 font-medium">الفلاتر النشطة:</span>
|
||||
{filters.identityType !== 'all' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
{filters.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</span>
|
||||
)}
|
||||
{(filters.minBookings || filters.maxBookings) && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
الحجوزات: {filters.minBookings || '0'} - {filters.maxBookings || '∞'}
|
||||
</span>
|
||||
)}
|
||||
{(filters.minSpending || filters.maxSpending) && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
الإنفاق: {parseInt(filters.minSpending || 0).toLocaleString()} - {parseInt(filters.maxSpending || '∞').toLocaleString()} ل.س
|
||||
</span>
|
||||
)}
|
||||
{filters.dateRange !== 'all' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
|
||||
{filters.dateRange === 'today' ? 'اليوم' :
|
||||
filters.dateRange === 'week' ? 'آخر 7 أيام' :
|
||||
filters.dateRange === 'month' ? 'آخر 30 يوم' : 'آخر 12 شهر'}
|
||||
</span>
|
||||
)}
|
||||
{filters.activeOnly && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-lg text-xs">
|
||||
لديهم حجوزات نشطة
|
||||
</span>
|
||||
)}
|
||||
{filters.inactiveOnly && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
|
||||
بدون حجوزات نشطة
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
عرض <span className="font-bold text-gray-900">{filteredUsers.length}</span> مستخدم
|
||||
{filterStats.filtered && (
|
||||
<span className="text-gray-500 mr-1">(من {users.length})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredUsers.map((user, index) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white text-xl font-bold shadow-lg">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-lg">{user.name}</h3>
|
||||
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="w-4 h-4" />
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-4 h-4" />
|
||||
{user.phone}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="text-center min-w-[80px]">
|
||||
<div className="text-xl font-bold text-blue-600">{user.totalBookings}</div>
|
||||
<div className="text-xs text-gray-500">إجمالي الحجوزات</div>
|
||||
</div>
|
||||
<div className="text-center min-w-[80px]">
|
||||
<div className={`text-xl font-bold ${user.activeBookings > 0 ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{user.activeBookings}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">حجوزات نشطة</div>
|
||||
</div>
|
||||
<div className="text-center min-w-[100px]">
|
||||
<div className="text-xl 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-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
عرض التفاصيل
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد نتائج</h3>
|
||||
<p className="text-gray-500">لا يوجد مستخدمون يطابقون معايير البحث</p>
|
||||
{(searchTerm || getActiveFiltersCount() > 0) && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
إعادة تعيين الفلاتر
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<FilterDialog
|
||||
isOpen={showFilterDialog}
|
||||
onClose={() => setShowFilterDialog(false)}
|
||||
filters={filters}
|
||||
onApplyFilters={applyFilters}
|
||||
onResetFilters={resetFilters}
|
||||
/>
|
||||
|
||||
<UserDetailsModal
|
||||
user={selectedUser}
|
||||
isOpen={!!selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
|
||||
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 }) {
|
||||
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({
|
||||
city: '',
|
||||
propertyType: '',
|
||||
priceRange: '',
|
||||
identityType: 'syrian'
|
||||
city: "all",
|
||||
propertyType: "all",
|
||||
priceRange: "all",
|
||||
identityType: "syrian",
|
||||
ownerSource: "all",
|
||||
rentPeriod: "all",
|
||||
availableToday: false,
|
||||
});
|
||||
|
||||
const cities = [
|
||||
{ id: 'all', label: 'جميع المدن' },
|
||||
{ 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 [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
};
|
||||
const handleSearch = () => {
|
||||
onSearch({
|
||||
...filters,
|
||||
propertyType: filters.propertyType || 'all',
|
||||
city: filters.city || 'all',
|
||||
priceRange: filters.priceRange || 'all'
|
||||
mode: activeTab,
|
||||
city: filters.city || "all",
|
||||
propertyType: filters.propertyType || "all",
|
||||
priceRange: filters.priceRange || "all",
|
||||
ownerSource: filters.ownerSource || "all",
|
||||
rentPeriod: filters.rentPeriod || "all",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
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) => (
|
||||
<motion.button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||
activeTab === tab
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-white/20 text-white hover:bg-white/30'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{t(`${tab}Tab`)}
|
||||
</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>
|
||||
<>
|
||||
<motion.div
|
||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{["rent", "buy", "sell"].map((tab) => {
|
||||
const isActive = activeTab === tab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
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"}`}
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Home className="w-4 h-4" />
|
||||
{t("rentTypeLabel")}
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={filters.propertyType}
|
||||
onChange={(e) => setFilters({...filters, propertyType: 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'
|
||||
}}
|
||||
>
|
||||
{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
|
||||
{/* city */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<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"))} />
|
||||
{activeTab === "rent" && (
|
||||
<FilterSelect
|
||||
icon={<Home className="w-4 h-4" />}
|
||||
value={filters.propertyType}
|
||||
onChange={(e) => setFilters({ ...filters, city: e.target.value })}
|
||||
option={propertyTypes}
|
||||
label={t("rentTypeLabel")}
|
||||
/>
|
||||
)}
|
||||
{/* price */}
|
||||
<FilterSelect
|
||||
icon={<DollarSign className="w-4 h-4" />}
|
||||
value={filters.priceRange}
|
||||
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"
|
||||
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'
|
||||
}}
|
||||
>
|
||||
{priceRanges.map(range => (
|
||||
<option key={range.id} value={range.id}>{range.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(e) => setFilters({ ...filters, priceRange: e.target.value })}
|
||||
option={priceRanges}
|
||||
label={t("priceLabel")}
|
||||
/>
|
||||
{/* type */}
|
||||
<FilterSelect value={filters.identityType} onChange={(e) => setFilters({ ...filters, identityType: e.target.value })} option={identityTypes} label={t("identityTypeLabel")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
نوع الوثيقة
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={filters.identityType}
|
||||
onChange={(e) => setFilters({...filters, identityType: 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'
|
||||
}}
|
||||
>
|
||||
{identityTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
{/* owener */}
|
||||
<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 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`}>
|
||||
{/* checkbox */}
|
||||
<label className="mt-4 flex items-center gap-3 text-amber-50 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.availableToday}
|
||||
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||
/>
|
||||
<span className="font-medium">{t("availableToday")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
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"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
{t("searchButton")}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
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"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
{t("searchButton")}
|
||||
</motion.button>
|
||||
</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;
|
||||
@ -17,7 +17,11 @@ export const useFavorites = () => {
|
||||
function mapApiFavorite(item) {
|
||||
const info = item.propertyInformation || {};
|
||||
let details = {};
|
||||
try { details = JSON.parse(info.detailsJSON || '{}'); } catch {}
|
||||
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';
|
||||
|
||||
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);
|
||||
@ -1,38 +1,38 @@
|
||||
/**
|
||||
* BookingStatus Enum
|
||||
* Backend values are strings
|
||||
* Used in: Reservation workflow
|
||||
*/
|
||||
|
||||
const BookingStatus = Object.freeze({
|
||||
PENDING: 'pending',
|
||||
OWNER_APPROVED: 'owner_approved',
|
||||
ADMIN_APPROVED: 'admin_approved',
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
REJECTED: 'rejected',
|
||||
CANCELLED: 'cancelled',
|
||||
ownerConfirmed: 'ownerConfirmed',
|
||||
depositPaid: 'depositPaid',
|
||||
depositConfirmed: 'depositConfirmed',
|
||||
completed: 'completed',
|
||||
cancelled: 'cancelled',
|
||||
});
|
||||
|
||||
// Map status → Arabic label
|
||||
const BookingStatusLabels = Object.freeze({
|
||||
[BookingStatus.PENDING]: 'بانتظار الموافقة',
|
||||
[BookingStatus.OWNER_APPROVED]: 'موافقة المالك',
|
||||
[BookingStatus.ADMIN_APPROVED]: 'موافقة الإدارة',
|
||||
[BookingStatus.ACTIVE]: 'إيجار نشط',
|
||||
[BookingStatus.COMPLETED]: 'منتهي',
|
||||
[BookingStatus.REJECTED]: 'مرفوض',
|
||||
[BookingStatus.CANCELLED]: 'ملغي',
|
||||
[BookingStatus.PENDING]: 'قيد الانتظار',
|
||||
[BookingStatus.ownerConfirmed]: 'مؤكد من المالك',
|
||||
[BookingStatus.depositPaid]: 'تم دفع السلفة',
|
||||
[BookingStatus.depositConfirmed]: 'تم تأكيد الدفع',
|
||||
[BookingStatus.completed]: 'منتهي',
|
||||
[BookingStatus.cancelled]: 'ملغي',
|
||||
});
|
||||
|
||||
// Map status → color class (Tailwind bg)
|
||||
const BookingStatusColors = Object.freeze({
|
||||
[BookingStatus.PENDING]: 'yellow',
|
||||
[BookingStatus.OWNER_APPROVED]: 'blue',
|
||||
[BookingStatus.ADMIN_APPROVED]: 'green',
|
||||
[BookingStatus.ACTIVE]: 'purple',
|
||||
[BookingStatus.COMPLETED]: 'gray',
|
||||
[BookingStatus.REJECTED]: 'red',
|
||||
[BookingStatus.CANCELLED]: 'red',
|
||||
[BookingStatus.ownerConfirmed]: 'blue',
|
||||
[BookingStatus.depositPaid]: 'orange',
|
||||
[BookingStatus.depositConfirmed]: 'green',
|
||||
[BookingStatus.completed]: 'teal',
|
||||
[BookingStatus.cancelled]: 'red',
|
||||
});
|
||||
|
||||
export { BookingStatus, BookingStatusLabels, BookingStatusColors };
|
||||
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 };
|
||||
|
||||
@ -1,33 +1,72 @@
|
||||
/**
|
||||
* BuildingType Enum
|
||||
* Backend values are numeric (0, 1, 2)
|
||||
* 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,
|
||||
});
|
||||
|
||||
// Map numeric value → Arabic label
|
||||
const BuildingTypeLabels = Object.freeze({
|
||||
[BuildingType.APARTMENT]: 'شقة',
|
||||
[BuildingType.VILLA]: 'فيلا',
|
||||
[BuildingType.HOUSE]: 'بيت',
|
||||
[BuildingType.SWEET]: 'سويت',
|
||||
[BuildingType.ROOM]: 'غرفة',
|
||||
[BuildingType.STUDIO]: 'استوديو',
|
||||
[BuildingType.OFFICE]: 'مكتب',
|
||||
[BuildingType.FARMS]: 'مزرعة',
|
||||
[BuildingType.SHOP]: 'متجر',
|
||||
[BuildingType.WAREHOUSE]: 'مستودع',
|
||||
});
|
||||
|
||||
// Map numeric value → English key (for UI filters)
|
||||
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',
|
||||
});
|
||||
|
||||
// Reverse map: English key → numeric value
|
||||
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,
|
||||
});
|
||||
|
||||
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey };
|
||||
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 };
|
||||
@ -18,7 +18,21 @@ const City = Object.freeze({
|
||||
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));
|
||||
|
||||
@ -35,4 +49,22 @@ function extractCity(address) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export { City, CitiesList, extractCity };
|
||||
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, };
|
||||
|
||||
@ -16,4 +16,10 @@ const CommissionTypeLabels = Object.freeze({
|
||||
[CommissionType.FROM_BOTH]: 'من الاثنين',
|
||||
});
|
||||
|
||||
export { CommissionType, CommissionTypeLabels };
|
||||
const CommissionTypeTranslationKeys = Object.freeze({
|
||||
[CommissionType.FROM_OWNER]: 'commissionType.fromOwner',
|
||||
[CommissionType.FROM_TENANT]: 'commissionType.fromTenant',
|
||||
[CommissionType.FROM_BOTH]: 'commissionType.fromBoth',
|
||||
});
|
||||
|
||||
export { CommissionType, CommissionTypeLabels, CommissionTypeTranslationKeys };
|
||||
|
||||
@ -5,16 +5,29 @@
|
||||
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',
|
||||
});
|
||||
|
||||
export { Currency, CurrencyLabels, CurrencySymbols };
|
||||
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 };
|
||||
|
||||
@ -14,4 +14,9 @@ const CustomerTypeLabels = Object.freeze({
|
||||
[CustomerType.FAMILY]: 'عائلي',
|
||||
});
|
||||
|
||||
export { CustomerType, CustomerTypeLabels };
|
||||
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 };
|
||||
@ -20,4 +20,9 @@ const IdentityTypeFlags = Object.freeze({
|
||||
[IdentityType.PASSPORT]: '🛂',
|
||||
});
|
||||
|
||||
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags };
|
||||
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 };
|
||||
@ -14,4 +14,9 @@ const OwnerTypeLabels = Object.freeze({
|
||||
[OwnerType.REAL_ESTATE_AGENCY]: 'وكالة عقارية',
|
||||
});
|
||||
|
||||
export { OwnerType, OwnerTypeLabels };
|
||||
const OwnerTypeTranslationKeys = Object.freeze({
|
||||
[OwnerType.PERSON]: 'ownerType.person',
|
||||
[OwnerType.REAL_ESTATE_AGENCY]: 'ownerType.agency',
|
||||
});
|
||||
|
||||
export { OwnerType, OwnerTypeLabels, OwnerTypeTranslationKeys };
|
||||
|
||||
@ -38,4 +38,20 @@ const PropertyServiceLabels = Object.freeze({
|
||||
// All services as array
|
||||
const PropertyServicesList = Object.freeze(Object.values(PropertyService));
|
||||
|
||||
export { PropertyService, PropertyServiceLabels, PropertyServicesList };
|
||||
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 };
|
||||
|
||||
@ -5,29 +5,32 @@
|
||||
*/
|
||||
const PropertyStatus = Object.freeze({
|
||||
AVAILABLE: 0,
|
||||
BOOKED: 1,
|
||||
MAINTENANCE: 2,
|
||||
NOT_AVAILABLE: 1,
|
||||
BOOKED: 2,
|
||||
});
|
||||
|
||||
// Map numeric value → Arabic label
|
||||
const PropertyStatusLabels = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'متاح',
|
||||
[PropertyStatus.NOT_AVAILABLE]: 'غير متاح',
|
||||
[PropertyStatus.BOOKED]: 'محجوز',
|
||||
[PropertyStatus.MAINTENANCE]: 'صيانة',
|
||||
});
|
||||
|
||||
// Map numeric value → English key (for UI filters)
|
||||
const PropertyStatusKeys = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'available',
|
||||
[PropertyStatus.NOT_AVAILABLE]: 'notAvailable',
|
||||
[PropertyStatus.BOOKED]: 'booked',
|
||||
[PropertyStatus.MAINTENANCE]: 'maintenance',
|
||||
});
|
||||
|
||||
// Reverse map: English key → numeric value
|
||||
const PropertyStatusByKey = Object.freeze({
|
||||
available: PropertyStatus.AVAILABLE,
|
||||
notAvailable: PropertyStatus.NOT_AVAILABLE,
|
||||
booked: PropertyStatus.BOOKED,
|
||||
maintenance: PropertyStatus.MAINTENANCE,
|
||||
});
|
||||
|
||||
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey };
|
||||
const PropertyStatusTranslationKeys = Object.freeze({
|
||||
[PropertyStatus.AVAILABLE]: 'propertyStatus.available',
|
||||
[PropertyStatus.NOT_AVAILABLE]: 'propertyStatus.notAvailable',
|
||||
[PropertyStatus.BOOKED]: 'propertyStatus.booked',
|
||||
});
|
||||
|
||||
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey, PropertyStatusTranslationKeys };
|
||||
|
||||
@ -18,4 +18,10 @@ const PropertyTermLabels = Object.freeze({
|
||||
// All terms as array
|
||||
const PropertyTermsList = Object.freeze(Object.values(PropertyTerm));
|
||||
|
||||
export { PropertyTerm, PropertyTermLabels, PropertyTermsList };
|
||||
const PropertyTermTranslationKeys = Object.freeze({
|
||||
[PropertyTerm.NO_SMOKING]: 'propertyTerm.noSmoking',
|
||||
[PropertyTerm.NO_ANIMALS]: 'propertyTerm.noAnimals',
|
||||
[PropertyTerm.NO_PARTIES]: 'propertyTerm.noParties',
|
||||
});
|
||||
|
||||
export { PropertyTerm, PropertyTermLabels, PropertyTermsList, PropertyTermTranslationKeys };
|
||||
|
||||
@ -13,4 +13,9 @@ const RentPropertyConditionLabels = Object.freeze({
|
||||
[RentPropertyCondition.WITHOUT_FURNITURE]: 'غير مفروش',
|
||||
});
|
||||
|
||||
export { RentPropertyCondition, RentPropertyConditionLabels };
|
||||
const RentPropertyConditionTranslationKeys = Object.freeze({
|
||||
[RentPropertyCondition.WITH_FURNITURE]: 'rentCondition.furnished',
|
||||
[RentPropertyCondition.WITHOUT_FURNITURE]: 'rentCondition.unfurnished',
|
||||
});
|
||||
|
||||
export { RentPropertyCondition, RentPropertyConditionLabels, RentPropertyConditionTranslationKeys };
|
||||
|
||||
@ -14,4 +14,10 @@ const RentPropertyTypeLabels = Object.freeze({
|
||||
[RentPropertyType.SEMI_FURNISHED]: 'مفروش جزئياً',
|
||||
});
|
||||
|
||||
export { RentPropertyType, RentPropertyTypeLabels };
|
||||
const RentPropertyTypeTranslationKeys = Object.freeze({
|
||||
[RentPropertyType.FURNISHED]: 'rentPropertyType.fullyFurnished',
|
||||
[RentPropertyType.UNFURNISHED]: 'rentPropertyType.unfurnished',
|
||||
[RentPropertyType.SEMI_FURNISHED]: 'rentPropertyType.partiallyFurnished',
|
||||
});
|
||||
|
||||
export { RentPropertyType, RentPropertyTypeLabels, RentPropertyTypeTranslationKeys };
|
||||
|
||||
@ -13,4 +13,9 @@ const RentTypeLabels = Object.freeze({
|
||||
[RentType.DAILY]: 'يومي',
|
||||
});
|
||||
|
||||
export { RentType, RentTypeLabels };
|
||||
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 };
|
||||
@ -7,21 +7,28 @@ const UserRole = Object.freeze({
|
||||
GUEST: 'guest',
|
||||
CUSTOMER: 'customer',
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
AGENT: 'agent',
|
||||
});
|
||||
|
||||
const UserRoleLabels = Object.freeze({
|
||||
[UserRole.GUEST]: 'زائر',
|
||||
[UserRole.CUSTOMER]: 'مستأجر',
|
||||
[UserRole.OWNER]: 'مالك عقار',
|
||||
[UserRole.ADMIN]: 'مدير النظام',
|
||||
[UserRole.AGENT]: 'وسيط عقاري',
|
||||
});
|
||||
|
||||
const UserRoleColors = Object.freeze({
|
||||
[UserRole.GUEST]: 'gray',
|
||||
[UserRole.CUSTOMER]: 'blue',
|
||||
[UserRole.OWNER]: 'amber',
|
||||
[UserRole.ADMIN]: 'red',
|
||||
[UserRole.AGENT]: 'purple',
|
||||
});
|
||||
|
||||
export { UserRole, UserRoleLabels, UserRoleColors };
|
||||
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 };
|
||||
|
||||
@ -18,4 +18,11 @@ 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';
|
||||
|
||||
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 |
@ -1,60 +1,53 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Heart, MapPin, Bed, Bath, Square, X, ImageIcon } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
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();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setIsAdmin(AuthService.isAdmin());
|
||||
// Admin check removed
|
||||
// if (AuthService.isAdmin()) {
|
||||
// router.push('/');
|
||||
// return;
|
||||
// }
|
||||
// setIsAdmin(AuthService.isAdmin());
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
return amount?.toLocaleString() + " " + t("currency-syp-suffix");
|
||||
};
|
||||
|
||||
if (favoritesLoading && favorites.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <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">المفضلة</h1>
|
||||
<p className="text-gray-600">العقارات التي قمت بحفظها</p>
|
||||
<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">لا توجد عقارات في المفضلة</h3>
|
||||
<p className="text-gray-500 mb-6">يمكنك إضافة العقارات التي تعجبك بالنقر على أيقونة القلب</p>
|
||||
<Link
|
||||
href="/properties"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
استعرض العقارات
|
||||
<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>
|
||||
) : (
|
||||
@ -68,13 +61,7 @@ export default function FavoritesPage() {
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
@ -93,7 +80,7 @@ export default function FavoritesPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium">
|
||||
{property.type === 'apartment' ? 'شقة' : property.type === 'villa' ? 'فيلا' : 'بيت'}
|
||||
{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>
|
||||
@ -106,7 +93,7 @@ export default function FavoritesPage() {
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
|
||||
<div className="text-xs text-gray-500">/{property.priceUnit === "daily" ? t("day") : t("month")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -127,11 +114,8 @@ export default function FavoritesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/property/${property.id}`}
|
||||
className="block w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
عرض التفاصيل
|
||||
<Link 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>
|
||||
@ -141,4 +125,4 @@ export default function FavoritesPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,30 +1,78 @@
|
||||
'use client';
|
||||
|
||||
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 { 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() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
const handleRequestOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
toast.error(t('forgotPassword.emailRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
await requestForgetPasswordOtp(email);
|
||||
toast.success(t('forgotPassword.otpSent'));
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
toast.error(err?.message || t('forgotPassword.otpSendFailed'));
|
||||
} finally {
|
||||
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 (
|
||||
<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 -top-40 -right-40 w-80 h-80 bg-purple-500 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 -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-orange-400 rounded-full opacity-20 blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
@ -33,97 +81,168 @@ export default function ForgotPasswordPage() {
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative w-full max-w-md"
|
||||
>
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 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">
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>العودة لتسجيل الدخول</span>
|
||||
<Link href="/login" className="flex items-center gap-2 text-gray-600 hover:text-amber-600 transition-colors group">
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>{t('forgotPassword.backToLogin')}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/20 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-purple-500 to-pink-500 p-8 text-center">
|
||||
<motion.h1
|
||||
<div className="bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/60 overflow-hidden">
|
||||
<div className="bg-gradient-to-l from-amber-500 to-amber-600 p-8 text-center">
|
||||
<motion.h1
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="text-3xl font-bold text-white mb-2"
|
||||
>
|
||||
استعادة كلمة المرور
|
||||
{t('forgotPassword.title')}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-purple-100"
|
||||
className="text-amber-100"
|
||||
>
|
||||
سنرسل لك رابط لإعادة تعيين كلمة المرور
|
||||
{step === 1 ? t('forgotPassword.step1Instruction') : t('forgotPassword.step2Instruction')}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{!isSubmitted ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
البريد الإلكتروني
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Mail className="w-5 h-5 text-gray-400 group-focus-within:text-purple-500 transition-colors" />
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<motion.form
|
||||
key="step1"
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 30 }}
|
||||
onSubmit={handleRequestOtp}
|
||||
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>
|
||||
<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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
<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('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 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>جاري الإرسال...</span>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('forgotPassword.otpLabel')}
|
||||
</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>
|
||||
) : (
|
||||
'إرسال رابط الاستعادة'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-center py-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">تم الإرسال بنجاح!</h3>
|
||||
<p className="text-gray-300 mb-6">
|
||||
تم إرسال رابط استعادة كلمة المرور إلى {email}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block px-6 py-3 bg-white/10 text-white rounded-xl hover:bg-white/20 transition-colors"
|
||||
>
|
||||
العودة لتسجيل الدخول
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('forgotPassword.newPasswordLabel')}
|
||||
</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 text-gray-400 group-focus-within:text-amber-500 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('forgotPassword.newPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</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('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>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ─── Madani Arabic Font ─── */
|
||||
@font-face {
|
||||
font-family: 'Madani Arabic';
|
||||
@ -92,6 +94,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@ -135,6 +157,13 @@ body {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: #1e293b;
|
||||
}
|
||||
.dark .nav-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.property-tooltip {
|
||||
background: white !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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user