Compare commits
80 Commits
5d593d593f
...
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 |
@ -10,7 +10,7 @@ jobs:
|
||||
- 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,9 +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 { NotificationsProvider } from '@/app/contexts/NotificationsContext';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import BottomNav from "./components/BottomNav";
|
||||
import {
|
||||
Globe,
|
||||
LogIn,
|
||||
@ -18,21 +16,14 @@ import {
|
||||
Building,
|
||||
PlusCircle,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Settings,
|
||||
Edit,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Camera,
|
||||
Bell,
|
||||
Home,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
X,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Star,
|
||||
FileText,
|
||||
@ -44,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();
|
||||
@ -55,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";
|
||||
@ -78,7 +74,7 @@ export default function ClientLayout({ children }) {
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
phone: authUser.phone,
|
||||
role: AuthService.isOwner() ? UserRole.OWNER : UserRole.CUSTOMER,
|
||||
role: AuthService.isOwner() ? UserRole.OWNER : AuthService.isAgent() ? UserRole.AGENT : UserRole.CUSTOMER,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
@ -124,17 +120,14 @@ export default function ClientLayout({ children }) {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const isAuthPage = [
|
||||
"/login",
|
||||
"/blocked",
|
||||
"/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 isAgent = user?.role === UserRole.AGENT;
|
||||
const isOwnerOrAgent = isOwner || isAgent;
|
||||
const isCustomer = user?.role === UserRole.CUSTOMER;
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
@ -147,69 +140,57 @@ 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 (
|
||||
<>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{!isAuthPage && !isAuthenticated && (
|
||||
<nav className="fixed top-0 left-0 right-0 bg-white/95 backdrop-blur-sm border-b border-gray-200 z-50 transition-all duration-300 shadow-sm">
|
||||
<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" : ""}`}
|
||||
<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 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" />
|
||||
</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>
|
||||
<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>
|
||||
</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" />
|
||||
</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">
|
||||
@ -219,42 +200,39 @@ export default function ClientLayout({ children }) {
|
||||
</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>
|
||||
|
||||
<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/reservations">
|
||||
<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>
|
||||
</>
|
||||
@ -262,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>
|
||||
</>
|
||||
@ -310,49 +288,29 @@ 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)}
|
||||
>
|
||||
<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">المفضلة</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
العقارات المحفوظة
|
||||
</p>
|
||||
<p className="font-medium">{t("favorites")}</p>
|
||||
<p className="text-xs text-gray-500">{t("savedProperties")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -363,38 +321,24 @@ 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("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)}
|
||||
>
|
||||
<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">تقييماتي</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
التقييمات التي تلقيتها
|
||||
</p>
|
||||
<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)}
|
||||
>
|
||||
<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">الإعدادات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إعدادات الحساب والأمان
|
||||
</p>
|
||||
<p className="font-medium">{t("settings")}</p>
|
||||
<p className="text-xs text-gray-500">{t("accountAndSecurity")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -409,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>
|
||||
|
||||
@ -423,10 +365,8 @@ 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>
|
||||
|
||||
@ -437,10 +377,8 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الحجوزات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إدارة حجوزاتك
|
||||
</p>
|
||||
<p className="font-medium">{t("reservations")}</p>
|
||||
<p className="text-xs text-gray-500">{t("manageReservations")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -451,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>
|
||||
|
||||
@ -465,10 +401,8 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<TrendingUp className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">الأرباح</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
إحصائيات وأرباح
|
||||
</p>
|
||||
<p className="font-medium">{t("profits")}</p>
|
||||
<p className="text-xs text-gray-500">{t("statisticsAndProfits")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -479,10 +413,8 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<DollarSign className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">دفتر الحسابات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
سجل المعاملات المالية
|
||||
</p>
|
||||
<p className="font-medium">{t("accountBook")}</p>
|
||||
<p className="text-xs text-gray-500">{t("financialRecords")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -499,10 +431,8 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">حجوزاتي</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
عرض حجوزاتك
|
||||
</p>
|
||||
<p className="font-medium">{t("myBookings")}</p>
|
||||
<p className="text-xs text-gray-500">{t("viewBookings")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -513,10 +443,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("myBookedProperties")}</p>
|
||||
<p className="text-xs text-gray-500">{t("viewAndRateBooked")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -527,24 +455,16 @@ export default function ClientLayout({ children }) {
|
||||
>
|
||||
<Star 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("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)}
|
||||
>
|
||||
<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">البلاغات</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
تقديم ومتابعة البلاغات
|
||||
</p>
|
||||
<p className="font-medium">{t("reports")}</p>
|
||||
<p className="text-xs text-gray-500">{t("submitAndTrackReports")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -552,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>
|
||||
@ -576,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" />
|
||||
@ -599,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>
|
||||
@ -614,70 +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" />
|
||||
</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" />
|
||||
</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>
|
||||
<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/reservations"
|
||||
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>
|
||||
</>
|
||||
@ -685,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>
|
||||
</>
|
||||
@ -709,68 +611,105 @@ export default function ClientLayout({ children }) {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<NotificationsProvider>
|
||||
<FavoritesProvider>
|
||||
{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 && !isAuthenticated ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||
className={`${!isAuthPage && !isProfilePage && !isAuthenticated ? "pt-20" : ""} flex-1 flex flex-col ${isHomePage ? "bg-transparent" : "bg-gray-50"} ${currentLanguage === "ar" ? "text-right" : "text-left"} ${isHomePage ? "relative" : ""} ${isAuthenticated && !isAuthPage ? "pb-20" : ""}`}
|
||||
>
|
||||
{children}
|
||||
{!isAuthPage && isAuthenticated && (
|
||||
<div className={`flex items-center justify-between px-4 py-2 sticky top-0 z-20 bg-transparent backdrop-blur-3xl ${isHomePage ? "" : "border-b border-gray-200/20"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-sm font-semibold px-3 py-1 rounded-lg ${!isAuthenticated ? (isHomePage ? "bg-white/20 text-white" : "bg-gray-200 text-gray-600") : isOwner ? (isHomePage ? "bg-amber-500/30 text-amber-200" : "bg-amber-100 text-amber-700") : isHomePage ? "bg-blue-500/30 text-blue-200" : "bg-blue-100 text-blue-700"}`}
|
||||
>
|
||||
{!isAuthenticated ? t("visitor") : isOwner ? t("owner") : t("customer")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/favorites"
|
||||
className={`relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 ${pathname === "/favorites" ? "text-amber-400" : "nav-icon"}`}
|
||||
>
|
||||
<Heart className="w-5 h-5" />
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||
{t("favorites")}
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className={`relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 ${pathname === "/notifications" ? "text-amber-400" : "nav-icon"}`}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{isMounted && unreadCount > 0 && (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">{unreadCount > 9 ? "9+" : unreadCount}</div>
|
||||
)}
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||
{t("notifications")}
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={`relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 ${pathname === "/settings" ? "text-amber-400" : "nav-icon"}`}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||
{t("settings")}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => changeLanguage(currentLanguage === "ar" ? "en" : "ar")}
|
||||
className="relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 nav-icon"
|
||||
>
|
||||
<Globe className="w-5 h-5" />
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||
{currentLanguage === "ar" ? t("switchToEnglish") : t("switchToArabic")}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">{children}</div>
|
||||
{isAuthenticated && !isAuthPage && <BottomNav isOwner={isOwner} isOwnerOrAgent={isOwnerOrAgent} />}
|
||||
</main>
|
||||
|
||||
{isAuthenticated && !isAuthPage && (
|
||||
<BottomNav isOwner={isOwner} />
|
||||
)}
|
||||
</FavoritesProvider>
|
||||
</NotificationsProvider>
|
||||
|
||||
{pathname === "/" && (
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<footer className="py-20" style={{backgroundColor:"#0f172a"}}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className={`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>
|
||||
@ -781,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" />
|
||||
@ -799,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" } });
|
||||
};
|
||||
@ -1,31 +1,24 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
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 { 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 [emailCode, setEmailCode] = useState("");
|
||||
const [phoneCode, setPhoneCode] = useState("");
|
||||
const [sendingEmailOTP, setSendingEmailOTP] = useState(false);
|
||||
const [sendingPhoneOTP, setSendingPhoneOTP] = useState(false);
|
||||
const [verifyingEmail, setVerifyingEmail] = useState(false);
|
||||
@ -41,7 +34,7 @@ export default function AccountVerificationPage() {
|
||||
setUser(authUser);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
router.push('/login');
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
@ -49,10 +42,10 @@ export default function AccountVerificationPage() {
|
||||
setSendingEmailOTP(true);
|
||||
try {
|
||||
await sendEmailOTP();
|
||||
toast.success('تم إرسال رمز التحقق إلى بريدك الإلكتروني');
|
||||
toast.success(t("accountVerification.emailOtpSent"));
|
||||
setShowEmailInput(true);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'فشل إرسال رمز التحقق');
|
||||
toast.error(err.message || t("accountVerification.otpSendFailed"));
|
||||
} finally {
|
||||
setSendingEmailOTP(false);
|
||||
}
|
||||
@ -60,7 +53,7 @@ export default function AccountVerificationPage() {
|
||||
|
||||
const handleVerifyEmail = async () => {
|
||||
if (!emailCode.trim()) {
|
||||
toast.error('الرجاء إدخال رمز التحقق');
|
||||
toast.error(t("accountVerification.otpRequired"));
|
||||
return;
|
||||
}
|
||||
setVerifyingEmail(true);
|
||||
@ -68,9 +61,9 @@ export default function AccountVerificationPage() {
|
||||
await verifyEmail(emailCode.trim());
|
||||
setEmailVerified(true);
|
||||
setShowEmailInput(false);
|
||||
toast.success('تم التحقق من البريد الإلكتروني بنجاح');
|
||||
toast.success(t("accountVerification.emailVerifiedSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'رمز التحقق غير صحيح');
|
||||
toast.error(err.message || t("accountVerification.otpInvalid"));
|
||||
} finally {
|
||||
setVerifyingEmail(false);
|
||||
}
|
||||
@ -80,10 +73,10 @@ export default function AccountVerificationPage() {
|
||||
setSendingPhoneOTP(true);
|
||||
try {
|
||||
await sendPhoneOTP();
|
||||
toast.success('تم إرسال رمز التحقق إلى هاتفك');
|
||||
toast.success(t("accountVerification.phoneOtpSent"));
|
||||
setShowPhoneInput(true);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'فشل إرسال رمز التحقق');
|
||||
toast.error(err.message || t("accountVerification.otpSendFailed"));
|
||||
} finally {
|
||||
setSendingPhoneOTP(false);
|
||||
}
|
||||
@ -91,7 +84,7 @@ export default function AccountVerificationPage() {
|
||||
|
||||
const handleVerifyPhone = async () => {
|
||||
if (!phoneCode.trim()) {
|
||||
toast.error('الرجاء إدخال رمز التحقق');
|
||||
toast.error(t("accountVerification.otpRequired"));
|
||||
return;
|
||||
}
|
||||
setVerifyingPhone(true);
|
||||
@ -99,80 +92,56 @@ export default function AccountVerificationPage() {
|
||||
await verifyPhone(phoneCode.trim());
|
||||
setPhoneVerified(true);
|
||||
setShowPhoneInput(false);
|
||||
toast.success('تم التحقق من رقم الهاتف بنجاح');
|
||||
toast.success(t("accountVerification.phoneVerifiedSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'رمز التحقق غير صحيح');
|
||||
toast.error(err.message || t("accountVerification.otpInvalid"));
|
||||
} finally {
|
||||
setVerifyingPhone(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<div className="min-h-screen py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="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"
|
||||
>
|
||||
<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">التحقق من الحساب</h1>
|
||||
<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={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">البريد الإلكتروني</h2>
|
||||
<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 || 'بريد إلكتروني غير مسجل'}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{emailVerified
|
||||
? 'بريدك الإلكتروني موثق ويمكنك استخدامه لتسجيل الدخول'
|
||||
: 'يرجى توثيق بريدك الإلكتروني لحماية حسابك'}
|
||||
</p>
|
||||
<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
|
||||
@ -180,29 +149,21 @@ export default function AccountVerificationPage() {
|
||||
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 ? 'جاري الإرسال...' : 'إرسال رمز التحقق'}
|
||||
{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">أدخل الرمز المكون من 6 أرقام الذي تم إرساله إلى بريدك الإلكتروني</p>
|
||||
<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="رمز التحقق"
|
||||
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"
|
||||
/>
|
||||
@ -211,20 +172,12 @@ export default function AccountVerificationPage() {
|
||||
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" />
|
||||
)}
|
||||
تحقق
|
||||
{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"
|
||||
>
|
||||
إعادة إرسال الرمز
|
||||
<button onClick={handleSendEmailOTP} disabled={sendingEmailOTP} className="text-xs text-amber-600 hover:text-amber-700">
|
||||
{t("accountVerification.resendCode")}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -232,7 +185,7 @@ export default function AccountVerificationPage() {
|
||||
{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">تم توثيق البريد الإلكتروني بنجاح</span>
|
||||
<span className="text-sm font-medium">{t("accountVerification.emailVerifiedSuccess")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -241,23 +194,19 @@ export default function AccountVerificationPage() {
|
||||
<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">رقم الهاتف</h2>
|
||||
<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 || 'رقم هاتف غير مسجل'}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{phoneVerified
|
||||
? 'رقم هاتفك موثق ويمكنك استخدامه لتسجيل الدخول'
|
||||
: 'يرجى توثيق رقم هاتفك لحماية حسابك'}
|
||||
</p>
|
||||
<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
|
||||
@ -265,29 +214,21 @@ export default function AccountVerificationPage() {
|
||||
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 ? 'جاري الإرسال...' : 'إرسال رمز التحقق'}
|
||||
{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">أدخل الرمز المكون من 6 أرقام الذي تم إرساله إلى هاتفك</p>
|
||||
<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="رمز التحقق"
|
||||
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"
|
||||
/>
|
||||
@ -296,20 +237,12 @@ export default function AccountVerificationPage() {
|
||||
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" />
|
||||
)}
|
||||
تحقق
|
||||
{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"
|
||||
>
|
||||
إعادة إرسال الرمز
|
||||
<button onClick={handleSendPhoneOTP} disabled={sendingPhoneOTP} className="text-xs text-amber-600 hover:text-amber-700">
|
||||
{t("accountVerification.resendCode")}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -317,7 +250,7 @@ export default function AccountVerificationPage() {
|
||||
{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">تم توثيق رقم الهاتف بنجاح</span>
|
||||
<span className="text-sm font-medium">{t("accountVerification.phoneVerifiedSuccess")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -326,20 +259,20 @@ export default function AccountVerificationPage() {
|
||||
<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">لماذا يجب توثيق حسابك؟</h3>
|
||||
<h3 className="text-lg font-semibold">{t("accountVerification.whyVerify")}</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-amber-50">
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -3,8 +3,11 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
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,13 +90,13 @@ 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>
|
||||
|
||||
@ -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,7 @@ export default function ChooseRolePage() {
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
إنشاء حساب مستأجر
|
||||
{t('chooseRole.createTenantAccount')}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
@ -239,23 +243,23 @@ export default function ChooseRolePage() {
|
||||
<Briefcase 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.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>
|
||||
|
||||
@ -264,7 +268,7 @@ export default function ChooseRolePage() {
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
إنشاء حساب وكيل
|
||||
{t('chooseRole.createAgentAccount')}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
@ -276,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>
|
||||
|
||||
@ -1,22 +1,25 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
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 { 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 [form, setForm] = useState({ subject: "", body: "" });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSent, setIsSent] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
AuthService.deleteToken();
|
||||
router.replace('/');
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
const updateField = (field, value) => {
|
||||
@ -28,7 +31,7 @@ export default function BlockedPage() {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.subject.trim() || !form.body.trim()) {
|
||||
toast.error('يرجى تعبئة الموضوع والرسالة');
|
||||
toast.error(t("blocked.fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -37,39 +40,29 @@ export default function BlockedPage() {
|
||||
try {
|
||||
await sendGeneralReport(form.subject.trim(), form.body.trim());
|
||||
setIsSent(true);
|
||||
setForm({ subject: '', body: '' });
|
||||
toast.success('تم إرسال طلب الدعم بنجاح');
|
||||
setForm({ subject: "", body: "" });
|
||||
toast.success(t("blocked.reportSent"));
|
||||
} catch (error) {
|
||||
toast.error('حدث خطأ أثناء إرسال طلب الدعم. حاول مرة أخرى');
|
||||
toast.error(t("blocked.sendError"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-red-50 via-white to-amber-50 flex items-center justify-center p-4" dir="rtl">
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-5xl"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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">الحساب محظور</h1>
|
||||
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
|
||||
تم تقييد وصولك إلى التطبيق. يمكنك تسجيل الخروج أو مراسلة دعم العملاء للمساعدة في حل المشكلة.
|
||||
</p>
|
||||
<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">
|
||||
<div className="grid md:grid-cols-2 gap-6 relative z-10 ">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@ -80,57 +73,50 @@ export default function BlockedPage() {
|
||||
<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">تسجيل الخروج</h2>
|
||||
<p className="text-gray-600 leading-7">
|
||||
إنهاء الجلسة الحالية وإزالة بيانات الدخول من هذا الجهاز.
|
||||
</p>
|
||||
<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 bg-red-600 hover:bg-red-700 text-white rounded-2xl py-4 font-bold transition-colors flex items-center justify-center gap-3"
|
||||
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, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-3xl shadow-sm border border-gray-200 p-8"
|
||||
>
|
||||
<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">مراسلة دعم العملاء</h2>
|
||||
<p className="text-gray-600 mt-1">أرسل تفاصيل المشكلة وسنقوم بمراجعتها.</p>
|
||||
<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">الموضوع</label>
|
||||
<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="اكتب موضوع الرسالة"
|
||||
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">الرسالة</label>
|
||||
<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)}
|
||||
onChange={(event) => updateField("body", event.target.value)}
|
||||
rows={6}
|
||||
placeholder="اشرح المشكلة بالتفصيل"
|
||||
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>
|
||||
@ -143,17 +129,17 @@ export default function BlockedPage() {
|
||||
{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>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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,
|
||||
@ -14,27 +15,29 @@ import PropertyRatingForm from '../components/ratings/PropertyRatingForm';
|
||||
const STATUS_MAP = ['pending', 'ownerConfirmed', 'depositPaid', 'depositConfirmed', 'completed', 'cancelled'];
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
ownerConfirmed: { label: 'مؤكد من المالك', color: 'bg-blue-100 text-blue-800 border-blue-300' },
|
||||
depositPaid: { label: 'تم دفع السلفة', color: 'bg-orange-100 text-orange-800 border-orange-300' },
|
||||
depositConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800 border-green-300' },
|
||||
completed: { label: 'منتهي', color: 'bg-teal-100 text-teal-800 border-teal-300' },
|
||||
cancelled: { label: 'ملغي', color: 'bg-red-100 text-red-800 border-red-300' },
|
||||
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" /> {cfg.label}
|
||||
<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;
|
||||
@ -49,7 +52,7 @@ function ReservationCard({ reservation: r, onRate }) {
|
||||
<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">عقار #{r.propertyId || r.id}</span>
|
||||
<span className="font-semibold text-gray-900">{t('propertyId', { id: r.propertyId || r.id })}</span>
|
||||
</div>
|
||||
<StatusBadge code={r.status} />
|
||||
</div>
|
||||
@ -73,38 +76,38 @@ function ReservationCard({ reservation: r, onRate }) {
|
||||
|
||||
{(beds > 0 || baths > 0) && (
|
||||
<div className="flex gap-3 text-sm text-gray-600">
|
||||
{beds > 0 && <span>{beds} غرف</span>}
|
||||
{baths > 0 && <span>{baths} حمامات</span>}
|
||||
{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">من</div>
|
||||
<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">إلى</div>
|
||||
<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">السعر الإجمالي</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>رقم الحجز: #{r.id}</span>
|
||||
<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" /> تقييم العقار
|
||||
<Star className="w-4 h-4" /> {t('rateProperty')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -113,6 +116,7 @@ function ReservationCard({ reservation: r, onRate }) {
|
||||
}
|
||||
|
||||
export default function BookedPropertiesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ratingReservation, setRatingReservation] = useState(null);
|
||||
@ -120,7 +124,7 @@ export default function BookedPropertiesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.getToken()) {
|
||||
toast.error('يرجى تسجيل الدخول أولاً');
|
||||
toast.error(t('loginRequired'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -133,20 +137,14 @@ export default function BookedPropertiesPage() {
|
||||
setReservations(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('فشل تحميل الحجوزات');
|
||||
toast.error(t('failedToLoadBookings'));
|
||||
setReservations([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center" dir="rtl">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
@ -155,14 +153,14 @@ export default function BookedPropertiesPage() {
|
||||
<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">العقارات المحجوزة</h1>
|
||||
<p className="text-gray-600">لديك {reservations.length} حجز</p>
|
||||
<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 ? 'طي الكل' : 'عرض الكل'}
|
||||
{expandedId ? t('collapseAll') : t('showAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -172,8 +170,8 @@ export default function BookedPropertiesPage() {
|
||||
<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">لا توجد حجوزات</h3>
|
||||
<p className="text-gray-500">لم تقم بحجز أي عقار حتى الآن</p>
|
||||
<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">
|
||||
@ -188,8 +186,8 @@ export default function BookedPropertiesPage() {
|
||||
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">تقييم العقارات</p>
|
||||
<p className="text-amber-600 text-sm">يمكنك تقييم العقارات المنتهية حجزها لمساعدة المستأجرين الآخرين</p>
|
||||
<p className="text-amber-800 font-medium">{t('rateProperties')}</p>
|
||||
<p className="text-amber-600 text-sm">{t('ratePropertiesDesc')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -209,7 +207,7 @@ export default function BookedPropertiesPage() {
|
||||
</button>
|
||||
<PropertyRatingForm
|
||||
reservationId={ratingReservation.id}
|
||||
onSuccess={() => { setRatingReservation(null); toast.success('تم إرسال التقييم بنجاح!'); }}
|
||||
onSuccess={() => { setRatingReservation(null); toast.success(t('ratingSubmitted')); }}
|
||||
onCancel={() => setRatingReservation(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
'use client';
|
||||
"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 { changePassword } from '../utils/api';
|
||||
import AuthService from '../services/AuthService';
|
||||
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 [form, setForm] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
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);
|
||||
|
||||
@ -22,162 +25,124 @@ export default function ChangePasswordPage() {
|
||||
e.preventDefault();
|
||||
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
toast.error('يرجى تسجيل الدخول أولاً');
|
||||
router.push('/login');
|
||||
toast.error(t("changePassword.loginRequired"));
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
toast.error('كلمة المرور الجديدة وتأكيدها غير متطابقتين');
|
||||
toast.error(t("changePassword.passwordsDoNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword.length < 6) {
|
||||
toast.error('كلمة المرور الجديدة يجب أن تكون 6 أحرف على الأقل');
|
||||
toast.error(t("validation.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await changePassword(form.oldPassword, form.newPassword);
|
||||
toast.success('تم تغيير كلمة المرور بنجاح');
|
||||
setTimeout(() => router.push('/profile'), 1200);
|
||||
toast.success(t("changePassword.changeSuccess"));
|
||||
setTimeout(() => router.push("/profile"), 1200);
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'فشل تغيير كلمة المرور');
|
||||
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";
|
||||
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 bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen relative z-10 flex items-center justify-center p-4" style={{ backgroundColor: "#0f172a" }}>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-amber-600 transition-colors"
|
||||
>
|
||||
<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>العودة للملف الشخصي</span>
|
||||
<span>{t("changePassword.backToProfile")}</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
<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 }}
|
||||
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"
|
||||
>
|
||||
تغيير كلمة المرور
|
||||
<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"
|
||||
>
|
||||
أدخل كلمة المرور الحالية والجديدة
|
||||
<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">
|
||||
<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">
|
||||
كلمة المرور الحالية
|
||||
</label>
|
||||
<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'}
|
||||
type={show.old ? "text" : "password"}
|
||||
value={form.oldPassword}
|
||||
onChange={handleChange('oldPassword')}
|
||||
onChange={handleChange("oldPassword")}
|
||||
className={inputClass}
|
||||
placeholder="أدخل كلمة المرور الحالية"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
كلمة المرور الجديدة
|
||||
</label>
|
||||
<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'}
|
||||
type={show.new ? "text" : "password"}
|
||||
value={form.newPassword}
|
||||
onChange={handleChange('newPassword')}
|
||||
onChange={handleChange("newPassword")}
|
||||
className={inputClass}
|
||||
placeholder="أدخل كلمة المرور الجديدة"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
تأكيد كلمة المرور الجديدة
|
||||
</label>
|
||||
<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'}
|
||||
type={show.confirm ? "text" : "password"}
|
||||
value={form.confirmPassword}
|
||||
onChange={handleChange('confirmPassword')}
|
||||
onChange={handleChange("confirmPassword")}
|
||||
className={inputClass}
|
||||
placeholder="أعد إدخال كلمة المرور الجديدة"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
@ -193,10 +158,10 @@ export default function ChangePasswordPage() {
|
||||
{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>
|
||||
<span>{t("changePassword.saving")}</span>
|
||||
</div>
|
||||
) : (
|
||||
'تغيير كلمة المرور'
|
||||
t("changePassword.changePassword")
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
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}`} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Home, Building, Calendar, Heart, Bell, Settings } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Home, Building2, Building, CalendarCheck, CalendarDays, ClipboardList, CreditCard, BookOpenCheck } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNotifications } from "@/app/contexts/NotificationsContext";
|
||||
|
||||
export default function BottomNav({ isOwner }) {
|
||||
const { unreadCount } = useNotifications();
|
||||
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";
|
||||
|
||||
@ -15,43 +16,95 @@ export default function BottomNav({ isOwner }) {
|
||||
}, []);
|
||||
|
||||
const items = [
|
||||
{ href: "/", label: "الرئيسية", icon: Home },
|
||||
{ href: "/properties", label: "عقاراتنا", icon: Building },
|
||||
{ href: bookingsHref, label: "الحجوزات", icon: Calendar },
|
||||
{ href: "/favorites", label: "المفضلة", icon: Heart },
|
||||
{ href: "/notifications", label: "الإشعارات", icon: Bell, badge: isMounted ? unreadCount : 0 },
|
||||
{ href: "/settings", label: "الإعدادات", icon: Settings },
|
||||
{ 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 (
|
||||
<nav className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div className="bg-white/95 backdrop-blur-sm border border-gray-200 rounded-3xl shadow-lg px-2 py-2 flex items-center gap-3">
|
||||
<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}
|
||||
className="relative group flex flex-col items-center justify-center px-2.5 py-2 text-gray-700 hover:text-amber-600 transition-colors"
|
||||
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="w-6 h-6" />
|
||||
<Icon className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
|
||||
{it.badge > 0 && (
|
||||
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
|
||||
<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="absolute -top-10 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none shadow-lg">
|
||||
{it.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-center" aria-hidden>
|
||||
{it.label}
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 text-[10px] sm:mt-1 text-center sm:text-xs">{it.label}</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,11 +4,13 @@ import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { Heart, Bell, CreditCard, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function FloatingSidebar({ isRTL }) {
|
||||
const { t } = useTranslation();
|
||||
const { favorites } = useFavorites();
|
||||
const { unreadCount } = useNotifications();
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
@ -88,7 +90,7 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('favorites', 'المفضلة')}
|
||||
{renderTooltip('favorites', t('favorites'))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
@ -116,7 +118,7 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('notifications', 'الإشعارات')}
|
||||
{renderTooltip('notifications', t('notifications'))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
@ -133,7 +135,7 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
>
|
||||
<CreditCard className="w-7 h-7 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
</Link>
|
||||
{renderTooltip('payments', 'المدفوعات')}
|
||||
{renderTooltip('payments', t('payments'))}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
@ -150,7 +152,7 @@ export default function FloatingSidebar({ isRTL }) {
|
||||
>
|
||||
<Settings className="w-7 h-7 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
</Link>
|
||||
{renderTooltip('settings', 'الإعدادات')}
|
||||
{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,11 +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",
|
||||
@ -16,6 +16,7 @@ const firebaseConfig = {
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
//عم نعمل تهيئة للاعدادات
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
export default function NotificationHandler() {
|
||||
@ -26,11 +27,10 @@ export default function NotificationHandler() {
|
||||
useEffect(() => {
|
||||
function checkAuth() {
|
||||
if (initialized.current) return;
|
||||
|
||||
if (!AuthService.getToken()) return;
|
||||
initialized.current = true;
|
||||
|
||||
if ("Notification" in window) {
|
||||
//عم نشوف اذا حط سماح او رفض
|
||||
if (Notification.permission === "default") {
|
||||
setShowPrompt(true);
|
||||
} else if (Notification.permission === "granted") {
|
||||
@ -91,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();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
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',
|
||||
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 }) {
|
||||
@ -22,7 +22,7 @@ export default function PropertyMapWithMarkers({ properties = [], onPropertyClic
|
||||
|
||||
const map = L.map(mapRef.current).setView([33.5138, 38.9968], 7);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
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);
|
||||
@ -41,32 +41,32 @@ export default function PropertyMapWithMarkers({ properties = [], onPropertyClic
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !mapLoaded) return;
|
||||
|
||||
markersRef.current.forEach(marker => marker.remove());
|
||||
markersRef.current.forEach((marker) => marker.remove());
|
||||
markersRef.current = [];
|
||||
|
||||
properties.forEach(property => {
|
||||
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>
|
||||
<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]}" alt="${property.title}" style="width: 100%; height: 96px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" onerror="this.src='/property-placeholder.jpg'" />` : ''}
|
||||
${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>` : ''}
|
||||
${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', () => {
|
||||
marker.on("click", () => {
|
||||
if (onPropertyClick) {
|
||||
onPropertyClick(property);
|
||||
}
|
||||
@ -83,9 +83,9 @@ export default function PropertyMapWithMarkers({ properties = [], onPropertyClic
|
||||
}, [properties, mapLoaded]);
|
||||
|
||||
const formatPrice = (property) => {
|
||||
if (property.priceUnit === 'monthly') {
|
||||
if (property.priceUnit === "monthly") {
|
||||
return `${property.price?.toLocaleString() || 0} ل.س/شهر`;
|
||||
} else if (property.priceUnit === 'daily') {
|
||||
} else if (property.priceUnit === "daily") {
|
||||
return `${property.price?.toLocaleString() || 0} ل.س/يوم`;
|
||||
} else {
|
||||
return `${property.price?.toLocaleString() || 0} ل.س`;
|
||||
|
||||
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,91 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
|
||||
|
||||
export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||
"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('buy');
|
||||
const { cities, identityTypes, ownerSources, priceRanges, propertyTypes, rentPeriods } = useFilterOptions();
|
||||
const [activeTab, setActiveTab] = useState("buy");
|
||||
const [filters, setFilters] = useState({
|
||||
city: 'all',
|
||||
propertyType: 'all',
|
||||
priceRange: 'all',
|
||||
identityType: 'syrian',
|
||||
ownerSource: 'all',
|
||||
rentPeriod: 'all',
|
||||
availableToday: false
|
||||
city: "all",
|
||||
propertyType: "all",
|
||||
priceRange: "all",
|
||||
identityType: "syrian",
|
||||
ownerSource: "all",
|
||||
rentPeriod: "all",
|
||||
availableToday: false,
|
||||
});
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(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: 'studio', label: 'استوديو' },
|
||||
{ id: 'commercial', label: 'عقار تجاري' },
|
||||
{ id: 'villa', 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 ownerSources = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'owner', label: 'من المالك' },
|
||||
{ id: 'agency', label: 'من مكتب عقاري' }
|
||||
];
|
||||
|
||||
const rentPeriods = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'daily', label: 'إيجار يومي' },
|
||||
{ id: 'monthly', label: 'إيجار شهري' }
|
||||
];
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch({
|
||||
...filters,
|
||||
mode: activeTab,
|
||||
city: filters.city || 'all',
|
||||
propertyType: filters.propertyType || 'all',
|
||||
priceRange: filters.priceRange || 'all',
|
||||
ownerSource: filters.ownerSource || 'all',
|
||||
rentPeriod: filters.rentPeriod || 'all'
|
||||
city: filters.city || "all",
|
||||
propertyType: filters.propertyType || "all",
|
||||
priceRange: filters.priceRange || "all",
|
||||
ownerSource: filters.ownerSource || "all",
|
||||
rentPeriod: filters.rentPeriod || "all",
|
||||
});
|
||||
};
|
||||
|
||||
@ -98,173 +44,58 @@ export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{['rent', 'buy', 'sell'].map((tab) => (
|
||||
<motion.button
|
||||
{["rent", "buy", "sell"].map((tab) => {
|
||||
const isActive = activeTab === tab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabClick(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 }}
|
||||
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"}`}
|
||||
>
|
||||
{t(`${tab}Tab`)}
|
||||
</motion.button>
|
||||
))}
|
||||
{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>
|
||||
{/* city */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{t("cityStreetLabel")}
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={filters.city}
|
||||
onChange={(e) => setFilters({...filters, city: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{cities.map(city => (
|
||||
<option key={city.id} value={city.id}>{city.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
<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, 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
|
||||
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>
|
||||
</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>
|
||||
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 className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
|
||||
<select
|
||||
value={filters.ownerSource}
|
||||
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{ownerSources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
|
||||
<select
|
||||
value={filters.rentPeriod}
|
||||
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{rentPeriods.map((period) => (
|
||||
<option key={period.id} value={period.id}>
|
||||
{period.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
|
||||
<label className="mt-4 flex items-center gap-3 text-white text-sm">
|
||||
{/* 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">عرض فقط العقارات المتاحة من اليوم</span>
|
||||
<span className="font-medium">{t("availableToday")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -280,40 +111,7 @@ export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showLoginDialog && !isAuthenticated && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<ShieldCheck className="w-7 h-7 text-amber-500" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
|
||||
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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';
|
||||
@ -10,11 +11,12 @@ 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>}
|
||||
{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);
|
||||
@ -22,9 +24,9 @@ export default function CustomerRatingForm({ reservationId, onSuccess, onCancel
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validate = () => {
|
||||
if (furnitureIntegrityRating === 0) return 'الحفاظ على الأثاث';
|
||||
if (termsComplianceRating === 0) return 'الالتزام بالشروط';
|
||||
if (renterBehaviorRating === 0) return 'سلوك المستأجر';
|
||||
if (furnitureIntegrityRating === 0) return t('ratings.furnitureIntegrity');
|
||||
if (termsComplianceRating === 0) return t('ratings.termsCompliance');
|
||||
if (renterBehaviorRating === 0) return t('ratings.renterBehavior');
|
||||
return null;
|
||||
};
|
||||
|
||||
@ -32,7 +34,7 @@ export default function CustomerRatingForm({ reservationId, onSuccess, onCancel
|
||||
e.preventDefault();
|
||||
const missing = validate();
|
||||
if (missing) {
|
||||
toast.error(`يرجى تقييم: ${missing}`);
|
||||
toast.error(t('ratings.pleaseRate') + ': ' + missing);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -45,10 +47,10 @@ export default function CustomerRatingForm({ reservationId, onSuccess, onCancel
|
||||
renterBehaviorRating,
|
||||
comment: comment.trim() || null,
|
||||
});
|
||||
toast.success('تم إرسال تقييم المستأجر بنجاح!');
|
||||
toast.success(t('ratings.customerRatingSubmitted'));
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
toast.error('حدث خطأ، حاول مرة أخرى');
|
||||
toast.error(t('ratings.submitError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -57,7 +59,7 @@ export default function CustomerRatingForm({ reservationId, onSuccess, onCancel
|
||||
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>
|
||||
<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} />
|
||||
@ -65,17 +67,17 @@ export default function CustomerRatingForm({ reservationId, onSuccess, onCancel
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<RatingField label="الحفاظ على الأثاث" value={furnitureIntegrityRating} onChange={setFurnitureIntegrityRating} />
|
||||
<RatingField label="الالتزام بالشروط" value={termsComplianceRating} onChange={setTermsComplianceRating} />
|
||||
<RatingField label="سلوك المستأجر" value={renterBehaviorRating} onChange={setRenterBehaviorRating} />
|
||||
<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">تعليق (اختياري)</label>
|
||||
<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="شارك تجربتك مع المستأجر..."
|
||||
placeholder={t('ratings.commentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@ -84,7 +86,7 @@ export default function CustomerRatingForm({ reservationId, onSuccess, onCancel
|
||||
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 ? 'جاري الإرسال...' : 'إرسال التقييم'}
|
||||
{loading ? t('ratings.sending') : t('ratings.submitRating')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
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);
|
||||
@ -26,4 +26,13 @@ const BookingStatusColors = Object.freeze({
|
||||
[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 };
|
||||
|
||||
@ -56,4 +56,17 @@ const BuildingTypeByKey = Object.freeze({
|
||||
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 };
|
||||
|
||||
@ -10,4 +10,10 @@ const CancellationReasonLabels = Object.freeze({
|
||||
[CancellationReason.OWNER_CANCELED]: 'إلغاء من المالك',
|
||||
});
|
||||
|
||||
export { CancellationReason, CancellationReasonLabels };
|
||||
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 };
|
||||
|
||||
@ -23,4 +23,11 @@ const CurrencySymbols = Object.freeze({
|
||||
[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 };
|
||||
|
||||
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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -27,4 +27,10 @@ const PropertyStatusByKey = Object.freeze({
|
||||
booked: PropertyStatus.BOOKED,
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@ -8,4 +8,9 @@ const SalePropertiesStatusLabels = Object.freeze({
|
||||
[SalePropertiesStatus.CONFIRMED]: 'مؤكد',
|
||||
});
|
||||
|
||||
export { SalePropertiesStatus, SalePropertiesStatusLabels };
|
||||
const SalePropertiesStatusTranslationKeys = Object.freeze({
|
||||
[SalePropertiesStatus.PENDING]: 'saleStatus.pending',
|
||||
[SalePropertiesStatus.CONFIRMED]: 'saleStatus.confirmed',
|
||||
});
|
||||
|
||||
export { SalePropertiesStatus, SalePropertiesStatusLabels, SalePropertiesStatusTranslationKeys };
|
||||
|
||||
@ -8,4 +8,9 @@ const TransactionTypeLabels = Object.freeze({
|
||||
[TransactionType.MANUAL]: 'يدوي',
|
||||
});
|
||||
|
||||
export { TransactionType, TransactionTypeLabels };
|
||||
const TransactionTypeTranslationKeys = Object.freeze({
|
||||
[TransactionType.ONLINE]: 'transactionType.online',
|
||||
[TransactionType.MANUAL]: 'transactionType.manual',
|
||||
});
|
||||
|
||||
export { TransactionType, TransactionTypeLabels, TransactionTypeTranslationKeys };
|
||||
|
||||
@ -7,18 +7,28 @@ const UserRole = Object.freeze({
|
||||
GUEST: 'guest',
|
||||
CUSTOMER: 'customer',
|
||||
OWNER: 'owner',
|
||||
AGENT: 'agent',
|
||||
});
|
||||
|
||||
const UserRoleLabels = Object.freeze({
|
||||
[UserRole.GUEST]: 'زائر',
|
||||
[UserRole.CUSTOMER]: 'مستأجر',
|
||||
[UserRole.OWNER]: 'مالك عقار',
|
||||
[UserRole.AGENT]: 'وسيط عقاري',
|
||||
});
|
||||
|
||||
const UserRoleColors = Object.freeze({
|
||||
[UserRole.GUEST]: 'gray',
|
||||
[UserRole.CUSTOMER]: 'blue',
|
||||
[UserRole.OWNER]: 'amber',
|
||||
[UserRole.AGENT]: 'purple',
|
||||
});
|
||||
|
||||
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,6 +18,8 @@ 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';
|
||||
|
||||
@ -2,47 +2,25 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpCircle, ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'كيفية البحث عن عقار؟',
|
||||
answer: 'يمكنك البحث عن عقار من خلال استخدام شريط البحث في الصفحة الرئيسية، حيث يمكنك تحديد المدينة ونوع العقار والسعر. كما يمكنك استخدام الفلاتر المتقدمة لتحديد عدد الغرف والحمامات والمساحة وطرق الدفع. يمكنك أيضاً تصفح العقارات المميزة والتوصيات المقدمة خصيصاً لك.',
|
||||
},
|
||||
{
|
||||
question: 'كيف أحجز عقاراً؟',
|
||||
answer: 'بعد العثور على العقار المناسب، يمكنك النقر على "حجز" واختيار تواريخ الإقامة. سيطلب منك تسجيل الدخول أو إنشاء حساب إذا لم يكن لديك حساب. بعد ذلك، قم بتأكيد الحجز وسيتم إخطار المالك بطلبك. يمكنك متابعة حالة حجزك من صفحة الحجوزات.',
|
||||
},
|
||||
{
|
||||
question: 'كيف أضيف عقاراً؟',
|
||||
answer: 'لتسجيل عقارك على المنصة، يجب عليك أولاً إنشاء حساب مالك. بعد تسجيل الدخول، انتقل إلى لوحة التحكم واختر "إضافة عقار". قم بتعبئة جميع المعلومات المطلوبة مثل الموقع، المساحة، عدد الغرف، الصور، السعر، وغيرها من التفاصيل. بعد المراجعة، سيتم نشر عقارك على المنصة.',
|
||||
},
|
||||
{
|
||||
question: 'كيف أتواصل مع المالك؟',
|
||||
answer: 'بعد تسجيل الدخول، يمكنك التواصل مع المالك من خلال صفحة العقار بالضغط على زر "تواصل مع المالك". ستظهر لك معلومات الاتصال بالمالك بما في ذلك رقم الهاتف والبريد الإلكتروني. يمكنك أيضاً مراسلة المالك مباشرة من خلال نظام المراسلة الداخلي في المنصة.',
|
||||
},
|
||||
{
|
||||
question: 'ما هي سياسة الإلغاء؟',
|
||||
answer: 'سياسة الإلغاء تختلف حسب كل عقار. بشكل عام، يمكن إلغاء الحجز قبل 48 ساعة من موعد الوصول للحصول على استرداد كامل. للإلغاء خلال أقل من 48 ساعة، قد يتم تطبيق رسوم إلغاء. ننصحك بمراجعة سياسة الإلغاء المدونة في صفحة العقار قبل تأكيد الحجز.',
|
||||
},
|
||||
{
|
||||
question: 'كيف أقيم العقار؟',
|
||||
answer: 'بعد انتهاء إقامتك، يمكنك تقييم العقار من خلال صفحة الحجوزات. اختر الحجز المناسب ثم اضغط على "تقييم". يمكنك إعطاء تقييم بالنجوم وكتابة تعليق يصف تجربتك. تقييمات المستخدمين تساعد الآخرين في اتخاذ القرار المناسب.',
|
||||
},
|
||||
{
|
||||
question: 'هل يمكنني تغيير كلمة المرور؟',
|
||||
answer: 'نعم، يمكنك تغيير كلمة المرور من خلال صفحة الملف الشخصي. اختر "تغيير كلمة المرور" وأدخل كلمة المرور الحالية ثم كلمة المرور الجديدة. يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل وتحتوي على حرف كبير وحرف صغير ورقم.',
|
||||
},
|
||||
{
|
||||
question: 'كيف أحذف حسابي؟',
|
||||
answer: 'لحذف حسابك نهائياً، يرجى التواصل مع فريق الدعم الفني من خلال صفحة الدعم. سنقوم بمعالجة طلبك خلال 24 ساعة. يرجى العلم أنه لا يمكن استعادة الحساب بعد حذفه، وسيتم فقدان جميع البيانات والحجوزات المرتبطة به.',
|
||||
},
|
||||
];
|
||||
|
||||
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) ||
|
||||
@ -54,7 +32,7 @@ export default function FAQPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-amber-50/50 to-white py-12" dir="rtl">
|
||||
<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 }}
|
||||
@ -64,9 +42,9 @@ export default function FAQPage() {
|
||||
<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">الأسئلة الشائعة</h1>
|
||||
<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>
|
||||
|
||||
@ -79,7 +57,7 @@ export default function FAQPage() {
|
||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ابحث في الأسئلة الشائعة..."
|
||||
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"
|
||||
@ -134,7 +112,7 @@ export default function FAQPage() {
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<p className="text-gray-500 text-lg">لا توجد نتائج للبحث</p>
|
||||
<p className="text-gray-500 text-lg">{t('faq.noResults')}</p>
|
||||
</motion.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,15 +1,18 @@
|
||||
'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();
|
||||
|
||||
@ -23,38 +26,28 @@ export default function FavoritesPage() {
|
||||
}, [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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -6,10 +6,13 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
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('');
|
||||
@ -20,17 +23,17 @@ export default function ForgotPasswordPage() {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
toast.error('يرجى إدخال البريد الإلكتروني');
|
||||
toast.error(t('forgotPassword.emailRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await requestForgetPasswordOtp(email);
|
||||
toast.success('تم إرسال رمز التحقق إلى بريدك الإلكتروني');
|
||||
toast.success(t('forgotPassword.otpSent'));
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'فشل إرسال رمز التحقق');
|
||||
toast.error(err?.message || t('forgotPassword.otpSendFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -40,22 +43,22 @@ export default function ForgotPasswordPage() {
|
||||
e.preventDefault();
|
||||
|
||||
if (!code.trim()) {
|
||||
toast.error('يرجى إدخال رمز التحقق');
|
||||
toast.error(t('forgotPassword.otpRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
toast.error('كلمة المرور الجديدة يجب أن تكون 6 أحرف على الأقل');
|
||||
toast.error(t('validation.passwordMinLength'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyForgetPasswordOtp(email, code, newPassword);
|
||||
toast.success('تم إعادة تعيين كلمة المرور بنجاح');
|
||||
toast.success(t('forgotPassword.resetSuccess'));
|
||||
setTimeout(() => router.push('/login'), 1200);
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'فشل التحقق من الرمز');
|
||||
toast.error(err?.message || t('forgotPassword.otpVerifyFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -64,8 +67,9 @@ export default function ForgotPasswordPage() {
|
||||
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-amber-50 via-orange-50 to-yellow-50 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-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>
|
||||
@ -84,7 +88,7 @@ export default function ForgotPasswordPage() {
|
||||
>
|
||||
<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>العودة لتسجيل الدخول</span>
|
||||
<span>{t('forgotPassword.backToLogin')}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
@ -95,7 +99,7 @@ export default function ForgotPasswordPage() {
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="text-3xl font-bold text-white mb-2"
|
||||
>
|
||||
استعادة كلمة المرور
|
||||
{t('forgotPassword.title')}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
@ -103,7 +107,7 @@ export default function ForgotPasswordPage() {
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-amber-100"
|
||||
>
|
||||
{step === 1 ? 'أدخل بريدك الإلكتروني لاستلام رمز التحقق' : 'أدخل رمز التحقق وكلمة المرور الجديدة'}
|
||||
{step === 1 ? t('forgotPassword.step1Instruction') : t('forgotPassword.step2Instruction')}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
@ -120,7 +124,7 @@ export default function ForgotPasswordPage() {
|
||||
>
|
||||
<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">
|
||||
@ -131,7 +135,7 @@ export default function ForgotPasswordPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="أدخل بريدك الإلكتروني"
|
||||
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||
dir="ltr"
|
||||
required
|
||||
/>
|
||||
@ -148,10 +152,10 @@ export default function ForgotPasswordPage() {
|
||||
{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>
|
||||
<span>{t('forgotPassword.sending')}</span>
|
||||
</div>
|
||||
) : (
|
||||
'إرسال رمز التحقق'
|
||||
t('forgotPassword.sendOtp')
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
@ -168,7 +172,7 @@ export default function ForgotPasswordPage() {
|
||||
>
|
||||
<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">
|
||||
@ -179,7 +183,7 @@ export default function ForgotPasswordPage() {
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="أدخل رمز التحقق"
|
||||
placeholder={t('forgotPassword.otpPlaceholder')}
|
||||
dir="ltr"
|
||||
required
|
||||
/>
|
||||
@ -188,7 +192,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
<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">
|
||||
@ -199,7 +203,7 @@ export default function ForgotPasswordPage() {
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="أدخل كلمة المرور الجديدة"
|
||||
placeholder={t('forgotPassword.newPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
@ -216,10 +220,10 @@ export default function ForgotPasswordPage() {
|
||||
{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>
|
||||
<span>{t('forgotPassword.verifying')}</span>
|
||||
</div>
|
||||
) : (
|
||||
'إعادة تعيين كلمة المرور'
|
||||
t('forgotPassword.resetPassword')
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
@ -230,7 +234,7 @@ export default function ForgotPasswordPage() {
|
||||
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>تغيير البريد الإلكتروني</span>
|
||||
<span>{t('forgotPassword.changeEmail')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
3439
app/i18n/config.js
3439
app/i18n/config.js
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,9 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import ClientLayout from "./ClientLayout";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { NotificationsProvider } from "./contexts/NotificationsContext";
|
||||
import { FavoritesProvider } from "./contexts/FavoritesContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -24,7 +27,7 @@ export const metadata = {
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<html lang="ar" dir="rtl">
|
||||
<html lang="ar" dir="rtl" className="overflow-x-hidden">
|
||||
<head>
|
||||
<link
|
||||
rel="preload"
|
||||
@ -52,7 +55,13 @@ export default function Layout({ children }) {
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<NotificationsProvider>
|
||||
<FavoritesProvider>
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
</FavoritesProvider>
|
||||
</NotificationsProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -1,17 +1,37 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Loading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: "#0f172a" }}>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1.3 }} transition={{ duration: 0.4 }} className="text-center flex flex-col items-center justify-center gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
animate={{
|
||||
scale: [1, 1.06, 1],
|
||||
opacity: [0.8, 1, 0.8],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="relative w-25 h-36 flex items-center justify-center"
|
||||
>
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full fill-amber-500/10 stroke-amber-500 stroke-[4] drop-shadow-[0_0_12px_rgba(245,158,11,0.2)] " xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="50,5 95,27.5 95,72.5 50,95 5,72.5 5,27.5" />
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center p-2 text-center">
|
||||
<span className="text-amber-400 font-bold text-[10px] tracking-wide select-none">Sweet Home</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<p className="text-amber-400 text-sm font-bold tracking-wide animate-pulse" suppressHydrationWarning>
|
||||
{t("loading")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,735 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowLeft,
|
||||
LogIn,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Home,
|
||||
Shield,
|
||||
Phone,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
loginWithEmail,
|
||||
loginWithPhone,
|
||||
sendEmailOTP,
|
||||
sendPhoneOTP,
|
||||
verifyEmail,
|
||||
verifyPhone,
|
||||
isEmail,
|
||||
isPhoneNumber,
|
||||
getOwnerByUserId,
|
||||
getCustomerByUserId,
|
||||
} from "../utils/api";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
import Login from "../components/LoginComponent/Login";
|
||||
export const metadata = {
|
||||
title: "صفحة تسجيل الدخول ",
|
||||
describtion: "صفحة تسجيل الدخول في Sweet Home",
|
||||
};
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Step: 'login' | 'otp'
|
||||
const [step, setStep] = useState("login");
|
||||
const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
credential: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.credential) {
|
||||
newErrors.credential =
|
||||
loginMethod === "email"
|
||||
? "البريد الإلكتروني مطلوب"
|
||||
: "رقم الهاتف مطلوب";
|
||||
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||
newErrors.credential = "رقم الهاتف غير صالح";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "كلمة المرور مطلوبة";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||
console.log(
|
||||
"[Login] Attempting login via",
|
||||
loginMethod,
|
||||
":",
|
||||
formData.credential,
|
||||
);
|
||||
|
||||
const result = await loginFn(formData.credential, formData.password);
|
||||
|
||||
console.log("[Login] Response status:", result.status);
|
||||
|
||||
if (result.status === 200) {
|
||||
const token =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
AuthService.addToken(token);
|
||||
console.log("[Login] Token stored");
|
||||
|
||||
// Fetch user profile to get full name
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser?.id) {
|
||||
try {
|
||||
const isOwner = AuthService.isOwner();
|
||||
const fetchFn = isOwner ? getOwnerByUserId : getCustomerByUserId;
|
||||
const profile = await fetchFn(authUser.id);
|
||||
if (profile) {
|
||||
AuthService.cacheUser({
|
||||
name:
|
||||
profile.fullName ||
|
||||
profile.name ||
|
||||
`${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email: profile.email || authUser.email,
|
||||
phone: profile.phone || profile.phoneNumber || authUser.phone,
|
||||
});
|
||||
console.log("[Login] User profile cached");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Login] Failed to fetch profile:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userRole = AuthService.isOwner() ? "owner" : "customer";
|
||||
console.log("[Login] User role:", userRole);
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success("تم تسجيل الدخول بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else if (result.status === 206) {
|
||||
console.log("[Login] 206 — OTP required");
|
||||
const tempToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (tempToken) {
|
||||
AuthService.addToken(tempToken);
|
||||
console.log("[Login] Temp token stored for OTP");
|
||||
}
|
||||
toast("يرجى إدخال رمز التحقق", {
|
||||
icon: "🔐",
|
||||
style: { background: "#fef3c7", color: "#92400e" },
|
||||
});
|
||||
|
||||
// Send OTP
|
||||
try {
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
console.log("[Login] OTP sent successfully");
|
||||
} catch (otpErr) {
|
||||
console.warn("[Login] OTP send failed, proceeding anyway:", otpErr);
|
||||
}
|
||||
|
||||
setStep("otp");
|
||||
} else {
|
||||
// Other error
|
||||
console.error("[Login] Unexpected status:", result.status, result.data);
|
||||
toast.error(
|
||||
result.data?.message || result.data || "بيانات الدخول غير صحيحة",
|
||||
{
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Error:", err);
|
||||
toast.error(err.message || "حدث خطأ في الاتصال", {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOTP = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpError("يرجى إدخال رمز التحقق");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setOtpError("");
|
||||
|
||||
try {
|
||||
const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
|
||||
console.log("[OTP] Verifying code:", otpCode);
|
||||
|
||||
const result = await verifyFn(otpCode);
|
||||
console.log("[OTP] Verify response status:", result.status);
|
||||
|
||||
if (result.ok) {
|
||||
const finalToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (finalToken && typeof finalToken === "string") {
|
||||
AuthService.addToken(finalToken);
|
||||
console.log("[OTP] Final token stored");
|
||||
}
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success("تم التحقق بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("[OTP] Redirecting to home");
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else {
|
||||
console.error("[OTP] Verification failed:", result.data);
|
||||
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[OTP] Error:", err);
|
||||
setOtpError(err.message || "حدث خطأ في التحقق");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOTP = async () => {
|
||||
try {
|
||||
console.log("[OTP] Resending OTP via", loginMethod);
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
toast.success("تم إرسال رمز التحقق مجدداً", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[OTP] Resend failed:", err);
|
||||
toast.error("فشل إرسال الرمز");
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-detect login method from input
|
||||
const handleCredentialChange = (value) => {
|
||||
setFormData({ ...formData, credential: value });
|
||||
if (errors.credential) setErrors({ ...errors, credential: null });
|
||||
|
||||
// Auto-switch method
|
||||
// if (isEmail(value)) {
|
||||
// setLoginMethod('email');
|
||||
// } else if (isPhoneNumber(value)) {
|
||||
// setLoginMethod('phone');
|
||||
// }
|
||||
};
|
||||
|
||||
const particles = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 3 + 1,
|
||||
duration: Math.random() * 15 + 10,
|
||||
delay: Math.random() * 5,
|
||||
}));
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { type: "spring", stiffness: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
{/* Particles */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{particles.map((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 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="relative w-full max-w-md z-10"
|
||||
>
|
||||
{/* Back link */}
|
||||
<motion.div variants={itemVariants} className="absolute -top-16 left-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ x: -5 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
<span>العودة للرئيسية</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
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" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<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 */}
|
||||
<div className="flex gap-2 bg-white/5 p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginMethod("email");
|
||||
setFormData({ ...formData, credential: "" });
|
||||
}}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
loginMethod === "email"
|
||||
? "bg-amber-500 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
بريد إلكتروني
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginMethod("phone");
|
||||
setFormData({ ...formData, credential: "" });
|
||||
}}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
loginMethod === "phone"
|
||||
? "bg-amber-500 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
رقم الهاتف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Credential input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{loginMethod === "email"
|
||||
? "البريد الإلكتروني"
|
||||
: "رقم الهاتف"}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
{loginMethod === "email" ? (
|
||||
<Mail
|
||||
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
) : (
|
||||
<Phone
|
||||
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
// type={loginMethod === 'email' ? 'email' : 'tel'}
|
||||
value={formData.credential}
|
||||
onChange={(e) => handleCredentialChange(e.target.value)}
|
||||
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.credential
|
||||
? "border-red-500"
|
||||
: "border-gray-700"
|
||||
}`}
|
||||
placeholder={
|
||||
loginMethod === "email"
|
||||
? "example@email.com"
|
||||
: "+963XXXXXXXXX"
|
||||
}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
{errors.credential && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.credential}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock
|
||||
className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
password: e.target.value,
|
||||
});
|
||||
if (errors.password)
|
||||
setErrors({ ...errors, password: null });
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.password}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
rememberMe: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||
تذكرني
|
||||
</span>
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
جاري تسجيل الدخول...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
تم بنجاح!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
تسجيل الدخول
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
) : (
|
||||
/* OTP Verification Step */
|
||||
<motion.form
|
||||
key="otp"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onSubmit={handleVerifyOTP}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
|
||||
<p className="text-gray-300 text-sm">
|
||||
تم إرسال رمز التحقق إلى{" "}
|
||||
<span className="text-white font-medium" dir="ltr">
|
||||
{formData.credential}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
رمز التحقق
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => {
|
||||
setOtpCode(e.target.value);
|
||||
if (otpError) setOtpError("");
|
||||
}}
|
||||
className={`w-full px-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white text-center text-2xl tracking-[0.5em] placeholder-gray-500 transition-all ${
|
||||
otpError ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="______"
|
||||
maxLength={6}
|
||||
dir="ltr"
|
||||
/>
|
||||
{otpError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1 text-center"
|
||||
>
|
||||
{otpError}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading || isSuccess}
|
||||
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
جاري التحقق...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
تم بنجاح!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="w-5 h-5" />
|
||||
تحقق
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("login");
|
||||
setOtpCode("");
|
||||
setOtpError("");
|
||||
console.log("[OTP] Going back to login");
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
← العودة
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resendOTP}
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
إعادة إرسال الرمز
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-400 mt-6"
|
||||
>
|
||||
ليس لديك حساب؟{" "}
|
||||
<Link
|
||||
href="/auth/choose-role"
|
||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
إنشاء حساب جديد
|
||||
</Link>
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-500 text-xs mt-4"
|
||||
>
|
||||
بتسجيل الدخول، أنت توافق على{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
شروط الاستخدام
|
||||
</Link>{" "}
|
||||
و{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
سياسة الخصوصية
|
||||
</Link>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, User, Calendar, MessageSquare, ThumbsUp, Loader2 } from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { getCustomerRatings } from '../utils/ratings';
|
||||
import AuthService from '../services/AuthService';
|
||||
import StarRating from '../components/ratings/StarRating';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const RATING_FIELDS = [
|
||||
{ key: 'cleanRating', label: 'النظافة' },
|
||||
{ key: 'servicesRating', label: 'الخدمات' },
|
||||
{ key: 'ownerBehaviorRating', label: 'سلوك المالك' },
|
||||
{ key: 'experienceRating', label: 'التجربة العامة' },
|
||||
{ key: 'cleanRating', label: 'ratings.cleanliness' },
|
||||
{ key: 'servicesRating', label: 'ratings.services' },
|
||||
{ key: 'ownerBehaviorRating', label: 'ratings.ownerBehavior' },
|
||||
{ key: 'experienceRating', label: 'ratings.experience' },
|
||||
];
|
||||
|
||||
function calcOverall(ratings) {
|
||||
@ -24,7 +25,7 @@ function calcOverall(ratings) {
|
||||
return total / ratings.length;
|
||||
}
|
||||
|
||||
function RatingCard({ rating }) {
|
||||
function RatingCard({ rating, t }) {
|
||||
const dateStr = rating.createdAt
|
||||
? new Date(rating.createdAt).toLocaleDateString('ar')
|
||||
: null;
|
||||
@ -49,7 +50,7 @@ function RatingCard({ rating }) {
|
||||
<div className="space-y-3 mb-4">
|
||||
{RATING_FIELDS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{label}</span>
|
||||
<span className="text-sm text-gray-600">{t(label)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating rating={Number(rating[key]) || 0} size={18} readOnly />
|
||||
<span className="text-sm font-medium text-gray-700 w-6 text-left">
|
||||
@ -71,18 +72,19 @@ function RatingCard({ rating }) {
|
||||
}
|
||||
|
||||
export default function MyRatesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [ratings, setRatings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const user = AuthService.getUser();
|
||||
if (!user || !user.id) {
|
||||
toast.error('يرجى تسجيل الدخول أولاً');
|
||||
toast.error(t('auth.loginRequired'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
loadRatings(user.id);
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
async function loadRatings(userId) {
|
||||
try {
|
||||
@ -91,20 +93,14 @@ export default function MyRatesPage() {
|
||||
setRatings(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('فشل تحميل التقييمات');
|
||||
toast.error(t('ratings.loadError'));
|
||||
setRatings([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center" dir="rtl">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const overall = calcOverall(ratings);
|
||||
|
||||
@ -113,8 +109,8 @@ export default function MyRatesPage() {
|
||||
<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">
|
||||
<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('ratings.myRatings')}</h1>
|
||||
<p className="text-gray-600">{t('ratings.receivedFromOwners')}</p>
|
||||
</motion.div>
|
||||
|
||||
{ratings.length > 0 && (
|
||||
@ -125,9 +121,9 @@ export default function MyRatesPage() {
|
||||
<StarRating rating={Math.round(overall)} size={24} readOnly />
|
||||
</div>
|
||||
<div className="text-4xl font-bold">{overall.toFixed(1)}</div>
|
||||
<div className="text-amber-100 text-sm mt-1">المعدل العام</div>
|
||||
<div className="text-amber-100 text-sm mt-1">{t('ratings.overallAverage')}</div>
|
||||
<div className="text-amber-100 text-xs mt-2">
|
||||
بناءً على {ratings.length} {ratings.length === 1 ? 'تقييم' : 'تقييمات'}
|
||||
{t('ratings.basedOn')} {ratings.length} {ratings.length === 1 ? t('ratings.review') : t('ratings.reviews')}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -136,13 +132,13 @@ export default function MyRatesPage() {
|
||||
<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">
|
||||
<Star className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد تقييمات</h3>
|
||||
<p className="text-gray-500">لم تقم باستئجار أي عقار بعد لتتلقى تقييمات</p>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('ratings.noRatings')}</h3>
|
||||
<p className="text-gray-500">{t('ratings.noRatingsDescription')}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{ratings.map((r, i) => (
|
||||
<RatingCard key={r.id || i} rating={r} />
|
||||
<RatingCard key={r.id || i} rating={r} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle, CheckCheck, Loader2 } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { getUserNotifications } from '@/app/utils/api';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle, CheckCheck, Loader2 } from "lucide-react";
|
||||
import AuthService from "@/app/services/AuthService";
|
||||
import { getUserNotifications } from "@/app/utils/api";
|
||||
import Loading from "../loading";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
@ -16,7 +19,7 @@ export default function NotificationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
router.push('/login');
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
fetchNotifications();
|
||||
@ -31,36 +34,27 @@ export default function NotificationsPage() {
|
||||
setNotifications(items);
|
||||
setUnreadCount(items.length);
|
||||
} catch (err) {
|
||||
console.error('Error fetching notifications:', err);
|
||||
setError(err.message || 'فشل تحميل الإشعارات');
|
||||
console.error("Error fetching notifications:", err);
|
||||
setError(err.message || t("fetch-notifications-failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = (id) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<Loader2 className="w-12 h-12 text-amber-500 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
<div>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -68,13 +62,9 @@ export default function NotificationsPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">خطأ في التحميل</h3>
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">{t("loading-error")}</h3>
|
||||
<p className="text-gray-500 mb-4">{error}</p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
@ -82,7 +72,7 @@ export default function NotificationsPage() {
|
||||
onClick={fetchNotifications}
|
||||
className="px-6 py-2 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
إعادة المحاولة
|
||||
{t("retry")}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -92,16 +82,10 @@ export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex justify-between items-center mb-8"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الإشعارات</h1>
|
||||
<p className="text-gray-600">
|
||||
{unreadCount > 0 ? `لديك ${unreadCount} إشعار غير مقروء` : 'جميع الإشعارات مقروءة'}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t("notifications")}</h1>
|
||||
<p className="text-gray-600">{unreadCount > 0 ? t("unread-notifications", { count: unreadCount }) : t("all-notifications-read")}</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<motion.button
|
||||
@ -111,20 +95,16 @@ export default function NotificationsPage() {
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all shadow-sm"
|
||||
>
|
||||
<CheckCheck className="w-4 h-4 text-amber-500" />
|
||||
تحديد الكل كمقروء
|
||||
{t("mark-all-read")}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{notifications.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"
|
||||
>
|
||||
<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">
|
||||
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد إشعارات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا الإشعارات المتعلقة بحجوزاتك ومدفوعاتك</p>
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">{t("no-notifications")}</h3>
|
||||
<p className="text-gray-500">{t("notifications-empty-hint")}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@ -135,32 +115,20 @@ export default function NotificationsPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
transition={{ delay: index * 0.05, type: 'spring', stiffness: 100 }}
|
||||
transition={{ delay: index * 0.05, type: "spring", stiffness: 100 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
className={`bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md cursor-pointer ${
|
||||
!notification.read ? 'border-amber-200 bg-amber-50/50' : 'border-gray-200'
|
||||
}`}
|
||||
className={`bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md cursor-pointer ${!notification.read ? "border-amber-200 bg-amber-50/50" : "border-gray-200"}`}
|
||||
>
|
||||
<div className="p-5 flex gap-4">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center shrink-0 ${
|
||||
!notification.read ? 'bg-amber-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
{!notification.read ? (
|
||||
<Bell className="w-6 h-6 text-amber-600" />
|
||||
) : (
|
||||
<CheckCircle className="w-6 h-6 text-gray-400" />
|
||||
)}
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center shrink-0 ${!notification.read ? "bg-amber-100" : "bg-gray-100"}`}>
|
||||
{!notification.read ? <Bell className="w-6 h-6 text-amber-600" /> : <CheckCircle className="w-6 h-6 text-gray-400" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className={`text-base ${!notification.read ? 'font-bold text-gray-900' : 'font-medium text-gray-700'}`}>
|
||||
{notification.title}
|
||||
</h3>
|
||||
{notification.message && (
|
||||
<p className="text-gray-500 text-sm mt-1 line-clamp-2">{notification.message}</p>
|
||||
)}
|
||||
<h3 className={`text-base ${!notification.read ? "font-bold text-gray-900" : "font-medium text-gray-700"}`}>{notification.title}</h3>
|
||||
{notification.message && <p className="text-gray-500 text-sm mt-1 line-clamp-2">{notification.message}</p>}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{notification.date && (
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
@ -176,9 +144,7 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!notification.read && (
|
||||
<span className="w-2.5 h-2.5 bg-amber-500 rounded-full shrink-0 mt-2" />
|
||||
)}
|
||||
{!notification.read && <span className="w-2.5 h-2.5 bg-amber-500 rounded-full shrink-0 mt-2" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,33 +5,35 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Home, Search, Rocket, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STORAGE_KEY = 'sweethome_onboarding_completed';
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: Home,
|
||||
title: 'مرحباً بك في SweetHome',
|
||||
description: 'نحن هنا لمساعدتك في إيجاد العقار المثالي الذي تحلم به. اكتشف آلاف العقارات المتاحة للإيجار والبيع في جميع أنحاء سوريا.',
|
||||
title: t('onboarding.step1.title'),
|
||||
description: t('onboarding.step1.description'),
|
||||
gradient: 'from-amber-500 to-orange-600',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'ابحث عن منزل أحلامك',
|
||||
description: 'استخدم محرك البحث الذكي لدينا للعثور على العقار المناسب. تصفح حسب الموقع، السعر، النوع، والمزيد من الفلاتر المتقدمة.',
|
||||
title: t('onboarding.step2.title'),
|
||||
description: t('onboarding.step2.description'),
|
||||
gradient: 'from-blue-500 to-indigo-600',
|
||||
},
|
||||
{
|
||||
icon: Rocket,
|
||||
title: 'انطلق الآن',
|
||||
description: 'انشئ حسابك مجاناً وابدأ رحلة البحث عن منزلك الجديد. يمكنك حفظ العقارات المفضلة والتواصل مع المالكين مباشرة.',
|
||||
title: t('onboarding.step3.title'),
|
||||
description: t('onboarding.step3.description'),
|
||||
gradient: 'from-emerald-500 to-teal-600',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -74,7 +76,7 @@ export default function OnboardingPage() {
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{[...Array(30)].map((_, i) => (
|
||||
<motion.div
|
||||
@ -142,7 +144,7 @@ export default function OnboardingPage() {
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.1 }}
|
||||
className="w-24 h-24 mx-auto bg-white/20 rounded-3xl flex items-center justify-center backdrop-blur-sm"
|
||||
>
|
||||
<IconComponent className="w-12 h-12 text-white" />
|
||||
<IconComponent className="w-12 h-12 text-amber-50" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
@ -152,7 +154,7 @@ export default function OnboardingPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="text-3xl font-bold text-white mb-4"
|
||||
className="text-3xl font-bold text-amber-50 mb-4"
|
||||
>
|
||||
{step.title}
|
||||
</motion.h1>
|
||||
@ -199,7 +201,7 @@ export default function OnboardingPage() {
|
||||
className="flex items-center gap-2 px-5 py-3 rounded-xl text-gray-300 hover:text-white hover:bg-white/10 transition-all border border-white/10"
|
||||
>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
<span>السابق</span>
|
||||
<span>{t('onboarding.prev')}</span>
|
||||
</motion.button>
|
||||
) : (
|
||||
<div />
|
||||
@ -212,7 +214,7 @@ export default function OnboardingPage() {
|
||||
onClick={nextStep}
|
||||
className={`flex items-center gap-2 px-6 py-3 rounded-xl text-white font-medium bg-gradient-to-r ${step.gradient} hover:shadow-lg transition-all`}
|
||||
>
|
||||
<span>التالي</span>
|
||||
<span>{t('onboarding.next')}</span>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</motion.button>
|
||||
) : (
|
||||
@ -223,7 +225,7 @@ export default function OnboardingPage() {
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-6 py-3 rounded-xl bg-white text-gray-900 font-bold hover:bg-gray-100 transition-all"
|
||||
>
|
||||
تسجيل جديد
|
||||
{t('onboarding.register')}
|
||||
</motion.button>
|
||||
</Link>
|
||||
<Link href="/login" onClick={handleFinish}>
|
||||
@ -232,7 +234,7 @@ export default function OnboardingPage() {
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-emerald-500 to-teal-600 text-white font-bold hover:shadow-lg transition-all"
|
||||
>
|
||||
تسجيل دخول
|
||||
{t('onboarding.login')}
|
||||
</motion.button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -1,57 +1,50 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Users,
|
||||
Building,
|
||||
Download,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { getOwnerStatistics } from '@/app/utils/api';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, TrendingUp, Calendar, Loader2, Star } from "lucide-react";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import AuthService from "@/app/services/AuthService";
|
||||
import { getOwnerStatistics } from "@/app/utils/api";
|
||||
import Loading from "@/app/loading";
|
||||
|
||||
const StatCard = ({ title, value, icon: Icon, color, subtitle }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
|
||||
>
|
||||
const StatCard = ({ title, value, icon: Icon, color, subtitle, isNA }) => (
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm text-gray-500 mb-1">{title}</h3>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{isNA ? "N/A" : value}</div>
|
||||
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
export default function OwnerAccountBookPage() {
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
totalRevenue: 0,
|
||||
totalReservations: 0,
|
||||
activeProperties: 0,
|
||||
totalRevenue: null,
|
||||
totalReservations: null,
|
||||
activeProperties: null,
|
||||
financialRevenue: null,
|
||||
financialCommission: null,
|
||||
financialBalance: null,
|
||||
directRevenue: null,
|
||||
directCommission: null,
|
||||
directBalance: null,
|
||||
directRating: null,
|
||||
directPostponements: null,
|
||||
});
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isGuest()) {
|
||||
router.push('/auth/choose-role');
|
||||
return;
|
||||
}
|
||||
if (!AuthService.isOwner()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -66,171 +59,82 @@ export default function OwnerAccountBookPage() {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const data = await getOwnerStatistics();
|
||||
|
||||
if (data) {
|
||||
setStats({
|
||||
totalRevenue: data.totalRevenue ?? 0,
|
||||
totalReservations: data.totalReservations ?? 0,
|
||||
activeProperties: data.activeProperties ?? 0,
|
||||
totalRevenue: data.totalRevenue ?? null,
|
||||
totalReservations: data.totalReservations ?? null,
|
||||
activeProperties: data.activeProperties ?? null,
|
||||
financialRevenue: data.financialRevenue ?? data.totalRevenue ?? null,
|
||||
financialCommission: data.financialCommission ?? null,
|
||||
financialBalance: data.financialBalance ?? null,
|
||||
directRevenue: data.directRevenue ?? null,
|
||||
directCommission: data.directCommission ?? null,
|
||||
directBalance: data.directBalance ?? null,
|
||||
directRating: data.directRating ?? null,
|
||||
directPostponements: data.directPostponements ?? null,
|
||||
});
|
||||
if (data.transactions) {
|
||||
setTransactions(data.transactions);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('تعذر تحميل إحصائيات الحساب');
|
||||
toast.error(t("accountBook.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
const num = Number(amount) || 0;
|
||||
return num.toLocaleString() + ' ل.س';
|
||||
const num = Number(amount);
|
||||
if (isNaN(num)) return "N/A";
|
||||
return num.toLocaleString() + " " + t("currency-syp-suffix");
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
toast.success('جاري تصدير البيانات...');
|
||||
const formatNumber = (val) => {
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return "N/A";
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const formatRating = (val) => {
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return "N/A";
|
||||
return num.toFixed(1);
|
||||
};
|
||||
|
||||
const isNA = (val) => val === null || val === undefined || val === "";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل بيانات الحساب...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir={i18n.language === "ar" ? "rtl" : "ltr"}>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 hover:bg-gray-200 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">دفتر الحسابات</h1>
|
||||
<p className="text-gray-600">
|
||||
مرحباً {user?.name}، نظرة عامة على حساباتك
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير
|
||||
</button>
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t("accountBook")}</h1>
|
||||
<p className="text-gray-600">{t("financialRecords")}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="إجمالي الإيرادات"
|
||||
value={formatCurrency(stats.totalRevenue)}
|
||||
icon={DollarSign}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="إجمالي الحجوزات"
|
||||
value={stats.totalReservations}
|
||||
icon={Calendar}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="العقارات النشطة"
|
||||
value={stats.activeProperties}
|
||||
icon={Building}
|
||||
color="bg-amber-500"
|
||||
/>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t("accountBook.ownerFinancialOverview")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<StatCard title={t("accountBook.revenue")} value={formatCurrency(stats.financialRevenue)} icon={TrendingUp} color="bg-emerald-500" isNA={isNA(stats.financialRevenue)} />
|
||||
<StatCard title={t("accountBook.commission")} value={formatCurrency(stats.financialCommission)} icon={DollarSign} color="bg-orange-500" isNA={isNA(stats.financialCommission)} />
|
||||
<StatCard title={t("accountBook.remainingBalance")} value={formatCurrency(stats.financialBalance)} icon={DollarSign} color="bg-blue-500" isNA={isNA(stats.financialBalance)} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-bold text-gray-900">المعاملات المالية</h2>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t("accountBook.directPropertyStats")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<StatCard title={t("accountBook.revenue")} value={formatCurrency(stats.directRevenue)} icon={TrendingUp} color="bg-emerald-500" isNA={isNA(stats.directRevenue)} />
|
||||
<StatCard title={t("accountBook.commission")} value={formatCurrency(stats.directCommission)} icon={DollarSign} color="bg-orange-500" isNA={isNA(stats.directCommission)} />
|
||||
<StatCard title={t("accountBook.remainingBalance")} value={formatCurrency(stats.directBalance)} icon={DollarSign} color="bg-blue-500" isNA={isNA(stats.directBalance)} />
|
||||
<StatCard title={t("accountBook.overallRating")} value={formatRating(stats.directRating)} icon={Star} color="bg-purple-500" isNA={isNA(stats.directRating)} />
|
||||
<StatCard title={t("accountBook.postponements")} value={formatNumber(stats.directPostponements)} icon={Calendar} color="bg-red-500" isNA={isNA(stats.directPostponements)} />
|
||||
</div>
|
||||
{transactions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
التاريخ
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
البيان
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
المبلغ
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
الحالة
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{transactions.map((tx, idx) => (
|
||||
<tr
|
||||
key={tx.id || idx}
|
||||
className={`hover:bg-amber-50/40 transition-colors ${
|
||||
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{tx.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-800 font-medium">
|
||||
{tx.description}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-gray-800">
|
||||
{formatCurrency(tx.amount)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
tx.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: tx.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{tx.status === 'completed'
|
||||
? 'مكتمل'
|
||||
: tx.status === 'pending'
|
||||
? 'قيد الانتظار'
|
||||
: 'ملغي'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center">
|
||||
<DollarSign className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">لا توجد معاملات مالية بعد</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
return (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
@ -39,13 +40,14 @@ import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
|
||||
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [viewType, setViewType] = useState('grid');
|
||||
|
||||
const monthNames = [
|
||||
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
|
||||
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
|
||||
t('calendar.month1'), t('calendar.month2'), t('calendar.month3'), t('calendar.month4'), t('calendar.month5'), t('calendar.month6'),
|
||||
t('calendar.month7'), t('calendar.month8'), t('calendar.month9'), t('calendar.month10'), t('calendar.month11'), t('calendar.month12')
|
||||
];
|
||||
|
||||
const daysInMonth = new Date(
|
||||
@ -76,17 +78,17 @@ const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onProper
|
||||
const totalProperties = properties.length;
|
||||
const bookedCount = properties.filter(p => isDateBookedForProperty(date, p)).length;
|
||||
|
||||
if (bookedCount === 0) return { status: 'all_available', label: 'جميع العقارات متاحة', color: 'bg-green-100 text-green-800' };
|
||||
if (bookedCount === totalProperties) return { status: 'all_booked', label: 'جميع العقارات محجوزة', color: 'bg-red-100 text-red-800' };
|
||||
return { status: 'partial', label: `${bookedCount}/${totalProperties} محجوز`, color: 'bg-yellow-100 text-yellow-800' };
|
||||
if (bookedCount === 0) return { status: 'all_available', label: t('calendar.allAvailable'), color: 'bg-green-100 text-green-800' };
|
||||
if (bookedCount === totalProperties) return { status: 'all_booked', label: t('calendar.allBooked'), color: 'bg-red-100 text-red-800' };
|
||||
return { status: 'partial', label: t('calendar.bookedCount', { count: bookedCount, total: totalProperties }), color: 'bg-yellow-100 text-yellow-800' };
|
||||
} else {
|
||||
const property = properties.find(p => p.id === selectedPropertyId);
|
||||
if (!property) return { status: 'no_property', label: 'غير متاح', color: 'bg-gray-100 text-gray-500' };
|
||||
if (!property) return { status: 'no_property', label: t('calendar.notAvailable'), color: 'bg-gray-100 text-gray-500' };
|
||||
|
||||
const isBooked = isDateBookedForProperty(date, property);
|
||||
return {
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
label: isBooked ? 'محجوز' : 'متاح',
|
||||
label: isBooked ? t('calendar.booked') : t('calendar.available'),
|
||||
color: isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
};
|
||||
}
|
||||
@ -171,7 +173,7 @@ const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onProper
|
||||
onClick={() => setCurrentMonth(new Date())}
|
||||
className="px-4 py-2 bg-amber-500 text-white rounded-xl text-sm hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
اليوم
|
||||
{t('calendar.today')}
|
||||
</button>
|
||||
<div className="flex border border-gray-200 rounded-xl overflow-hidden">
|
||||
<button
|
||||
@ -192,7 +194,7 @@ const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onProper
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 p-4 bg-gray-50 border-b border-gray-200">
|
||||
{['أحد', 'إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت'].map((day, index) => (
|
||||
{[t('calendar.day1'), t('calendar.day2'), t('calendar.day3'), t('calendar.day4'), t('calendar.day5'), t('calendar.day6'), t('calendar.day7')].map((day, index) => (
|
||||
<div key={index} className="text-center text-sm font-medium text-gray-600 py-2">
|
||||
{day}
|
||||
</div>
|
||||
@ -209,23 +211,23 @@ const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onProper
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-100 rounded" />
|
||||
<span className="text-gray-600">متاح</span>
|
||||
<span className="text-gray-600">{t('calendar.available')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-100 rounded" />
|
||||
<span className="text-gray-600">محجوز</span>
|
||||
<span className="text-gray-600">{t('calendar.booked')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-yellow-100 rounded" />
|
||||
<span className="text-gray-600">محجوز جزئياً</span>
|
||||
<span className="text-gray-600">{t('calendar.partiallyBooked')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 ring-2 ring-amber-500 rounded" />
|
||||
<span className="text-gray-600">اليوم</span>
|
||||
<span className="text-gray-600">{t('calendar.today')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 ring-2 ring-blue-500 rounded" />
|
||||
<span className="text-gray-600">محدد</span>
|
||||
<span className="text-gray-600">{t('calendar.selected')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -234,8 +236,9 @@ const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onProper
|
||||
};
|
||||
|
||||
const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
return amount?.toLocaleString() + ' ' + t('calendar.syp');
|
||||
};
|
||||
|
||||
const isDateBooked = (property, date) => {
|
||||
@ -264,8 +267,8 @@ const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) =>
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<CalendarDays className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-2">اختر تاريخاً</h3>
|
||||
<p className="text-gray-500">اضغط على أي يوم في التقويم لعرض حالة العقارات في ذلك التاريخ</p>
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-2">{t('calendar.selectDate')}</h3>
|
||||
<p className="text-gray-500">{t('calendar.clickDayHint')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -282,7 +285,7 @@ const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) =>
|
||||
<div className="p-4 md:p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-amber-100">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
حالة العقارات في تاريخ: {formattedDate}
|
||||
{t('calendar.statusForDate')} {formattedDate}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@ -306,7 +309,7 @@ const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) =>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{isBooked ? 'محجوز' : 'متاح'}
|
||||
{isBooked ? t('calendar.booked') : t('calendar.available')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm mb-2">
|
||||
@ -316,26 +319,26 @@ const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) =>
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bed className="w-4 h-4" />
|
||||
<span>{property.bedrooms} غرف</span>
|
||||
<span>{property.bedrooms} {t('calendar.rooms')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{property.bathrooms} حمامات</span>
|
||||
<span>{property.bathrooms} {t('calendar.bathrooms')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>{property.area} م²</span>
|
||||
<span>{property.area} {t('calendar.sqm')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/يوم</div>
|
||||
<div className="text-xs text-gray-500">{t('calendar.perDay')}</div>
|
||||
{isBooked && booking && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div>مستأجر: {booking.tenantName || 'غير معروف'}</div>
|
||||
<div>من: {booking.startDate} إلى {booking.endDate}</div>
|
||||
<div>{t('calendar.tenant')}: {booking.tenantName || t('calendar.unknown')}</div>
|
||||
<div>{t('calendar.from')} {booking.startDate} {t('calendar.to')} {booking.endDate}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -349,10 +352,11 @@ const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) =>
|
||||
};
|
||||
|
||||
const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
return amount?.toLocaleString() + ' ' + t('calendar.syp');
|
||||
};
|
||||
|
||||
return (
|
||||
@ -385,7 +389,7 @@ const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
<div className="p-6 space-y-6">
|
||||
{property.images && property.images.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3">صور العقار</h3>
|
||||
<h3 className="font-bold text-gray-900 mb-3">{t('calendar.propertyImages')}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{property.images.slice(0, 4).map((image, index) => (
|
||||
<div key={index} className="relative h-32 rounded-lg overflow-hidden bg-gray-100">
|
||||
@ -400,28 +404,28 @@ const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bedrooms}</div>
|
||||
<div className="text-xs text-gray-500">غرف نوم</div>
|
||||
<div className="text-xs text-gray-500">{t('calendar.bedrooms')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.bathrooms}</div>
|
||||
<div className="text-xs text-gray-500">حمامات</div>
|
||||
<div className="text-xs text-gray-500">{t('calendar.bathrooms')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-sm font-bold">{property.area}</div>
|
||||
<div className="text-xs text-gray-500">م²</div>
|
||||
<div className="text-xs text-gray-500">{t('calendar.sqm')}</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 className="text-xs text-gray-500">{t('calendar.perDay')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{property.features && property.features.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3">المميزات</h3>
|
||||
<h3 className="font-bold text-gray-900 mb-3">{t('calendar.features')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{property.features.map((feature, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
|
||||
@ -436,14 +440,14 @@ const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-500" />
|
||||
الحجوزات القادمة
|
||||
{t('calendar.upcomingBookings')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{property.bookings.slice(0, 3).map((booking, index) => (
|
||||
<div key={index} className="bg-gray-50 p-3 rounded-lg flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.startDate} - {booking.endDate}</p>
|
||||
<p className="text-xs text-gray-500">مستأجر: {booking.tenantName || 'غير معروف'}</p>
|
||||
<p className="text-xs text-gray-500">{t('calendar.tenant')}: {booking.tenantName || t('calendar.unknown')}</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</span>
|
||||
</div>
|
||||
@ -458,13 +462,13 @@ const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
href={`/owner/properties/edit?id=${property.id}`}
|
||||
className="flex-1 bg-amber-500 text-white py-3 rounded-xl text-center font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تعديل العقار
|
||||
{t('calendar.editProperty')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.location.href = `/owner/bookings?property=${property.id}`}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl text-center font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
عرض الحجوزات
|
||||
{t('calendar.viewBookings')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -473,6 +477,7 @@ const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
};
|
||||
|
||||
export default function OwnerCalendarPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [properties, setProperties] = useState([]);
|
||||
@ -509,44 +514,44 @@ export default function OwnerCalendarPage() {
|
||||
const mockProperties = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
location: 'دمشق، المزة',
|
||||
title: t('calendar.mockVillaTitle'),
|
||||
location: t('calendar.mockVillaLocation'),
|
||||
bedrooms: 5,
|
||||
bathrooms: 4,
|
||||
area: 450,
|
||||
price: 500000,
|
||||
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7'],
|
||||
features: [t('swimmingPool'), t('privateGarden'), t('parking'), t('propertyService.security247')],
|
||||
images: ['/villa1.jpg'],
|
||||
status: 'available',
|
||||
bookings: [
|
||||
{ startDate: '2024-03-10', endDate: '2024-03-15', totalAmount: 2500000, tenantName: 'أحمد محمد' },
|
||||
{ startDate: '2024-03-20', endDate: '2024-03-25', totalAmount: 2500000, tenantName: 'سارة أحمد' }
|
||||
{ startDate: '2024-03-10', endDate: '2024-03-15', totalAmount: 2500000, tenantName: t('calendar.mockTenant1') },
|
||||
{ startDate: '2024-03-20', endDate: '2024-03-25', totalAmount: 2500000, tenantName: t('calendar.mockTenant2') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
location: 'حلب، الشهباء',
|
||||
title: t('calendar.mockApartmentTitle'),
|
||||
location: t('calendar.mockApartmentLocation'),
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
area: 180,
|
||||
price: 250000,
|
||||
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
|
||||
features: [t('equippedKitchen'), t('balcony'), t('parking'), t('propertyService.elevator')],
|
||||
images: ['/apartment1.jpg'],
|
||||
status: 'available',
|
||||
bookings: [
|
||||
{ startDate: '2024-03-05', endDate: '2024-03-08', totalAmount: 750000, tenantName: 'محمد علي' }
|
||||
{ startDate: '2024-03-05', endDate: '2024-03-08', totalAmount: 750000, tenantName: t('calendar.mockTenant3') }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
location: 'حمص، بابا عمرو',
|
||||
title: t('calendar.mockHouseTitle'),
|
||||
location: t('calendar.mockHouseLocation'),
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
area: 300,
|
||||
price: 350000,
|
||||
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
|
||||
features: [t('largeGarden'), t('parking'), t('fireplace'), t('garage')],
|
||||
images: ['/house1.jpg'],
|
||||
status: 'booked',
|
||||
bookings: []
|
||||
@ -586,14 +591,14 @@ export default function OwnerCalendarPage() {
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل التقويم...</p>
|
||||
<p className="text-gray-600">{t('calendar.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir={i18n.language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<PropertyDetailsModal
|
||||
@ -609,8 +614,8 @@ export default function OwnerCalendarPage() {
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">تقويم العقارات</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.name}، تتبع حالة عقاراتك عبر التقويم</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('calendar.pageTitle')}</h1>
|
||||
<p className="text-gray-600">{t('calendar.welcomeMessage', { name: user?.name })}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
@ -619,7 +624,7 @@ export default function OwnerCalendarPage() {
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
فلترة العقارات
|
||||
{t('calendar.filterProperties')}
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
@ -638,7 +643,7 @@ export default function OwnerCalendarPage() {
|
||||
>
|
||||
<Building className="w-6 h-6 text-amber-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-gray-900">{calendarStats.totalProperties}</div>
|
||||
<div className="text-sm text-gray-600">إجمالي العقارات</div>
|
||||
<div className="text-sm text-gray-600">{t('calendar.totalProperties')}</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -648,7 +653,7 @@ export default function OwnerCalendarPage() {
|
||||
>
|
||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-green-600">{calendarStats.availableToday}</div>
|
||||
<div className="text-sm text-gray-600">متاح اليوم</div>
|
||||
<div className="text-sm text-gray-600">{t('calendar.availableToday')}</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -658,7 +663,7 @@ export default function OwnerCalendarPage() {
|
||||
>
|
||||
<XCircle className="w-6 h-6 text-red-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-red-600">{calendarStats.bookedToday}</div>
|
||||
<div className="text-sm text-gray-600">محجوز اليوم</div>
|
||||
<div className="text-sm text-gray-600">{t('calendar.bookedToday')}</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -668,7 +673,7 @@ export default function OwnerCalendarPage() {
|
||||
>
|
||||
<CalendarDays className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-blue-600">{calendarStats.upcomingBookings}</div>
|
||||
<div className="text-sm text-gray-600">حجوزات قادمة</div>
|
||||
<div className="text-sm text-gray-600">{t('calendar.upcomingBookings')}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@ -682,13 +687,13 @@ export default function OwnerCalendarPage() {
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<label className="text-sm font-medium text-gray-700">اختر عقاراً:</label>
|
||||
<label className="text-sm font-medium text-gray-700">{t('calendar.selectProperty')}</label>
|
||||
<select
|
||||
value={selectedPropertyId}
|
||||
onChange={(e) => setSelectedPropertyId(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="all">جميع العقارات</option>
|
||||
<option value="all">{t('calendar.allProperties')}</option>
|
||||
{properties.map((property) => (
|
||||
<option key={property.id} value={property.id}>{property.title}</option>
|
||||
))}
|
||||
@ -697,7 +702,7 @@ export default function OwnerCalendarPage() {
|
||||
onClick={() => setSelectedPropertyId('all')}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
إعادة تعيين
|
||||
{t('calendar.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -729,7 +734,7 @@ export default function OwnerCalendarPage() {
|
||||
className="mt-6 text-center text-sm text-gray-500"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 inline ml-1" />
|
||||
اضغط على أي عقار لعرض التفاصيل الكاملة
|
||||
{t('calendar.clickPropertyHint')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -298,9 +298,11 @@ import { useRouter } from 'next/navigation';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function OwnerProfitsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -404,29 +406,29 @@ export default function OwnerProfitsPage() {
|
||||
const handleExportReport = () => {
|
||||
try {
|
||||
const exportData = tableData.map((row) => ({
|
||||
'العقار': row.property,
|
||||
'رقم الحجز': row.bookingNumber,
|
||||
'من تاريخ': row.fromDate,
|
||||
'حتى تاريخ': row.toDate,
|
||||
'العروض المستلم': row.amountReceived,
|
||||
'عمولة المنصة': row.platformCommission,
|
||||
'ربح المنصة (5%)': row.platformProfit,
|
||||
'المستحق للمالك': row.ownerDue,
|
||||
'تم التحويل للمالك': row.transferredToOwner,
|
||||
'رقم وصل التحويل': row.transferReceipt,
|
||||
[t('profits.colProperty')]: row.property,
|
||||
[t('profits.colBookingNumber')]: row.bookingNumber,
|
||||
[t('profits.colFromDate')]: row.fromDate,
|
||||
[t('profits.colToDate')]: row.toDate,
|
||||
[t('profits.colAmountReceived')]: row.amountReceived,
|
||||
[t('profits.colPlatformCommission')]: row.platformCommission,
|
||||
[t('profits.colPlatformProfit')]: row.platformProfit,
|
||||
[t('profits.colOwnerDue')]: row.ownerDue,
|
||||
[t('profits.colTransferred')]: row.transferredToOwner,
|
||||
[t('profits.colTransferReceipt')]: row.transferReceipt,
|
||||
}));
|
||||
|
||||
exportData.push({
|
||||
'العقار': 'الإجمالي العام',
|
||||
'رقم الحجز': '',
|
||||
'من تاريخ': '',
|
||||
'حتى تاريخ': '',
|
||||
'العروض المستلم': totals.totalAmountReceived,
|
||||
'عمولة المنصة': totals.totalCommission,
|
||||
'ربح المنصة (5%)': totals.totalPlatformProfit,
|
||||
'المستحق للمالك': totals.totalOwnerDue,
|
||||
'تم التحويل للمالك': totals.totalTransferred,
|
||||
'رقم وصل التحويل': '—',
|
||||
[t('profits.colProperty')]: t('profits.totalGeneral'),
|
||||
[t('profits.colBookingNumber')]: '',
|
||||
[t('profits.colFromDate')]: '',
|
||||
[t('profits.colToDate')]: '',
|
||||
[t('profits.colAmountReceived')]: totals.totalAmountReceived,
|
||||
[t('profits.colPlatformCommission')]: totals.totalCommission,
|
||||
[t('profits.colPlatformProfit')]: totals.totalPlatformProfit,
|
||||
[t('profits.colOwnerDue')]: totals.totalOwnerDue,
|
||||
[t('profits.colTransferred')]: totals.totalTransferred,
|
||||
[t('profits.colTransferReceipt')]: '—',
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
@ -445,14 +447,14 @@ export default function OwnerProfitsPage() {
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'أرباح المالك');
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, t('profits.sheetName'));
|
||||
|
||||
XLSX.writeFile(workbook, `تقرير_الأرباح_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
|
||||
XLSX.writeFile(workbook, `${t('profits.fileName')}${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
|
||||
|
||||
toast.success('تم تصدير التقرير بنجاح!');
|
||||
toast.success(t('profits.exportSuccess'));
|
||||
} catch (error) {
|
||||
console.error('خطأ في التصدير:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير التقرير');
|
||||
console.error(t('profits.exportErrorLog'), error);
|
||||
toast.error(t('profits.exportError'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -461,14 +463,14 @@ export default function OwnerProfitsPage() {
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري تحميل بيانات الأرباح...</p>
|
||||
<p className="text-gray-600">{t('profits.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir={i18n.language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<motion.div
|
||||
@ -477,9 +479,9 @@ export default function OwnerProfitsPage() {
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('profits.pageTitle')}</h1>
|
||||
<p className="text-gray-600">
|
||||
مرحباً {user?.name}
|
||||
{t('profits.greeting')} {user?.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@ -487,7 +489,7 @@ export default function OwnerProfitsPage() {
|
||||
className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
{t('profits.exportReport')}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
@ -500,18 +502,18 @@ export default function OwnerProfitsPage() {
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-800 text-gray-100">
|
||||
<tr>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العقار</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم الحجز</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">من تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">حتى تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العروض المستلم</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">عمولة المنصة</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colProperty')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colBookingNumber')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colFromDate')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colToDate')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colAmountReceived')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colPlatformCommission')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider bg-amber-50 text-amber-800">
|
||||
ربح المنصة <span className="font-normal text-[11px] block">(5% من العربون)</span>
|
||||
{t('profits.colPlatformProfit')} <span className="font-normal text-[11px] block">{t('profits.colPlatformProfitSub')}</span>
|
||||
</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">المستحق للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">تم التحويل للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم وصل التحويل</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colOwnerDue')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colTransferred')}</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">{t('profits.colTransferReceipt')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
@ -558,7 +560,7 @@ export default function OwnerProfitsPage() {
|
||||
<tfoot className="bg-gray-100 border-t-2 border-gray-300">
|
||||
<tr>
|
||||
<td colSpan="4" className="px-4 py-4 text-right font-bold text-gray-800">
|
||||
الإجمالي العام
|
||||
{t('profits.totalGeneral')}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalAmountReceived}
|
||||
@ -582,8 +584,8 @@ export default function OwnerProfitsPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
|
||||
<span className="inline-flex items-center gap-1"></span> ملاحظة:
|
||||
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
|
||||
<span className="inline-flex items-center gap-1"></span> {t('profits.note')}
|
||||
<strong> {t('profits.colPlatformProfit')} </strong>{t('profits.noteMiddle')}<strong className="text-amber-600">5%</strong>{t('profits.noteEnd', { colName: t('profits.colAmountReceived') })}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user