forked from amer2004/SweetHome
Compare commits
29 Commits
| 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 |
@ -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,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||
import BottomNav from './components/BottomNav';
|
||||
import BottomNav from "./components/BottomNav";
|
||||
import {
|
||||
Globe,
|
||||
LogIn,
|
||||
@ -16,25 +16,16 @@ import {
|
||||
Building,
|
||||
PlusCircle,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Settings,
|
||||
Edit,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Camera,
|
||||
Bell,
|
||||
Home,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
X,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Star,
|
||||
Moon,
|
||||
Sun,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
@ -43,8 +34,9 @@ import AuthService from "./services/AuthService";
|
||||
import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||
import "./i18n/config";
|
||||
import NotificationHandler from "./components/NotificationHandler";
|
||||
import { useTheme } from '@/app/contexts/ThemeContext';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
import { useNotifications } from "@/app/contexts/NotificationsContext";
|
||||
import Loading from "./loading";
|
||||
|
||||
export default function ClientLayout({ children }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@ -56,7 +48,7 @@ export default function ClientLayout({ children }) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const menuRef = useRef(null);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
@ -128,13 +120,7 @@ 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 === "/";
|
||||
@ -154,64 +140,52 @@ 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" suppressHydrationWarning>{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Loading/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{!isAuthPage && !isAuthenticated && (
|
||||
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${isHomePage ? "bg-transparent backdrop-blur-sm" : "bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm"}`}>
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${isHomePage ? "bg-transparent backdrop-blur-sm" : "bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm"}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className={`flex justify-between items-center h-20 ${currentLanguage === "ar" ? "flex-row-reverse" : ""}`}>
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<div className="relative w-[150px] h-[60px]">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={t("logoAlt")}
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<Image src="/logo.png" alt={t("logoAlt")} fill className="object-contain" priority />
|
||||
</div>
|
||||
<span className={`text-3xl font-bold hidden md:block ${isHomePage ? "text-white" : "text-gray-800"}`}>
|
||||
{t("brandNamePart1")}
|
||||
<span className={isHomePage ? "text-amber-400" : "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 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"}`}>
|
||||
<button
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${isHomePage ? "text-white/80 hover:text-amber-300 hover:bg-white/10" : "text-gray-700 hover:text-amber-600 hover:bg-amber-50"}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z"/>
|
||||
<path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z"/>
|
||||
<path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z" />
|
||||
<path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">{t("downloadApp")}</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 translate-y-2 group-hover:translate-y-0">
|
||||
<div className="p-2">
|
||||
<a href="/files/SweetHome.apk" download
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<a href="/files/SweetHome.apk" download className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="#16a34a" viewBox="0 0 16 16">
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
@ -222,7 +196,7 @@ export default function ClientLayout({ children }) {
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg opacity-50 cursor-not-allowed">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#6b7280" viewBox="0 0 16 16">
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
@ -234,9 +208,12 @@ export default function ClientLayout({ children }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavLink light={isHomePage} href="/">{t("home")}</NavLink>
|
||||
<NavLink light={isHomePage} href="/properties">{t("ourProperties")}</NavLink>
|
||||
|
||||
<NavLink light={isHomePage} href="/">
|
||||
{t("home")}
|
||||
</NavLink>
|
||||
<NavLink light={isHomePage} href="/properties">
|
||||
{t("ourProperties")}
|
||||
</NavLink>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
@ -311,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 || 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>
|
||||
<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">{t("profile")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("viewAndEditInfo")}
|
||||
</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">{t("favorites")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("savedProperties")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("savedProperties")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -365,37 +322,23 @@ export default function ClientLayout({ children }) {
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("myBookedProperties")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("viewAndRateBooked")}
|
||||
</p>
|
||||
<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">{t("myRatings")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("ratingsReceived")}
|
||||
</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">{t("settings")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("accountAndSecurity")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("accountAndSecurity")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -411,9 +354,7 @@ export default function ClientLayout({ children }) {
|
||||
<Building className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("myProperties")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("manageYourProperties")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("manageYourProperties")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -425,9 +366,7 @@ export default function ClientLayout({ children }) {
|
||||
<PlusCircle className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("addProperty")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("addNewProperty")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("addNewProperty")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -439,9 +378,7 @@ export default function ClientLayout({ children }) {
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("reservations")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("manageReservations")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("manageReservations")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -453,9 +390,7 @@ export default function ClientLayout({ children }) {
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("calendar")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("propertyAvailability")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("propertyAvailability")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -467,9 +402,7 @@ export default function ClientLayout({ children }) {
|
||||
<TrendingUp className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("profits")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("statisticsAndProfits")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("statisticsAndProfits")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -481,9 +414,7 @@ export default function ClientLayout({ children }) {
|
||||
<DollarSign className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("accountBook")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("financialRecords")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("financialRecords")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -501,9 +432,7 @@ export default function ClientLayout({ children }) {
|
||||
<Calendar className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("myBookings")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("viewBookings")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("viewBookings")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -515,9 +444,7 @@ export default function ClientLayout({ children }) {
|
||||
<CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("myBookedProperties")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("viewAndRateBooked")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("viewAndRateBooked")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -529,23 +456,15 @@ export default function ClientLayout({ children }) {
|
||||
<Star className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("myRatings")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("ratingsReceived")}
|
||||
</p>
|
||||
<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">{t("reports")}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("submitAndTrackReports")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{t("submitAndTrackReports")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
@ -553,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">{t("logout")}</p>
|
||||
<p className="text-xs text-red-400">
|
||||
{t("endSession")}
|
||||
</p>
|
||||
<p className="text-xs text-red-400">{t("endSession")}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -574,21 +488,10 @@ export default function ClientLayout({ children }) {
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex items-center gap-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5 text-gray-700" /> : <Moon className="w-5 h-5 text-gray-700" />}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, rotate: 360 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() =>
|
||||
changeLanguage(currentLanguage === "en" ? "ar" : "en")
|
||||
}
|
||||
onClick={() => changeLanguage(currentLanguage === "en" ? "ar" : "en")}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<Globe className="w-5 h-5 text-gray-700" />
|
||||
@ -609,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>
|
||||
@ -637,53 +536,50 @@ export default function ClientLayout({ children }) {
|
||||
{/* Download App - Mobile */}
|
||||
<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"}`}>
|
||||
<a
|
||||
href="/files/SweetHome.apk"
|
||||
download
|
||||
onClick={closeMobileMenu}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors ${isHomePage ? "text-green-400 hover:bg-white/10" : "text-green-600 hover:bg-green-50"}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
|
||||
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">{t("android")} - {t("downloadAPK")}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t("android")} - {t("downloadAPK")}
|
||||
</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md text-gray-400 cursor-not-allowed opacity-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
|
||||
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("ios")} - {t("comingSoon")}</span>
|
||||
<span className="text-sm">
|
||||
{t("ios")} - {t("comingSoon")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`border-t my-2 ${isHomePage ? "border-white/10" : "border-gray-200"}`}></div>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<MobileNavLink light={isHomePage} 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 light={isHomePage}
|
||||
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 light={isHomePage}
|
||||
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 light={isHomePage}
|
||||
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")}
|
||||
@ -701,10 +597,7 @@ export default function ClientLayout({ children }) {
|
||||
{t("login")}
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink light={isHomePage}
|
||||
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")}
|
||||
@ -722,7 +615,7 @@ export default function ClientLayout({ children }) {
|
||||
<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)' }}
|
||||
style={{ backgroundImage: "url(/hero.jpg)" }}
|
||||
initial={{ scale: 1.1 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
@ -732,110 +625,91 @@ export default function ClientLayout({ children }) {
|
||||
)}
|
||||
|
||||
<main
|
||||
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" : ""}`}
|
||||
>
|
||||
{!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={toggleTheme} className="relative group flex items-center justify-center w-9 h-9 rounded-full transition-colors hover:bg-white/10 nav-icon">
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon 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">
|
||||
{theme === 'dark' ? t('lightMode') : t('darkMode')}
|
||||
</div>
|
||||
</button>
|
||||
<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}
|
||||
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" : ""}`}
|
||||
>
|
||||
{!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>
|
||||
{isAuthenticated && !isAuthPage && (
|
||||
<BottomNav isOwner={isOwner} isOwnerOrAgent={isOwnerOrAgent} />
|
||||
)}
|
||||
</main>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
@ -846,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" />
|
||||
|
||||
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,24 +1,15 @@
|
||||
'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 { 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 { 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();
|
||||
@ -26,8 +17,8 @@ export default function AccountVerificationPage() {
|
||||
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);
|
||||
@ -43,7 +34,7 @@ export default function AccountVerificationPage() {
|
||||
setUser(authUser);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
router.push('/login');
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
@ -51,10 +42,10 @@ export default function AccountVerificationPage() {
|
||||
setSendingEmailOTP(true);
|
||||
try {
|
||||
await sendEmailOTP();
|
||||
toast.success(t('accountVerification.emailOtpSent'));
|
||||
toast.success(t("accountVerification.emailOtpSent"));
|
||||
setShowEmailInput(true);
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('accountVerification.otpSendFailed'));
|
||||
toast.error(err.message || t("accountVerification.otpSendFailed"));
|
||||
} finally {
|
||||
setSendingEmailOTP(false);
|
||||
}
|
||||
@ -62,7 +53,7 @@ export default function AccountVerificationPage() {
|
||||
|
||||
const handleVerifyEmail = async () => {
|
||||
if (!emailCode.trim()) {
|
||||
toast.error(t('accountVerification.otpRequired'));
|
||||
toast.error(t("accountVerification.otpRequired"));
|
||||
return;
|
||||
}
|
||||
setVerifyingEmail(true);
|
||||
@ -70,9 +61,9 @@ export default function AccountVerificationPage() {
|
||||
await verifyEmail(emailCode.trim());
|
||||
setEmailVerified(true);
|
||||
setShowEmailInput(false);
|
||||
toast.success(t('accountVerification.emailVerifiedSuccess'));
|
||||
toast.success(t("accountVerification.emailVerifiedSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('accountVerification.otpInvalid'));
|
||||
toast.error(err.message || t("accountVerification.otpInvalid"));
|
||||
} finally {
|
||||
setVerifyingEmail(false);
|
||||
}
|
||||
@ -82,10 +73,10 @@ export default function AccountVerificationPage() {
|
||||
setSendingPhoneOTP(true);
|
||||
try {
|
||||
await sendPhoneOTP();
|
||||
toast.success(t('accountVerification.phoneOtpSent'));
|
||||
toast.success(t("accountVerification.phoneOtpSent"));
|
||||
setShowPhoneInput(true);
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('accountVerification.otpSendFailed'));
|
||||
toast.error(err.message || t("accountVerification.otpSendFailed"));
|
||||
} finally {
|
||||
setSendingPhoneOTP(false);
|
||||
}
|
||||
@ -93,7 +84,7 @@ export default function AccountVerificationPage() {
|
||||
|
||||
const handleVerifyPhone = async () => {
|
||||
if (!phoneCode.trim()) {
|
||||
toast.error(t('accountVerification.otpRequired'));
|
||||
toast.error(t("accountVerification.otpRequired"));
|
||||
return;
|
||||
}
|
||||
setVerifyingPhone(true);
|
||||
@ -101,80 +92,56 @@ export default function AccountVerificationPage() {
|
||||
await verifyPhone(phoneCode.trim());
|
||||
setPhoneVerified(true);
|
||||
setShowPhoneInput(false);
|
||||
toast.success(t('accountVerification.phoneVerifiedSuccess'));
|
||||
toast.success(t("accountVerification.phoneVerifiedSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('accountVerification.otpInvalid'));
|
||||
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">{t('accountVerification.title')}</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">{t('accountVerification.emailSection')}</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')}
|
||||
{t("accountVerification.verified")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">{user?.email || t('accountVerification.emailNotRegistered')}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{emailVerified
|
||||
? t('accountVerification.emailVerifiedDesc')
|
||||
: t('accountVerification.emailVerifyDesc')}
|
||||
</p>
|
||||
<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
|
||||
@ -182,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 ? t('accountVerification.sending') : t('accountVerification.sendOtp')}
|
||||
{sendingEmailOTP ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
{sendingEmailOTP ? t("accountVerification.sending") : t("accountVerification.sendOtp")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEmailInput && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<p className="text-xs text-gray-500">{t('accountVerification.enter6DigitCode')}</p>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} className="space-y-3">
|
||||
<p className="text-xs text-gray-500">{t("accountVerification.enter6DigitCode")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={emailCode}
|
||||
onChange={(e) => setEmailCode(e.target.value)}
|
||||
placeholder={t('accountVerification.otpPlaceholder')}
|
||||
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"
|
||||
/>
|
||||
@ -213,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" />
|
||||
)}
|
||||
{t('accountVerification.verify')}
|
||||
{verifyingEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />}
|
||||
{t("accountVerification.verify")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendEmailOTP}
|
||||
disabled={sendingEmailOTP}
|
||||
className="text-xs text-amber-600 hover:text-amber-700"
|
||||
>
|
||||
{t('accountVerification.resendCode')}
|
||||
<button onClick={handleSendEmailOTP} disabled={sendingEmailOTP} className="text-xs text-amber-600 hover:text-amber-700">
|
||||
{t("accountVerification.resendCode")}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -234,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">{t('accountVerification.emailVerifiedSuccess')}</span>
|
||||
<span className="text-sm font-medium">{t("accountVerification.emailVerifiedSuccess")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -243,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">{t('accountVerification.phoneSection')}</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')}
|
||||
{t("accountVerification.verified")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">{user?.phone || t('accountVerification.phoneNotRegistered')}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{phoneVerified
|
||||
? t('accountVerification.phoneVerifiedDesc')
|
||||
: t('accountVerification.phoneVerifyDesc')}
|
||||
</p>
|
||||
<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
|
||||
@ -267,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 ? t('accountVerification.sending') : t('accountVerification.sendOtp')}
|
||||
{sendingPhoneOTP ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
{sendingPhoneOTP ? t("accountVerification.sending") : t("accountVerification.sendOtp")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPhoneInput && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<p className="text-xs text-gray-500">{t('accountVerification.enter6DigitCodePhone')}</p>
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} className="space-y-3">
|
||||
<p className="text-xs text-gray-500">{t("accountVerification.enter6DigitCodePhone")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={phoneCode}
|
||||
onChange={(e) => setPhoneCode(e.target.value)}
|
||||
placeholder={t('accountVerification.otpPlaceholder')}
|
||||
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"
|
||||
/>
|
||||
@ -298,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" />
|
||||
)}
|
||||
{t('accountVerification.verify')}
|
||||
{verifyingPhone ? <Loader2 className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />}
|
||||
{t("accountVerification.verify")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendPhoneOTP}
|
||||
disabled={sendingPhoneOTP}
|
||||
className="text-xs text-amber-600 hover:text-amber-700"
|
||||
>
|
||||
{t('accountVerification.resendCode')}
|
||||
<button onClick={handleSendPhoneOTP} disabled={sendingPhoneOTP} className="text-xs text-amber-600 hover:text-amber-700">
|
||||
{t("accountVerification.resendCode")}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -319,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">{t('accountVerification.phoneVerifiedSuccess')}</span>
|
||||
<span className="text-sm font-medium">{t("accountVerification.phoneVerifiedSuccess")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -328,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">{t('accountVerification.whyVerify')}</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')}
|
||||
{t("accountVerification.reason1")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{t('accountVerification.reason2')}
|
||||
{t("accountVerification.reason2")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{t('accountVerification.reason3')}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ 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();
|
||||
@ -37,7 +38,8 @@ export default function ChooseRolePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div dir={i18n.language === 'ar' ? 'rtl' : 'ltr'} 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
|
||||
@ -88,7 +90,7 @@ 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}
|
||||
>
|
||||
{t('chooseRole.welcomeToSweetHome')}
|
||||
|
||||
@ -1,24 +1,25 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldAlert, LogOut, MessageSquare, Send, Loader2 } from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../services/AuthService';
|
||||
import { sendGeneralReport } from '../utils/api';
|
||||
import { 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) => {
|
||||
@ -30,7 +31,7 @@ export default function BlockedPage() {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.subject.trim() || !form.body.trim()) {
|
||||
toast.error(t('blocked.fillAllFields'));
|
||||
toast.error(t("blocked.fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -39,39 +40,29 @@ export default function BlockedPage() {
|
||||
try {
|
||||
await sendGeneralReport(form.subject.trim(), form.body.trim());
|
||||
setIsSent(true);
|
||||
setForm({ subject: '', body: '' });
|
||||
toast.success(t('blocked.reportSent'));
|
||||
setForm({ subject: "", body: "" });
|
||||
toast.success(t("blocked.reportSent"));
|
||||
} catch (error) {
|
||||
toast.error(t('blocked.sendError'));
|
||||
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">{t('blocked.title')}</h1>
|
||||
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
|
||||
{t('blocked.description')}
|
||||
</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 }}
|
||||
@ -82,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">{t('blocked.logoutTitle')}</h2>
|
||||
<p className="text-gray-600 leading-7">
|
||||
{t('blocked.logoutDesc')}
|
||||
</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')}
|
||||
{t("blocked.logoutButton")}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-3xl shadow-sm border border-gray-200 p-8"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, x: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="bg-white rounded-3xl shadow-sm border border-gray-200 p-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-14 h-14 bg-amber-50 rounded-2xl flex items-center justify-center">
|
||||
<MessageSquare className="w-7 h-7 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{t('blocked.contactSupportTitle')}</h2>
|
||||
<p className="text-gray-600 mt-1">{t('blocked.contactSupportDesc')}</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{t("blocked.contactSupportTitle")}</h2>
|
||||
<p className="text-gray-600 mt-1">{t("blocked.contactSupportDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">{t('subject')}</label>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">{t("subject")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.subject}
|
||||
onChange={(event) => updateField('subject', event.target.value)}
|
||||
placeholder={t('blocked.subjectPlaceholder')}
|
||||
onChange={(event) => updateField("subject", event.target.value)}
|
||||
placeholder={t("blocked.subjectPlaceholder")}
|
||||
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">{t('blocked.messageLabel')}</label>
|
||||
<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={t('blocked.messagePlaceholder')}
|
||||
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>
|
||||
@ -145,17 +129,17 @@ export default function BlockedPage() {
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{t('blocked.sending')}
|
||||
{t("blocked.sending")}
|
||||
</>
|
||||
) : isSent ? (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
{t('blocked.sentButton')}
|
||||
{t("blocked.sentButton")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
{t('blocked.sendButton')}
|
||||
{t("blocked.sendButton")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@ -144,13 +144,7 @@ export default function BookedPropertiesPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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">
|
||||
|
||||
@ -1,18 +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 { useTranslation } from 'react-i18next';
|
||||
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 { t } = useTranslation();
|
||||
const [form, setForm] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [form, setForm] = useState({ oldPassword: "", newPassword: "", confirmPassword: "" });
|
||||
const [show, setShow] = useState({ old: false, new: false, confirm: false });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@ -24,162 +25,124 @@ export default function ChangePasswordPage() {
|
||||
e.preventDefault();
|
||||
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
toast.error(t('changePassword.loginRequired'));
|
||||
router.push('/login');
|
||||
toast.error(t("changePassword.loginRequired"));
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
toast.error(t('changePassword.passwordsDoNotMatch'));
|
||||
toast.error(t("changePassword.passwordsDoNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword.length < 6) {
|
||||
toast.error(t('validation.passwordMinLength'));
|
||||
toast.error(t("validation.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await changePassword(form.oldPassword, form.newPassword);
|
||||
toast.success(t('changePassword.changeSuccess'));
|
||||
setTimeout(() => router.push('/profile'), 1200);
|
||||
toast.success(t("changePassword.changeSuccess"));
|
||||
setTimeout(() => router.push("/profile"), 1200);
|
||||
} catch (err) {
|
||||
toast.error(err?.message || t('changePassword.changeFailed'));
|
||||
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>{t('changePassword.backToProfile')}</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"
|
||||
>
|
||||
{t('changePassword.title')}
|
||||
<motion.h1 initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="text-3xl font-bold text-white mb-2">
|
||||
{t("changePassword.title")}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-amber-100"
|
||||
>
|
||||
{t('changePassword.subtitle')}
|
||||
<motion.p 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">
|
||||
{t('changePassword.currentPasswordLabel')}
|
||||
</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={t('changePassword.currentPasswordPlaceholder')}
|
||||
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">
|
||||
{t('changePassword.newPasswordLabel')}
|
||||
</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={t('changePassword.newPasswordPlaceholder')}
|
||||
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">
|
||||
{t('changePassword.confirmNewPasswordLabel')}
|
||||
</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={t('changePassword.confirmPasswordPlaceholder')}
|
||||
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>
|
||||
@ -195,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>{t('changePassword.saving')}</span>
|
||||
<span>{t("changePassword.saving")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t('changePassword.changePassword')
|
||||
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}`} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Home, Building, Calendar, CreditCard, Briefcase, BookOpen } from "lucide-react";
|
||||
import { Home, Building2, Building, CalendarCheck, CalendarDays, ClipboardList, CreditCard, BookOpenCheck } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function BottomNav({ isOwner, isOwnerOrAgent }) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
@ -18,11 +17,20 @@ export default function BottomNav({ isOwner, isOwnerOrAgent }) {
|
||||
|
||||
const items = [
|
||||
{ href: "/", label: t("home"), icon: Home },
|
||||
{ href: "/properties", label: t("ourProperties"), icon: Building },
|
||||
...(isOwnerOrAgent ? [{ href: "/owner/properties", label: t("myProperties"), icon: Briefcase }] : []),
|
||||
{ href: bookingsHref, label: t("reservations"), icon: Calendar },
|
||||
{ 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: BookOpen }] : []),
|
||||
|
||||
...(isOwnerOrAgent ? [{ href: "/owner/account-book", label: t("accountBook"), icon: BookOpenCheck }] : []),
|
||||
];
|
||||
|
||||
const isActive = (href) => {
|
||||
@ -31,29 +39,67 @@ export default function BottomNav({ isOwner, isOwnerOrAgent }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 flex justify-center pb-4 pt-2 pointer-events-none">
|
||||
<nav className="bg-gray-50 rounded-3xl px-4 py-2 shadow-[0_4px_24px_rgba(251,146,60,0.25)] pointer-events-auto">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<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 flex flex-col items-center justify-center px-3 py-2 rounded-xl transition-all duration-200 ease-out hover:shadow-md hover:bg-white/80 ${active ? "bg-white shadow-sm text-amber-600" : "text-gray-700 hover:text-amber-600"}`}
|
||||
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="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>
|
||||
);
|
||||
})}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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") {
|
||||
|
||||
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,321 +1,117 @@
|
||||
'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: 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') }
|
||||
];
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch({
|
||||
...filters,
|
||||
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",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{['rent', 'buy', 'sell'].map((tab) => (
|
||||
<motion.button
|
||||
key={tab}
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
{t(`${tab}Tab`)}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{t("cityStreetLabel")}
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={filters.city}
|
||||
onChange={(e) => setFilters({...filters, city: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{cities.map(city => (
|
||||
<option key={city.id} value={city.id}>{city.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{activeTab === 'rent' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Home className="w-4 h-4" />
|
||||
{t("rentTypeLabel")}
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
value={filters.propertyType}
|
||||
onChange={(e) => setFilters({...filters, propertyType: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{propertyTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{t("priceLabel")}
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
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">
|
||||
{t("identityTypeLabel")}
|
||||
</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>
|
||||
</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">{t("ownerSourceLabel")}</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>
|
||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{["rent", "buy", "sell"].map((tab) => {
|
||||
const isActive = activeTab === tab;
|
||||
|
||||
{activeTab === 'rent' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">{t("rentTypeLabel")}</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={`${activeTab === 'rent' ? 'md:col-span-2' : 'md:col-span-3'} flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5`}>
|
||||
<label className="mt-4 flex items-center gap-3 text-white text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.availableToday}
|
||||
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||
/>
|
||||
<span className="font-medium">{t("availableToday")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
onClick={handleSearch}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-4 px-6 rounded-xl transition-all duration-300 flex items-center justify-center text-base gap-3 shadow-lg hover:shadow-xl"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
{t("searchButton")}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{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">{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">
|
||||
return (
|
||||
<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"
|
||||
key={tab}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={`relative px-5 py-2.5 rounded-lg font-medium text-sm transition-colors duration-200 outline-none select-none ${isActive ? "text-white" : "text-amber-50 hover:text-white"}`}
|
||||
>
|
||||
{t("close")}
|
||||
{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>
|
||||
<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>
|
||||
{/* city */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<FilterSelect icon={<MapPin className="w-4 h-4" />} value={filters.city} onChange={(e) => setFilters({ ...filters, city: e.target.value })} option={cities} label={t(t("cityStreetLabel"))} />
|
||||
{activeTab === "rent" && (
|
||||
<FilterSelect
|
||||
icon={<Home className="w-4 h-4" />}
|
||||
value={filters.propertyType}
|
||||
onChange={(e) => setFilters({ ...filters, city: e.target.value })}
|
||||
option={propertyTypes}
|
||||
label={t("rentTypeLabel")}
|
||||
/>
|
||||
)}
|
||||
{/* price */}
|
||||
<FilterSelect
|
||||
icon={<DollarSign className="w-4 h-4" />}
|
||||
value={filters.priceRange}
|
||||
onChange={(e) => setFilters({ ...filters, priceRange: e.target.value })}
|
||||
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">
|
||||
{/* owener */}
|
||||
<FilterSelect value={filters.ownerSource} onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })} option={ownerSources} label={t("ownerSourceLabel")} />
|
||||
{activeTab === "rent" && <FilterSelect value={filters.rentPeriod} onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })} option={rentPeriods} label={t("rentTypeLabel")} />}
|
||||
<div className={`${activeTab === "rent" ? "md:col-span-2" : "md:col-span-3"} flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5`}>
|
||||
{/* checkbox */}
|
||||
<label className="mt-4 flex items-center gap-3 text-amber-50 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.availableToday}
|
||||
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||
/>
|
||||
<span className="font-medium">{t("availableToday")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
onClick={handleSearch}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-4 px-6 rounded-xl transition-all duration-300 flex items-center justify-center text-base gap-3 shadow-lg hover:shadow-xl"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
{t("searchButton")}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { createContext, useContext, useState, useEffect } from "react";
|
||||
const ThemeContext = createContext();
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState("light");
|
||||
const [theme, setTheme] = useState("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -51,4 +65,6 @@ const CityTranslationKeys = Object.freeze({
|
||||
[City.RURAL_DAMASCUS]: 'city.ruralDamascus',
|
||||
});
|
||||
|
||||
export { City, CitiesList, extractCity, CityTranslationKeys };
|
||||
|
||||
|
||||
export { City, CitiesList, extractCity, CityTranslationKeys, };
|
||||
|
||||
@ -32,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 }}
|
||||
|
||||
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,14 +1,15 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Heart, MapPin, Bed, Bath, Square, X, ImageIcon } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { 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();
|
||||
@ -25,38 +26,28 @@ export default function FavoritesPage() {
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ' + t('currency-syp-suffix');
|
||||
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">{t('loading')}</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">{t('favorites')}</h1>
|
||||
<p className="text-gray-600">{t('saved-properties')}</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">{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')}
|
||||
<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>
|
||||
) : (
|
||||
@ -70,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" />
|
||||
@ -95,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' ? t('buildingType.apartment') : property.type === 'villa' ? t('buildingType.villa') : t('buildingType.house')}
|
||||
{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>
|
||||
@ -108,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' ? t('day') : t('month')}</div>
|
||||
<div className="text-xs text-gray-500">/{property.priceUnit === "daily" ? t("day") : t("month")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -129,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"
|
||||
>
|
||||
{t('viewDetails')}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ 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();
|
||||
@ -66,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>
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
4210
app/i18n/config.js
4210
app/i18n/config.js
File diff suppressed because it is too large
Load Diff
@ -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">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<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,730 +1,8 @@
|
||||
"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 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();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 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"
|
||||
? t("emailRequired")
|
||||
: t("phoneRequired");
|
||||
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||
newErrors.credential = t("phoneInvalid");
|
||||
}
|
||||
|
||||
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 loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||
|
||||
const result = await loginFn(formData.credential, formData.password);
|
||||
|
||||
if (result.status === 200) {
|
||||
const token =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
AuthService.addToken(token);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Login] Failed to fetch profile:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userRole = AuthService.isOwner() ? "owner" : "customer";
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success(t("loginSuccess"), {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else if (result.status === 206) {
|
||||
const tempToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (tempToken) {
|
||||
AuthService.addToken(tempToken);
|
||||
}
|
||||
toast(t("otpRequired"), {
|
||||
icon: "🔐",
|
||||
style: { background: "#fef3c7", color: "#92400e" },
|
||||
});
|
||||
|
||||
// Send OTP
|
||||
try {
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
} catch (otpErr) {
|
||||
console.warn("[Login] OTP send failed, proceeding anyway:", otpErr);
|
||||
}
|
||||
|
||||
setStep("otp");
|
||||
} else if (result.status >= 500) {
|
||||
console.error("[Login] Server error:", result.status, result.data);
|
||||
toast.error(t("serverError"), {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} else if (result.status === 404) {
|
||||
console.error("[Login] API endpoint not found:", result.status);
|
||||
toast.error(t("apiNotFound"), {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} else if (result.status === 429) {
|
||||
toast.error(t("tooManyRequests"), {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} else {
|
||||
console.error("[Login] Unexpected status:", result.status, result.data);
|
||||
toast.error(
|
||||
result.data?.message || result.data || t("invalidCredentials"),
|
||||
{
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Error:", err);
|
||||
toast.error(err.message || t("connectionError"), {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
const finalToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (finalToken && typeof finalToken === "string") {
|
||||
AuthService.addToken(finalToken);
|
||||
}
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success(t("verifySuccess"), {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else {
|
||||
console.error("[OTP] Verification failed:", result.data);
|
||||
setOtpError(result.data?.message || t("wrongOTP"));
|
||||
}
|
||||
} 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"));
|
||||
}
|
||||
};
|
||||
|
||||
// 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>{t("backToHome")}</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" ? t("enterOTP") : t("welcomeBack")}
|
||||
</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" />
|
||||
{t("emailMethod")}
|
||||
</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" />
|
||||
{t("phoneMethod")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Credential input */}
|
||||
<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>
|
||||
<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"
|
||||
? t("emailPlaceholder")
|
||||
: 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-white 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>
|
||||
</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">
|
||||
{t("sendOTPTo")}{" "}
|
||||
<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">
|
||||
{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-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" />
|
||||
{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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
return <Login />;
|
||||
}
|
||||
@ -7,6 +7,7 @@ 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: 'ratings.cleanliness' },
|
||||
@ -99,13 +100,7 @@ export default function MyRatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
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 { 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();
|
||||
@ -18,7 +19,7 @@ export default function NotificationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
router.push('/login');
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
fetchNotifications();
|
||||
@ -33,36 +34,27 @@ export default function NotificationsPage() {
|
||||
setNotifications(items);
|
||||
setUnreadCount(items.length);
|
||||
} catch (err) {
|
||||
console.error('Error fetching notifications:', err);
|
||||
setError(err.message || t('fetch-notifications-failed'));
|
||||
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">{t('loading')}</p>
|
||||
</motion.div>
|
||||
<div>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -70,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">{t('loading-error')}</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 }}
|
||||
@ -84,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')}
|
||||
{t("retry")}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -94,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">{t('notifications')}</h1>
|
||||
<p className="text-gray-600">
|
||||
{unreadCount > 0 ? t('unread-notifications', { count: unreadCount }) : t('all-notifications-read')}
|
||||
</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
|
||||
@ -113,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')}
|
||||
{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">{t('no-notifications')}</h3>
|
||||
<p className="text-gray-500">{t('notifications-empty-hint')}</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">
|
||||
@ -137,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">
|
||||
@ -178,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>
|
||||
|
||||
@ -76,7 +76,7 @@ export default function OnboardingPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div dir={i18n.language === 'ar' ? 'rtl' : 'ltr'} 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
|
||||
@ -144,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>
|
||||
@ -154,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>
|
||||
|
||||
@ -1,32 +1,23 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
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 { 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, 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"
|
||||
>
|
||||
<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">{isNA ? 'N/A' : 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>
|
||||
);
|
||||
@ -65,156 +56,84 @@ export default function OwnerAccountBookPage() {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const data = await getOwnerStatistics();
|
||||
async function fetchData() {
|
||||
try {
|
||||
const data = await getOwnerStatistics();
|
||||
|
||||
if (data) {
|
||||
setStats({
|
||||
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) {
|
||||
setStats({
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("accountBook.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('accountBook.loadError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
const num = Number(amount);
|
||||
if (isNaN(num)) return 'N/A';
|
||||
return num.toLocaleString() + ' ' + t('currency-syp-suffix');
|
||||
if (isNaN(num)) return "N/A";
|
||||
return num.toLocaleString() + " " + t("currency-syp-suffix");
|
||||
};
|
||||
|
||||
const formatNumber = (val) => {
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return 'N/A';
|
||||
if (isNaN(num)) return "N/A";
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const formatRating = (val) => {
|
||||
const num = Number(val);
|
||||
if (isNaN(num)) return 'N/A';
|
||||
if (isNaN(num)) return "N/A";
|
||||
return num.toFixed(1);
|
||||
};
|
||||
|
||||
const isNA = (val) => val === null || val === undefined || val === '';
|
||||
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">{t('accountBook.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir={i18n.language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
<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="mb-8"
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('accountBook')}</h1>
|
||||
<p className="text-gray-600">{t('financialRecords')}</p>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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)}
|
||||
/>
|
||||
<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.1 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('accountBook.directPropertyStats')}</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)}
|
||||
/>
|
||||
<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>
|
||||
</motion.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>
|
||||
);
|
||||
}
|
||||
@ -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,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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -664,7 +664,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { getRentProperty } from '../../utils/api';
|
||||
import { getRentProperties } from '../../utils/api';
|
||||
import Loading from '@/app/loading';
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
@ -756,8 +757,14 @@ async function enrich(r) {
|
||||
}
|
||||
}
|
||||
|
||||
const pAddr = (p) => p?.address ?? '';
|
||||
const pImgs = (p) => (Array.isArray(p?.images) ? p.images : []);
|
||||
const pAddr = (p, r) => p?.address ?? r?.propertyAddress ?? '';
|
||||
const pImgs = (p, r) => {
|
||||
if (p?.images && Array.isArray(p.images)) return p.images;
|
||||
if (r?.property?.images && Array.isArray(r.property.images)) return r.property.images;
|
||||
if (r?.propertyInfo?.images && Array.isArray(r.propertyInfo.images)) return r.propertyInfo.images;
|
||||
if (r?.propertyImage) return [r.propertyImage];
|
||||
return [];
|
||||
};
|
||||
const pBeds = (p) => p?.numberOfBedRooms ?? 0;
|
||||
const pBaths = (p) => p?.numberOfBathRooms ?? 0;
|
||||
|
||||
@ -893,9 +900,9 @@ function OwnerCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const p = r._prop;
|
||||
const imgs = pImgs(p);
|
||||
const imgs = pImgs(p, r);
|
||||
const img = imgs.length > 0 ? buildImageUrl(imgs[0]) : null;
|
||||
const addr = pAddr(p);
|
||||
const addr = pAddr(p, r);
|
||||
const isPending = Number(r.status) === 0;
|
||||
const isActionLoading = actionLoadingId === r.id;
|
||||
const isReporting = reportingId === r.id;
|
||||
@ -1035,6 +1042,7 @@ function DetailsModal({ r, isOpen, onClose, onReport, reportingId }) {
|
||||
|
||||
const p = r._prop;
|
||||
const isReporting = reportingId === r.id;
|
||||
const imgs = pImgs(p, r);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@ -1066,6 +1074,12 @@ function DetailsModal({ r, isOpen, onClose, onReport, reportingId }) {
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{imgs.length > 0 && (
|
||||
<div className="w-full h-56 rounded-xl overflow-hidden">
|
||||
<img src={buildImageUrl(imgs[0])} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p && (
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
||||
@ -1075,7 +1089,7 @@ function DetailsModal({ r, isOpen, onClose, onReport, reportingId }) {
|
||||
|
||||
<p>
|
||||
<span className="text-gray-500">{t('address')}</span>{' '}
|
||||
{pAddr(p) || '—'}
|
||||
{pAddr(p, r) || '—'}
|
||||
</p>
|
||||
|
||||
{(pBeds(p) || pBaths(p)) && (
|
||||
@ -1249,28 +1263,44 @@ export default function OwnerReservationRequestsPage() {
|
||||
|
||||
const token = AuthService.getToken();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE}/Reservations/GetOwnerResevationRequests`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
const [resResult, rentProps] = await Promise.all([
|
||||
fetch(
|
||||
`${API_BASE}/Reservations/GetOwnerResevationRequests`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(errorText || `HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
return list;
|
||||
}),
|
||||
getRentProperties().catch(() => []),
|
||||
]);
|
||||
|
||||
const propsList = Array.isArray(rentProps) ? rentProps : [];
|
||||
const propMap = {};
|
||||
propsList.forEach(rp => {
|
||||
const info = rp?.propertyInformation ?? {};
|
||||
if (rp?.allowedPaymentPeriod) info.allowedPaymentPeriod = rp.allowedPaymentPeriod;
|
||||
propMap[rp.propertyInformationId] = info;
|
||||
if (rp?.propertyInformation?.id) propMap[rp.propertyInformation.id] = info;
|
||||
});
|
||||
|
||||
const enriched = resResult.map(r => {
|
||||
if (r.propertyId && propMap[r.propertyId]) {
|
||||
r._prop = propMap[r.propertyId];
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(errorText || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
|
||||
const enriched = await Promise.all(list.map(enrich));
|
||||
return r;
|
||||
});
|
||||
|
||||
setReservations(enriched);
|
||||
setFiltered(enriched);
|
||||
@ -1305,7 +1335,7 @@ export default function OwnerReservationRequestsPage() {
|
||||
|
||||
result = result.filter(
|
||||
(x) =>
|
||||
pAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q)
|
||||
pAddr(x._prop, x).toLowerCase().includes(q) || String(x.id).includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1404,9 +1434,7 @@ export default function OwnerReservationRequestsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin" />
|
||||
</div>
|
||||
<Loading/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
603
app/page.js
603
app/page.js
@ -1,600 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
Zap,
|
||||
Star,
|
||||
Rocket,
|
||||
Search,
|
||||
MapPin,
|
||||
Home,
|
||||
DollarSign,
|
||||
ChevronDown,
|
||||
Shield,
|
||||
Award,
|
||||
Sparkles,
|
||||
UserCircle,
|
||||
LogOut,
|
||||
Calendar,
|
||||
Building,
|
||||
PlusCircle,
|
||||
Heart,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
import HeroSearch from './components/home/HeroSearch';
|
||||
import PropertyMapWithMarkers from './components/PropertyMapWithMarkers';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getRentProperties, getSaleProperties } from './utils/api';
|
||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from './enums';
|
||||
import AuthService from './services/AuthService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
import HomeClient from "./components/home/HomeClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "الصفحة الرئيسية | عقاراتك",
|
||||
description: "ابحث عن العقارات والبيوت للإيجار والبيع بأسهل الطرق.",
|
||||
};
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const mapSectionRef = useRef(null);
|
||||
const [searchFilters, setSearchFilters] = useState(null);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const [filteredProperties, setFilteredProperties] = useState([]);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const [allProperties, setAllProperties] = useState([]);
|
||||
const [rentProperties, setRentProperties] = useState([]);
|
||||
const [saleProperties, setSaleProperties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: AuthService.isOwner() ? 'owner' : 'customer',
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProperties();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters) {
|
||||
applyFilters(searchFilters);
|
||||
}
|
||||
}, [rentProperties, saleProperties, searchFilters]);
|
||||
|
||||
const logout = () => {
|
||||
AuthService.deleteToken();
|
||||
setUser(null);
|
||||
setShowUserMenu(false);
|
||||
};
|
||||
|
||||
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 > 50) return false; break;
|
||||
case '500-1000': if (priceUSD < 51 || priceUSD > 100) return false; break;
|
||||
case '1000-2000': if (priceUSD < 101 || priceUSD > 200) return false; break;
|
||||
case '2000-3000': if (priceUSD < 201 || priceUSD > 300) return false; break;
|
||||
case '3000+': if (priceUSD < 301) 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);
|
||||
|
||||
if (!showMap) {
|
||||
setShowMap(true);
|
||||
|
||||
setTimeout(() => {
|
||||
if (mapSectionRef.current) {
|
||||
setIsScrolling(true);
|
||||
mapSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
|
||||
setTimeout(() => setIsScrolling(false), 1000);
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
if (mapSectionRef.current) {
|
||||
setIsScrolling(true);
|
||||
mapSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
setTimeout(() => setIsScrolling(false), 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
setShowMap(false);
|
||||
setSearchFilters(null);
|
||||
setFilteredProperties([]);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
const getUserInitial = () => {
|
||||
if (user?.name) {
|
||||
return user.name.charAt(0).toUpperCase();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isOwner = user?.role === 'owner';
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
|
||||
|
||||
{isOwner && (
|
||||
<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")} {user?.name}!
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showMap && !isOwner && (
|
||||
<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-white" 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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!isOwner && (<>
|
||||
<AnimatePresence mode="wait">
|
||||
{showMap && (
|
||||
<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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<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 mb-12">
|
||||
<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: true }}
|
||||
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">
|
||||
<ShieldCheck className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t('feature1Title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t('feature1Description')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<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: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
whileHover={{ y: -4 }}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center group-hover:bg-blue-200 transition-colors duration-300">
|
||||
<Lock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t('feature2Title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t('feature2Description')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<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: true }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
whileHover={{ y: -4 }}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center group-hover:bg-green-200 transition-colors duration-300">
|
||||
<Zap className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{t('feature3Title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t('feature3Description')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{filteredProperties.length > 0 && searchFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, type: "spring" }}
|
||||
className="flex flex-wrap items-center justify-center gap-3"
|
||||
>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">{t('filterPropertyTypeLabel')}</span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.propertyType === 'all' ? t('all') :
|
||||
searchFilters.propertyType === 'apartment' ? t('buildingType.apartment') :
|
||||
searchFilters.propertyType === 'villa' ? t('buildingType.villa') : t('house')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">{t('filterPriceRangeLabel')}</span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.priceRange === 'all' ? t('allPrices') :
|
||||
searchFilters.priceRange === '0-500' ? t('priceRangeUnder500') :
|
||||
searchFilters.priceRange === '500-1000' ? t('priceRange50to100') :
|
||||
searchFilters.priceRange === '1000-2000' ? t('priceRange100to200') :
|
||||
searchFilters.priceRange === '2000-3000' ? t('priceRange200to300') : t('priceRangeOver300')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">{t('filterSourceLabel')}</span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.ownerSource === 'all' ? t('all') :
|
||||
searchFilters.ownerSource === 'owner' ? t('fromOwner') : t('fromAgency')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">{t('filterRentTypeLabel')}</span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.rentPeriod === 'all' ? t('all') :
|
||||
searchFilters.rentPeriod === 'daily' ? t('dailyRent') : t('monthlyRent')}
|
||||
</span>
|
||||
</div>
|
||||
{searchFilters.availableToday && (
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="font-bold text-gray-900">{t('availableFromToday')}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <HomeClient />;
|
||||
}
|
||||
@ -1,93 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Home,
|
||||
MapPin,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
Building,
|
||||
ArrowLeftRight,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
Calendar,
|
||||
Clock,
|
||||
LogIn,
|
||||
Lock,
|
||||
Info,
|
||||
Landmark,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { payDeposit, getMyTransaction, getPaymentTypes } from '@/app/utils/api';
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Home, MapPin, Phone, ShieldCheck, CreditCard, Banknote, Building, ArrowLeftRight, Loader2, Check, X, Calendar, Clock, LogIn, Lock, Info, Landmark, Wallet } from "lucide-react";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import AuthService from "@/app/services/AuthService";
|
||||
import { payDeposit, getMyTransaction, getPaymentTypes } from "@/app/utils/api";
|
||||
import Loading from "../loading";
|
||||
|
||||
const STATUS_MAP = ['pending', 'ownerConfirmed', 'depositPaid', 'depositConfirmed', 'completed', 'cancelled'];
|
||||
const STATUS_MAP = ["pending", "ownerConfirmed", "depositPaid", "depositConfirmed", "completed", "cancelled"];
|
||||
|
||||
function getStatusConfig(t) {
|
||||
return {
|
||||
pending: { label: t('bookingStatus.pending'), color: 'bg-yellow-100 text-yellow-800 border-yellow-300', icon: Clock },
|
||||
ownerConfirmed: { label: t('bookingStatus.ownerConfirmed'), color: 'bg-blue-100 text-blue-800 border-blue-300', icon: ShieldCheck },
|
||||
depositPaid: { label: t('bookingStatus.depositPaid'), color: 'bg-orange-100 text-orange-800 border-orange-300', icon: Wallet },
|
||||
depositConfirmed: { label: t('bookingStatus.depositConfirmed'), color: 'bg-green-100 text-green-800 border-green-300', icon: Check },
|
||||
completed: { label: t('bookingStatus.completed'), color: 'bg-teal-100 text-teal-800 border-teal-300', icon: Check },
|
||||
cancelled: { label: t('bookingStatus.cancelled'), color: 'bg-red-100 text-red-800 border-red-300', icon: X },
|
||||
pending: { label: t("bookingStatus.pending"), color: "bg-yellow-100 text-yellow-800 border-yellow-300", icon: Clock },
|
||||
ownerConfirmed: { label: t("bookingStatus.ownerConfirmed"), color: "bg-blue-100 text-blue-800 border-blue-300", icon: ShieldCheck },
|
||||
depositPaid: { label: t("bookingStatus.depositPaid"), color: "bg-orange-100 text-orange-800 border-orange-300", icon: Wallet },
|
||||
depositConfirmed: { label: t("bookingStatus.depositConfirmed"), color: "bg-green-100 text-green-800 border-green-300", icon: Check },
|
||||
completed: { label: t("bookingStatus.completed"), color: "bg-teal-100 text-teal-800 border-teal-300", icon: Check },
|
||||
cancelled: { label: t("bookingStatus.cancelled"), color: "bg-red-100 text-red-800 border-red-300", icon: X },
|
||||
};
|
||||
}
|
||||
|
||||
function getPaymentMethods(t) {
|
||||
return [
|
||||
{ id: 'cash', label: t('payments.method.cash'), desc: t('payments.method.cashDesc'), icon: Banknote, active: true },
|
||||
{ id: 'office', label: t('payments.method.office'), desc: t('payments.method.officeDesc'), icon: Building, active: true },
|
||||
{ id: 'transfer', label: t('payments.method.transfer'), desc: t('payments.method.transferDesc'), icon: ArrowLeftRight, active: false },
|
||||
{ id: 'electronic', label: t('payments.method.electronic'), desc: t('payments.method.electronicDesc'), icon: CreditCard, active: false },
|
||||
{ id: "cash", label: t("payments.method.cash"), desc: t("payments.method.cashDesc"), icon: Banknote, active: true },
|
||||
{ id: "office", label: t("payments.method.office"), desc: t("payments.method.officeDesc"), icon: Building, active: true },
|
||||
{ id: "transfer", label: t("payments.method.transfer"), desc: t("payments.method.transferDesc"), icon: ArrowLeftRight, active: false },
|
||||
{ id: "electronic", label: t("payments.method.electronic"), desc: t("payments.method.electronicDesc"), icon: CreditCard, active: false },
|
||||
];
|
||||
}
|
||||
|
||||
function formatCurrency(v, sign = '') {
|
||||
function formatCurrency(v, sign = "") {
|
||||
return `${sign} ${Number(v ?? 0).toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString('en-GB');
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString("en-GB");
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim().toLowerCase();
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isHaramText(value) {
|
||||
const text = normalizeText(value);
|
||||
return text.includes('haram') || text.includes('هرم') || text.includes('هرام');
|
||||
return text.includes("haram") || text.includes("هرم") || text.includes("هرام");
|
||||
}
|
||||
|
||||
function isReceiptRequiredPayment(value, method = null) {
|
||||
const haystack = [
|
||||
value,
|
||||
method?.name,
|
||||
method?.label,
|
||||
method?.id,
|
||||
method?.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const haystack = [value, method?.name, method?.label, method?.id, method?.description].filter(Boolean).join(" ");
|
||||
|
||||
const text = normalizeText(haystack);
|
||||
return (
|
||||
text.includes('transfer') ||
|
||||
text.includes('تحويل') ||
|
||||
text.includes('حوالة') ||
|
||||
text.includes('bank') ||
|
||||
text.includes('بنك') ||
|
||||
text.includes("transfer") ||
|
||||
text.includes("تحويل") ||
|
||||
text.includes("حوالة") ||
|
||||
text.includes("bank") ||
|
||||
text.includes("بنك") ||
|
||||
isHaramText(value) ||
|
||||
isHaramText(method?.name) ||
|
||||
isHaramText(method?.label) ||
|
||||
@ -126,12 +102,12 @@ export default function PaymentsPage() {
|
||||
endDate: reservation.endDate,
|
||||
totalPrice: reservation.totalPrice ?? transaction.amount ?? 0,
|
||||
depositAmount: transaction.amount ?? reservation.totalPrice ?? 0,
|
||||
currencySign: currency.sign || t('currency.syp'),
|
||||
currencyName: currency.name || '',
|
||||
currencySign: currency.sign || t("currency.syp"),
|
||||
currencyName: currency.name || "",
|
||||
currencyRate: currency.rate,
|
||||
propertyName: propertyInfo.name || propertyInfo.address || reservation.propertyName || `${t('payments.propertyLabel')} #${reservation.id || ''}`,
|
||||
propertyAddress: propertyInfo.address || reservation.propertyAddress || '',
|
||||
propertyCity: propertyInfo.city || reservation.city || '',
|
||||
propertyName: propertyInfo.name || propertyInfo.address || reservation.propertyName || `${t("payments.propertyLabel")} #${reservation.id || ""}`,
|
||||
propertyAddress: propertyInfo.address || reservation.propertyAddress || "",
|
||||
propertyCity: propertyInfo.city || reservation.city || "",
|
||||
_deposit: deposit,
|
||||
_reservation: reservation,
|
||||
};
|
||||
@ -140,7 +116,7 @@ export default function PaymentsPage() {
|
||||
setReservations(mapped);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(t('payments.loadingTransactions'));
|
||||
toast.error(t("payments.loadingTransactions"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -158,7 +134,7 @@ export default function PaymentsPage() {
|
||||
setSelectedPayment(firstActive.name ?? firstActive.id ?? null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Payments] failed to load payment methods', err);
|
||||
console.error("[Payments] failed to load payment methods", err);
|
||||
setPaymentMethods([]);
|
||||
} finally {
|
||||
setLoadingPaymentMethods(false);
|
||||
@ -180,21 +156,17 @@ export default function PaymentsPage() {
|
||||
}, [loadPaymentMethods]);
|
||||
|
||||
const resolvePaymentTypeId = (paymentKey) => {
|
||||
const method = paymentMethods.find(
|
||||
(m) => String(m.id) === String(paymentKey) || String(m.name) === String(paymentKey),
|
||||
);
|
||||
const method = paymentMethods.find((m) => String(m.id) === String(paymentKey) || String(m.name) === String(paymentKey));
|
||||
return method?.id ?? paymentKey;
|
||||
};
|
||||
|
||||
const handlePayDeposit = async (reservation, paymentKey, paymentImageFile) => {
|
||||
const paymentTypeId = resolvePaymentTypeId(paymentKey);
|
||||
const selectedMethod = paymentMethods.find(
|
||||
(m) => String(m.id) === String(paymentTypeId) || String(m.name) === String(paymentTypeId),
|
||||
);
|
||||
const selectedMethod = paymentMethods.find((m) => String(m.id) === String(paymentTypeId) || String(m.name) === String(paymentTypeId));
|
||||
const requiresReceiptUpload = isReceiptRequiredPayment(paymentKey, selectedMethod);
|
||||
|
||||
if (requiresReceiptUpload && !paymentImageFile) {
|
||||
toast.error(t('payments.receiptRequired'));
|
||||
toast.error(t("payments.receiptRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -205,46 +177,33 @@ export default function PaymentsPage() {
|
||||
paymentTypeId,
|
||||
paymentImage: paymentImageFile,
|
||||
});
|
||||
toast.success(t('payments.depositPaidSuccess'));
|
||||
toast.success(t("payments.depositPaidSuccess"));
|
||||
loadReservations();
|
||||
} catch (err) {
|
||||
toast.error(err?.message || t('payments.paymentFailed'));
|
||||
toast.error(err?.message || t("payments.paymentFailed"));
|
||||
} finally {
|
||||
setPayingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canPay = (status) => STATUS_MAP[status] === 'ownerConfirmed';
|
||||
const canPay = (status) => STATUS_MAP[status] === "ownerConfirmed";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center" dir={i18n.dir()}>
|
||||
<Loader2 className="w-12 h-12 text-amber-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isGuest) {
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-b from-amber-50/50 to-white flex items-center justify-center p-4" dir={i18n.dir()}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-white rounded-3xl shadow-xl border border-gray-200 p-10 max-w-md w-full text-center"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="bg-white rounded-3xl shadow-xl border border-gray-200 p-10 max-w-md w-full text-center">
|
||||
<div className="w-20 h-20 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Lock className="w-10 h-10 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">{t('payments.title')}</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-8">
|
||||
{t('payments.loginRequiredDesc')}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-3 rounded-2xl text-lg font-semibold transition shadow-lg shadow-amber-200"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">{t("payments.title")}</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-8">{t("payments.loginRequiredDesc")}</p>
|
||||
<Link href="/login" className="inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-3 rounded-2xl text-lg font-semibold transition shadow-lg shadow-amber-200">
|
||||
<LogIn className="w-5 h-5" />
|
||||
{t('login')}
|
||||
{t("login")}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -258,20 +217,16 @@ export default function PaymentsPage() {
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir={i18n.dir()}>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<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">{t('payments.title')}</h1>
|
||||
<p className="text-gray-600">{t('payments.description')}</p>
|
||||
<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">{t("payments.title")}</h1>
|
||||
<p className="text-gray-600">{t("payments.description")}</p>
|
||||
</motion.div>
|
||||
|
||||
{payables.length > 0 && (
|
||||
<div className="space-y-6 mb-10">
|
||||
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<Wallet className="w-5 h-5 text-amber-500" />
|
||||
{t('payments.payablesTitle')}
|
||||
{t("payments.payablesTitle")}
|
||||
</h2>
|
||||
{payables.map((r, i) => (
|
||||
<PaymentCard
|
||||
@ -292,21 +247,16 @@ export default function PaymentsPage() {
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-gray-500" />
|
||||
{t('payments.previousReservationsTitle')}
|
||||
{t("payments.previousReservationsTitle")}
|
||||
</h2>
|
||||
{others.map((r, i) => {
|
||||
const statusKey = STATUS_MAP[r.status] || 'pending';
|
||||
const statusKey = STATUS_MAP[r.status] || "pending";
|
||||
const cfg = getStatusConfig(t)[statusKey];
|
||||
const Icon = cfg.icon;
|
||||
const amount = r.depositAmount || r.totalPrice || 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={r.id || i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5"
|
||||
>
|
||||
<motion.div key={r.id || i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-sm font-medium text-gray-400">#{r.reservationId || r.id}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium border ${cfg.color}`}>
|
||||
@ -319,9 +269,7 @@ export default function PaymentsPage() {
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDate(r.startDate)} - {formatDate(r.endDate)}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatCurrency(amount, r.currencySign)}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">{formatCurrency(amount, r.currencySign)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@ -330,14 +278,10 @@ export default function PaymentsPage() {
|
||||
)}
|
||||
|
||||
{reservations.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<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">
|
||||
<CreditCard className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">{t('payments.noTransactions')}</h3>
|
||||
<p className="text-gray-500">{t('payments.noTransactionsDesc')}</p>
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">{t("payments.noTransactions")}</h3>
|
||||
<p className="text-gray-500">{t("payments.noTransactionsDesc")}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@ -353,8 +297,8 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
const [showCashDialog, setShowCashDialog] = useState(false);
|
||||
const [showReceiptModal, setShowReceiptModal] = useState(false);
|
||||
const [localPaymentImage, setLocalPaymentImage] = useState(null);
|
||||
const [receiptPreviewUrl, setReceiptPreviewUrl] = useState('');
|
||||
const [receiptFileName, setReceiptFileName] = useState('');
|
||||
const [receiptPreviewUrl, setReceiptPreviewUrl] = useState("");
|
||||
const [receiptFileName, setReceiptFileName] = useState("");
|
||||
|
||||
const methods = paymentMethods.length > 0 ? paymentMethods : getPaymentMethods(t);
|
||||
|
||||
@ -371,7 +315,7 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
|
||||
useEffect(() => {
|
||||
if (!localPaymentImage) {
|
||||
setReceiptPreviewUrl('');
|
||||
setReceiptPreviewUrl("");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -391,19 +335,19 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
|
||||
setShowReceiptModal(false);
|
||||
setLocalPaymentImage(null);
|
||||
setReceiptPreviewUrl('');
|
||||
setReceiptFileName('');
|
||||
setReceiptPreviewUrl("");
|
||||
setReceiptFileName("");
|
||||
};
|
||||
|
||||
const handleReceiptSelection = (event) => {
|
||||
const file = event.target.files?.[0] || null;
|
||||
setLocalPaymentImage(file);
|
||||
setReceiptFileName(file?.name || '');
|
||||
setReceiptFileName(file?.name || "");
|
||||
};
|
||||
|
||||
const handleReceiptConfirm = () => {
|
||||
if (!localPaymentImage) {
|
||||
toast.error(t('payments.receiptRequired'));
|
||||
toast.error(t("payments.receiptRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -412,11 +356,7 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-3xl shadow-md border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="bg-white rounded-3xl shadow-md border border-gray-200 overflow-hidden">
|
||||
{/* Property details */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
@ -424,13 +364,11 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
<Home className="w-7 h-7 text-amber-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-1">
|
||||
{r.propertyName}
|
||||
</h3>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-1">{r.propertyName}</h3>
|
||||
{(r.propertyAddress || r.propertyCity) && (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
{[r.propertyCity, r.propertyAddress].filter(Boolean).join(' - ')}
|
||||
{[r.propertyCity, r.propertyAddress].filter(Boolean).join(" - ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -442,8 +380,7 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
{formatDate(r.startDate)} - {formatDate(r.endDate)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
#{r.reservationId || r.id}
|
||||
<Clock className="w-4 h-4 text-gray-400" />#{r.reservationId || r.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -451,13 +388,9 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
{/* Deposit amount */}
|
||||
<div className="px-6 py-5 border-b border-gray-100">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 mb-1">{t('payments.depositAmount')}</p>
|
||||
<p className="text-4xl font-bold text-amber-600">
|
||||
{formatCurrency(amount, r.currencySign)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{t('payments.depositPaidToPlatform')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-1">{t("payments.depositAmount")}</p>
|
||||
<p className="text-4xl font-bold text-amber-600">{formatCurrency(amount, r.currencySign)}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">{t("payments.depositPaidToPlatform")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -465,24 +398,18 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
<div className="px-6 py-4 bg-amber-50 border-b border-amber-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-800 leading-relaxed">
|
||||
{t('payments.cashOnlyNotice')}
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 leading-relaxed">{t("payments.cashOnlyNotice")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method options */}
|
||||
<div className="px-6 py-5 border-b border-gray-100">
|
||||
<p className="text-sm font-bold text-gray-700 mb-3">{t('payments.paymentMethodLabel')}</p>
|
||||
<p className="text-sm font-bold text-gray-700 mb-3">{t("payments.paymentMethodLabel")}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{loadingPaymentMethods ? (
|
||||
<div className="col-span-full rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
||||
{t('payments.loadingPaymentMethods')}
|
||||
</div>
|
||||
<div className="col-span-full rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">{t("payments.loadingPaymentMethods")}</div>
|
||||
) : methods.length === 0 ? (
|
||||
<div className="col-span-full rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
||||
{t('payments.noPaymentMethodsAvailable')}
|
||||
</div>
|
||||
<div className="col-span-full rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">{t("payments.noPaymentMethodsAvailable")}</div>
|
||||
) : (
|
||||
methods.map((method, index) => {
|
||||
const optionId = method.name ?? method.id ?? `payment-method-${index}`;
|
||||
@ -498,34 +425,20 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
onClick={() => isActive && handleSelectMethod(optionId, method)}
|
||||
className={`relative flex items-start gap-3 p-4 rounded-2xl border-2 text-right transition-all ${
|
||||
!isActive
|
||||
? 'border-gray-100 bg-gray-50 opacity-50 cursor-not-allowed'
|
||||
? "border-gray-100 bg-gray-50 opacity-50 cursor-not-allowed"
|
||||
: isSelected
|
||||
? 'border-amber-500 bg-amber-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-amber-300 cursor-pointer'
|
||||
? "border-amber-500 bg-amber-50 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-amber-300 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isSelected ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${isSelected ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-500"}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-bold ${isSelected ? 'text-amber-900' : 'text-gray-800'}`}>
|
||||
{method.name || method.label || t('payments.paymentMethodName')}
|
||||
</p>
|
||||
{method.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 leading-relaxed">
|
||||
{method.description}
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
className={`inline-block mt-1 text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
||||
isActive ? 'bg-amber-100 text-amber-700' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{isActive ? t('payments.methodActive') : t('payments.methodInactive')}
|
||||
<p className={`text-sm font-bold ${isSelected ? "text-amber-900" : "text-gray-800"}`}>{method.name || method.label || t("payments.paymentMethodName")}</p>
|
||||
{method.description && <p className="text-xs text-gray-500 mt-0.5 leading-relaxed">{method.description}</p>}
|
||||
<span className={`inline-block mt-1 text-[10px] font-medium px-2 py-0.5 rounded-full ${isActive ? "bg-amber-100 text-amber-700" : "bg-gray-200 text-gray-500"}`}>
|
||||
{isActive ? t("payments.methodActive") : t("payments.methodInactive")}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
@ -544,7 +457,7 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
<div className="px-6 py-4 bg-amber-50 border-b border-amber-100">
|
||||
<div className="flex items-start gap-2 text-sm text-amber-800">
|
||||
<Info className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p>{t('payments.receiptUploadRequiredHint')}</p>
|
||||
<p>{t("payments.receiptUploadRequiredHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -562,12 +475,12 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
{payingId === r.id ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{t('payments.processingPayment')}
|
||||
{t("payments.processingPayment")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Banknote className="w-5 h-5" />
|
||||
{t('payments.payButton', { amount: formatCurrency(amount, r.currencySign) })}
|
||||
{t("payments.payButton", { amount: formatCurrency(amount, r.currencySign) })}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
@ -575,24 +488,18 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
|
||||
{showReceiptModal && (
|
||||
<div className="fixed inset-0 z-60 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{t('payments.receiptDialogTitle')}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{t('payments.receiptDialogDescription')}</p>
|
||||
<h3 className="text-xl font-bold text-gray-900">{t("payments.receiptDialogTitle")}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{t("payments.receiptDialogDescription")}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-2xl flex items-center justify-center">
|
||||
<CreditCard className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">
|
||||
{t('payments.uploadReceipt')}
|
||||
</label>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">{t("payments.uploadReceipt")}</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@ -602,34 +509,22 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
|
||||
{receiptFileName && (
|
||||
<div className="mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{t('payments.receiptSelected')} {receiptFileName}
|
||||
{t("payments.receiptSelected")} {receiptFileName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localPaymentImage && receiptPreviewUrl && (
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-gray-200 bg-gray-50 p-2">
|
||||
<img
|
||||
src={receiptPreviewUrl}
|
||||
alt={t('payments.uploadReceipt')}
|
||||
className="h-48 w-full rounded-xl object-cover"
|
||||
/>
|
||||
<img src={receiptPreviewUrl} alt={t("payments.uploadReceipt")} className="h-48 w-full rounded-xl object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReceiptModal(false)}
|
||||
className="w-full rounded-xl border border-gray-300 px-4 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{t('payments.closeButton')}
|
||||
<button type="button" onClick={() => setShowReceiptModal(false)} className="w-full rounded-xl border border-gray-300 px-4 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-100">
|
||||
{t("payments.closeButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReceiptConfirm}
|
||||
className="w-full rounded-xl bg-amber-500 px-4 py-3 text-sm font-semibold text-white hover:bg-amber-600"
|
||||
>
|
||||
{t('payments.continueToPay')}
|
||||
<button type="button" onClick={handleReceiptConfirm} className="w-full rounded-xl bg-amber-500 px-4 py-3 text-sm font-semibold text-white hover:bg-amber-600">
|
||||
{t("payments.continueToPay")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -644,30 +539,26 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
className="w-full bg-white border-2 border-amber-200 hover:border-amber-400 text-amber-700 font-bold py-3 px-6 rounded-2xl text-base transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Building className="w-5 h-5" />
|
||||
{t('payments.payCashButton')}
|
||||
{t("payments.payCashButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cash payment dialog */}
|
||||
{showCashDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-md rounded-3xl bg-white p-8 shadow-2xl border border-gray-200 text-center"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-2xl border border-gray-200 text-center">
|
||||
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-5">
|
||||
<Clock className="w-8 h-8 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">{t('payments.cashPendingDialogTitle')}</h3>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">{t("payments.cashPendingDialogTitle")}</h3>
|
||||
<p className="text-gray-600 leading-relaxed mb-6">
|
||||
{t('payments.cashPendingDialogDesc1')}
|
||||
{t("payments.cashPendingDialogDesc1")}
|
||||
<br />
|
||||
{t('payments.cashPendingDialogDesc2')}
|
||||
{t("payments.cashPendingDialogDesc2")}
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-2xl p-4 mb-6 text-right">
|
||||
<p className="text-sm font-bold text-gray-800 mb-1">{t('payments.platformOfficeLabel')}</p>
|
||||
<p className="text-sm text-gray-600">{t('payments.platformOfficeFullAddress')}</p>
|
||||
<p className="text-sm font-bold text-gray-800 mb-1">{t("payments.platformOfficeLabel")}</p>
|
||||
<p className="text-sm text-gray-600">{t("payments.platformOfficeFullAddress")}</p>
|
||||
<p className="text-sm text-gray-600 flex items-center gap-1 mt-1" dir="ltr">
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
+963567823411
|
||||
@ -679,13 +570,10 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
onClick={() => setShowCashDialog(false)}
|
||||
className="w-full px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors font-medium"
|
||||
>
|
||||
{t('payments.closeButton')}
|
||||
{t("payments.closeButton")}
|
||||
</button>
|
||||
<Link
|
||||
href="/reservations"
|
||||
className="w-full px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
{t('payments.myReservationsLink')}
|
||||
<Link href="/reservations" className="w-full px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors">
|
||||
{t("payments.myReservationsLink")}
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -697,10 +585,8 @@ function PaymentCard({ reservation, payingId, paymentMethods, loadingPaymentMeth
|
||||
<div className="flex items-start gap-3">
|
||||
<Landmark className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-800 mb-1">{t('payments.cashPaymentLocationLabel')}</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{t('payments.platformOfficeFullAddress')}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-800 mb-1">{t("payments.cashPaymentLocationLabel")}</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{t("payments.platformOfficeFullAddress")}</p>
|
||||
<p className="text-sm text-gray-600 flex items-center gap-1 mt-1">
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
<span dir="ltr">+963567823411</span>
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,24 +5,11 @@ import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MessageCircle,
|
||||
Check,
|
||||
X,
|
||||
ArrowLeft,
|
||||
Building,
|
||||
Home,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Loader2,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { User, Mail, Phone, MessageCircle, Check, X, ArrowLeft, Building, Home, Calendar, MapPin, Loader2, Pencil } from "lucide-react";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import AuthService from "../services/AuthService";
|
||||
import { getCustomerByUserId, getOwnerByUserId } from "../utils/api";
|
||||
import InteractiveBackground from "../components/Animation/Background";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@ -59,30 +46,23 @@ export default function ProfilePage() {
|
||||
|
||||
async function fetchProfile() {
|
||||
try {
|
||||
const fetchFn =
|
||||
userData.role === "owner" ? getOwnerByUserId : getCustomerByUserId;
|
||||
const fetchFn = userData.role === "owner" ? getOwnerByUserId : getCustomerByUserId;
|
||||
const profile = await fetchFn(userData.id);
|
||||
|
||||
if (profile) {
|
||||
const profileData = {
|
||||
name:
|
||||
profile.fullName ||
|
||||
profile.name ||
|
||||
`${profile.firstName || ""} ${profile.lastName || ""}`.trim() ||
|
||||
userData.name ||
|
||||
"",
|
||||
name: profile.fullName || profile.name || `${profile.firstName || ""} ${profile.lastName || ""}`.trim() || userData.name || "",
|
||||
email: profile.email || userData.email || "",
|
||||
phone:
|
||||
profile.phone || profile.phoneNumber || userData.phone || "",
|
||||
phone: profile.phone || profile.phoneNumber || userData.phone || "",
|
||||
whatsapp: profile.whatsAppNumber || profile.whatsapp || "",
|
||||
bio: profile.bio || "",
|
||||
location: profile.address || profile.location || "",
|
||||
joinedDate: profile.createdAt
|
||||
? new Date(profile.createdAt).toLocaleDateString(i18n.language === 'ar' ? 'ar-SA' : 'en-US', {
|
||||
? new Date(profile.createdAt).toLocaleDateString(i18n.language === "ar" ? "ar-SA" : "en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
: new Date().toLocaleDateString(i18n.language === 'ar' ? 'ar-SA' : 'en-US', {
|
||||
: new Date().toLocaleDateString(i18n.language === "ar" ? "ar-SA" : "en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
@ -109,7 +89,7 @@ export default function ProfilePage() {
|
||||
whatsapp: "",
|
||||
bio: "",
|
||||
location: "",
|
||||
joinedDate: new Date().toLocaleDateString(i18n.language === 'ar' ? 'ar-SA' : 'en-US', {
|
||||
joinedDate: new Date().toLocaleDateString(i18n.language === "ar" ? "ar-SA" : "en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
@ -146,7 +126,7 @@ export default function ProfilePage() {
|
||||
setFormData(updatedData);
|
||||
localStorage.setItem("userProfile", JSON.stringify(updatedData));
|
||||
setEditingBio(false);
|
||||
toast.success(t('bio-updated-success'));
|
||||
toast.success(t("bio-updated-success"));
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
@ -160,37 +140,25 @@ export default function ProfilePage() {
|
||||
<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">{t('loading-profile')}</p>
|
||||
<p className="text-gray-600">{t("loading-profile")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir={i18n.language === 'ar' ? 'rtl' : 'ltr'} className="min-h-screen bg-gray-50 py-8">
|
||||
<div dir={i18n.language === "ar" ? "rtl" : "ltr"} className="min-h-screen bg-gray-50 py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<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-6"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-amber-600 transition-colors"
|
||||
>
|
||||
<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 justify-between items-center mb-6">
|
||||
<Link href="/" className="flex items-center gap-2 text-gray-600 hover:text-amber-600 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>{t('backToHome')}</span>
|
||||
<span>{t("backToHome")}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="bg-white rounded-3xl shadow-xl overflow-hidden"
|
||||
>
|
||||
<motion.div variants={fadeInUp} initial="initial" animate="animate" className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
<div className="h-48 bg-gradient-to-r from-amber-500 to-amber-600 relative overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white/10"
|
||||
@ -209,43 +177,34 @@ export default function ProfilePage() {
|
||||
<div className="flex justify-center -mt-16 mb-6">
|
||||
<div className="relative group">
|
||||
<div className="w-32 h-32 rounded-full border-4 border-white bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-white text-4xl font-bold shadow-xl overflow-hidden">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt={formData.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
formData.name?.charAt(0).toUpperCase() || "U"
|
||||
)}
|
||||
{avatarPreview ? <img src={avatarPreview} alt={formData.name} className="w-full h-full object-cover" /> : formData.name?.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{formData.name}
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{formData.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-gray-500 mt-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{formData.location || t('addressNotSpecified')}</span>
|
||||
<span>{formData.location || t("addressNotSpecified")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-gray-500 mt-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span> {t('member-since')} {formData.joinedDate}</span>
|
||||
<span>
|
||||
{" "}
|
||||
{t("member-since")} {formData.joinedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 p-4 rounded-xl group">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<label className="text-sm font-medium text-gray-600">
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-600">{t("emailLabel")}</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-900">
|
||||
<Mail className="w-5 h-5 text-gray-400" />
|
||||
@ -255,42 +214,36 @@ export default function ProfilePage() {
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl group">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<label className="text-sm font-medium text-gray-600">
|
||||
{t('phoneLabel')}
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-600">{t("phoneLabel")}</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-900">
|
||||
<Phone className="w-5 h-5 text-gray-400" />
|
||||
<span>{formData.phone || t('not-specified')}</span>
|
||||
<span>{formData.phone || t("not-specified")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl group">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<label className="text-sm font-medium text-gray-600">
|
||||
{t('whatsapp-label')}
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-600">{t("whatsapp-label")}</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-900">
|
||||
<MessageCircle className="w-5 h-5 text-gray-400" />
|
||||
<span>{formData.whatsapp || t('not-specified')}</span>
|
||||
<span>{formData.whatsapp || t("not-specified")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">
|
||||
{t('account-type')}
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">{t("account-type")}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.role === "owner" ? (
|
||||
<>
|
||||
<Building className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-gray-900">{t('property-owner')}</span>
|
||||
<span className="text-gray-900">{t("property-owner")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Home className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-gray-900">{t('customer')}</span>
|
||||
<span className="text-gray-900">{t("customer")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -348,17 +301,17 @@ export default function ProfilePage() {
|
||||
|
||||
{user?.role === "owner" && (
|
||||
<div className="grid grid-cols-3 gap-4 mt-6">
|
||||
<div className="bg-amber-50 p-4 rounded-xl text-center">
|
||||
<div className=" p-4 rounded-xl text-center" style={{backgroundColor:"#0f172a"}} >
|
||||
<div className="text-2xl font-bold text-amber-600">12</div>
|
||||
<div className="text-xs text-gray-600">{t('propertiesListed')}</div>
|
||||
<div className="text-xs text-gray-600">{t("propertiesListed")}</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center"style={{backgroundColor:"#0f172a"}} >
|
||||
<div className="text-2xl font-bold text-blue-600">8</div>
|
||||
<div className="text-xs text-gray-600">{t('citiesCovered')}</div>
|
||||
<div className="text-xs text-gray-600">{t("citiesCovered")}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center" style={{backgroundColor:"#0f172a"}}>
|
||||
<div className="text-2xl font-bold text-green-600">4.8</div>
|
||||
<div className="text-xs text-gray-600">{t('customerSatisfaction')}</div>
|
||||
<div className="text-xs text-gray-600">{t("customerSatisfaction")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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-gray-50 flex items-center justify-center">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-lg">جاري التحميل...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,21 @@
|
||||
import PropertyDetail from './PropertyDetail';
|
||||
import PropertyDetail from "./PropertyDetail";
|
||||
|
||||
// Server-side API fetch for metadata (runs at request time on server)
|
||||
async function fetchPropertyForMeta(id) {
|
||||
async function fetchPropertyForMeta(id, type) {
|
||||
try {
|
||||
const res = await fetch(`http://45.93.137.91/api/RentProperties/GetRentProperties`, {
|
||||
const endpoint = type === "sale" ? "http://45.93.137.91/api/SaleProperties/GetSaleProperties" : "http://45.93.137.91/api/RentProperties/GetRentProperties";
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const text = await res.text();
|
||||
const json = JSON.parse(text);
|
||||
|
||||
const items = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
||||
return items.find(p => p.id == id) || items[0] || null;
|
||||
|
||||
return items.find((p) => p.id == id) || items[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -18,27 +23,46 @@ async function fetchPropertyForMeta(id) {
|
||||
|
||||
function mapProperty(item) {
|
||||
const info = item.propertyInformation || item.PropertyInformation || {};
|
||||
|
||||
let details = {};
|
||||
if (typeof info.detailsJSON === 'object' && info.detailsJSON) {
|
||||
|
||||
if (typeof info.detailsJSON === "object" && info.detailsJSON) {
|
||||
details = info.detailsJSON;
|
||||
} else if (typeof info.DetailsJSON === 'object' && info.DetailsJSON) {
|
||||
} else if (typeof info.DetailsJSON === "object" && info.DetailsJSON) {
|
||||
details = info.DetailsJSON;
|
||||
} else {
|
||||
try { details = JSON.parse(info.detailsJSON || info.DetailsJSON || '{}'); } catch {}
|
||||
try {
|
||||
details = JSON.parse(info.detailsJSON || info.DetailsJSON || "{}");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const price = item.monthlyRent || item.MonthlyRent || item.dailyRent || item.DailyRent || 0;
|
||||
const priceUnit = item.monthlyRent || item.MonthlyRent ? 'monthly' : 'daily';
|
||||
|
||||
const priceUnit = item.monthlyRent || item.MonthlyRent ? "monthly" : "daily";
|
||||
|
||||
const buildingType = info.buildingType ?? info.BuildingType ?? 0;
|
||||
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||
const typeLabel = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||
const address = info.address || info.Address || '';
|
||||
|
||||
const type =
|
||||
{
|
||||
0: "apartment",
|
||||
1: "villa",
|
||||
2: "house",
|
||||
}[buildingType] || "apartment";
|
||||
|
||||
const typeLabel =
|
||||
{
|
||||
0: "apartment",
|
||||
1: "villa",
|
||||
2: "house",
|
||||
}[buildingType] || "apartment";
|
||||
|
||||
const address = info.address || info.Address || "";
|
||||
const bedrooms = info.numberOfBedRooms || info.NumberOfBedRooms || 0;
|
||||
const bathrooms = info.numberOfBathRooms || info.NumberOfBathRooms || 0;
|
||||
const area = info.space || info.Space || 0;
|
||||
const desc = info.description || info.Description || '';
|
||||
const desc = info.description || info.Description || "";
|
||||
const images = info.images || info.Images || [];
|
||||
const firstImage = Array.isArray(images) && images[0] ? images[0] : '';
|
||||
const firstImage = Array.isArray(images) && images[0] ? images[0] : "";
|
||||
|
||||
return {
|
||||
title: `${typeLabel} في ${address}`,
|
||||
@ -54,49 +78,60 @@ function mapProperty(item) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
export async function generateMetadata({ params, searchParams }) {
|
||||
const { id } = await params;
|
||||
const raw = await fetchPropertyForMeta(id);
|
||||
const { type } = await searchParams;
|
||||
|
||||
|
||||
|
||||
const raw = await fetchPropertyForMeta(id, type);
|
||||
|
||||
if (!raw) {
|
||||
return {
|
||||
title: 'SweetHome - عقار',
|
||||
description: 'اكتشف أفضل العقارات للإيجار',
|
||||
title: "SweetHome - عقار",
|
||||
description: "اكتشف أفضل العقارات للإيجار",
|
||||
};
|
||||
}
|
||||
|
||||
const p = mapProperty(raw);
|
||||
const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === 'daily' ? 'يوم' : 'شهر'}`;
|
||||
const propertyImage = p.image
|
||||
? (p.image.startsWith('http') ? p.image : `http://45.93.137.91${p.image}`)
|
||||
: '';
|
||||
const logoUrl = `http://45.93.137.91/logo.png`;
|
||||
|
||||
// Use property image if available, otherwise logo
|
||||
const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === "daily" ? "يوم" : "شهر"}`;
|
||||
|
||||
const propertyImage = p.image ? (p.image.startsWith("http") ? p.image : `http://45.93.137.91${p.image}`) : "";
|
||||
|
||||
const logoUrl = "http://45.93.137.91/logo.png";
|
||||
|
||||
const ogImages = propertyImage
|
||||
? [{ url: propertyImage, width: 1200, height: 630 }, { url: logoUrl, width: 512, height: 512 }]
|
||||
? [
|
||||
{ url: propertyImage, width: 1200, height: 630 },
|
||||
{ url: logoUrl, width: 512, height: 512 },
|
||||
]
|
||||
: [{ url: logoUrl, width: 512, height: 512 }];
|
||||
|
||||
return {
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
|
||||
openGraph: {
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
images: ogImages,
|
||||
url: `http://45.93.137.91/property/${id}`,
|
||||
type: 'website',
|
||||
siteName: 'SweetHome',
|
||||
url: `http://45.93.137.91/property/${id}?type=${type}`,
|
||||
type: "website",
|
||||
siteName: "SweetHome",
|
||||
},
|
||||
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
card: "summary_large_image",
|
||||
title: `${p.title} - ${priceStr}`,
|
||||
description: p.description,
|
||||
images: ogImages.map(i => i.url),
|
||||
images: ogImages.map((i) => i.url),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function PropertyPage({ params }) {
|
||||
return <PropertyDetail params={params} />;
|
||||
export default async function PropertyPage({ params, searchParams }) {
|
||||
const { type } = await searchParams;
|
||||
|
||||
return <PropertyDetail params={params} type={type} />;
|
||||
}
|
||||
|
||||
@ -979,7 +979,7 @@ export default function AgentRegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<div className="min-h-screen bg-gradient-to-br flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="absolute inset-0 overflow-hidden">{backgroundElements}</div>
|
||||
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
'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 (
|
||||
<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 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
|
||||
<h2 className="text-2xl font-bold text-amber-50 mb-2">حدث خطأ</h2>
|
||||
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
|
||||
|
||||
@ -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,566 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
User, Mail, Phone, Lock, Eye, EyeOff, MessageCircle,
|
||||
Camera, X, CheckCircle, XCircle, ArrowLeft, Building,
|
||||
Loader2, Shield, KeyRound, FileText
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addOwner, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { OwnerType, OwnerTypeLabels } from '../../enums';
|
||||
|
||||
import Owner from "@/app/components/OwnerComponents/Owner";
|
||||
export const metadata = {
|
||||
title: "انشاء حساب صاحب عقار",
|
||||
description: "انشاء حساب والتسجيل ك صاحب عقار",
|
||||
};
|
||||
export default function OwnerRegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = 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 [otpCode, setOtpCode] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const fileInputFrontRef = useRef(null);
|
||||
const fileInputBackRef = useRef(null);
|
||||
const fileInputLicenseRef = useRef(null);
|
||||
|
||||
const isCompany = Number(formData.ownerType) === OwnerType.REAL_ESTATE_AGENCY;
|
||||
|
||||
const handleImageUpload = (side, file) => {
|
||||
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' } });
|
||||
};
|
||||
|
||||
const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
|
||||
|
||||
const validateStep1 = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.firstName) newErrors.firstName = t('register.firstNameRequired');
|
||||
if (!formData.lastName) newErrors.lastName = t('register.lastNameRequired');
|
||||
if (!formData.email) newErrors.email = t('register.emailRequired');
|
||||
else if (!validateEmail(formData.email)) newErrors.email = t('register.emailInvalid');
|
||||
if (!formData.phone) newErrors.phone = t('register.phoneRequired');
|
||||
else if (!validatePhone(formData.phone)) newErrors.phone = t('register.phoneInvalid') + ` (${t('register.phoneMustStartWith')} 09/05)`;
|
||||
if (!formData.whatsapp) newErrors.whatsapp = t('register.whatsappRequired');
|
||||
else if (!validatePhone(formData.whatsapp)) newErrors.whatsapp = t('register.phoneInvalid') + ` (${t('register.phoneMustStartWith')} 09/05)`;
|
||||
if (!formData.password) newErrors.password = t('register.passwordRequired');
|
||||
else if (formData.password.length < 6) newErrors.password = t('validation.passwordMinLength');
|
||||
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = t('validation.passwordsMatch');
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validateStep2 = () => {
|
||||
const newErrors = {};
|
||||
if (!idImages.front) newErrors.front = t('register.frontIdRequired');
|
||||
if (!idImages.back) newErrors.back = t('register.backIdRequired');
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (validateStep1()) {
|
||||
setStep(2);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
toast.error(t('validation.correctErrors'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateStep2()) {
|
||||
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) {
|
||||
const tempToken = res.data;
|
||||
if (tempToken) AuthService.addToken(tempToken);
|
||||
toast.success(res.message || t('register.accountCreated'), { 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'));
|
||||
router.push('/');
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || res.data?.message || t('register.accountCreationFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('register.registrationError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.5 }
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
animate: { transition: { staggerChildren: 0.1 } }
|
||||
};
|
||||
|
||||
const backgroundElements = useMemo(() => {
|
||||
const circles = [
|
||||
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-amber-500/5' },
|
||||
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/5' },
|
||||
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-purple-500/5' },
|
||||
];
|
||||
const dots = Array.from({ length: 10 }).map((_, i) => ({
|
||||
left: `${5 + i * 10}%`,
|
||||
top: `${10 + (i * 7) % 80}%`,
|
||||
size: `${80 + (i % 5) * 15}px`
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
{circles.map((circle, i) => (
|
||||
<div key={`circle-${i}`} className={`absolute rounded-full ${circle.className}`} style={circle.style} />
|
||||
))}
|
||||
{dots.map((dot, i) => (
|
||||
<div key={`dot-${i}`} className="absolute rounded-full bg-amber-500/10" style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="absolute inset-0 overflow-hidden">{backgroundElements}</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
|
||||
className="relative z-10 w-full max-w-2xl">
|
||||
<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-400">{t('register.stepOf2', { step: step })}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2].map((s) => (
|
||||
<motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? 'bg-amber-500' : 'bg-gray-700'}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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={{ 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">
|
||||
<Building className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{step === 1 ? t('register.owner.step1Title') : t('register.owner.step1bTitle')}</h1>
|
||||
<p className="text-amber-100">{step === 1 ? t('register.owner.step1Desc') : t('register.owner.step1bDesc')}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<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-white 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-white 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-white 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-white 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-white 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-white 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-white 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}>
|
||||
<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 text-white appearance-none cursor-pointer">
|
||||
{Object.entries(OwnerTypeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value} className="bg-gray-900 text-white">{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCompany && (
|
||||
<motion.div
|
||||
key="license-upload-step1"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">{t('register.owner.licenseImageLabel')} <span className="text-gray-400">({t('register.optional')})</span></label>
|
||||
<div className="mb-2 text-xs text-gray-400">{t('register.owner.licenseHint')}</div>
|
||||
<div onClick={() => fileInputLicenseRef.current?.click()}
|
||||
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.license ? 'border-green-500 bg-green-500/10' : 'border-gray-700 hover:border-amber-500 hover:bg-white/5'}`}>
|
||||
<input ref={fileInputLicenseRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('license', e.target.files?.[0])} className="hidden" />
|
||||
{idImagePreviews.license ? (
|
||||
<div className="relative">
|
||||
<Image src={idImagePreviews.license} alt={t('register.uploadLicenseAlt')} width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, license: null})); setIdImagePreviews(prev => ({...prev, license: ''})); }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">{t('register.owner.clickUploadLicense')}</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • {t('register.upTo5MB')}</p></>)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</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-white 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-white 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">{t('register.owner.frontIdLabel')} <span className="text-red-500">*</span></label>
|
||||
<div onClick={() => fileInputFrontRef.current?.click()}
|
||||
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.front ? 'border-green-500 bg-green-500/10' : errors.front ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-amber-500 hover:bg-white/5'}`}>
|
||||
<input ref={fileInputFrontRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('front', e.target.files?.[0])} className="hidden" />
|
||||
{idImagePreviews.front ? (
|
||||
<div className="relative">
|
||||
<Image src={idImagePreviews.front} alt={t('register.uploadFrontAlt')} width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, front: null})); setIdImagePreviews(prev => ({...prev, front: ''})); }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">{t('register.owner.clickUploadImage')}</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • {t('register.upTo5MB')}</p></>)}
|
||||
</div>
|
||||
{errors.front && <p className="text-red-500 text-sm mt-1">{errors.front}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">{t('register.owner.backIdLabel')} <span className="text-red-500">*</span></label>
|
||||
<div onClick={() => fileInputBackRef.current?.click()}
|
||||
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.back ? 'border-green-500 bg-green-500/10' : errors.back ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-amber-500 hover:bg-white/5'}`}>
|
||||
<input ref={fileInputBackRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('back', e.target.files?.[0])} className="hidden" />
|
||||
{idImagePreviews.back ? (
|
||||
<div className="relative">
|
||||
<Image src={idImagePreviews.back} alt={t('register.uploadBackAlt')} width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, back: null})); setIdImagePreviews(prev => ({...prev, back: ''})); }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">{t('register.owner.clickUploadImage')}</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • {t('register.upTo5MB')}</p></>)}
|
||||
</div>
|
||||
{errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp} className="flex items-center gap-2">
|
||||
<input type="checkbox" id="terms" checked={formData.agreeTerms}
|
||||
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500" required />
|
||||
<label htmlFor="terms" className="text-sm text-gray-300">
|
||||
{t('register.agreeTerms')} <Link href="/terms" className="text-amber-400 hover:text-amber-300">{t('register.termsOfUse')}</Link> {t('register.and')} <Link href="/privacy" className="text-amber-400 hover:text-amber-300">{t('register.privacyPolicy')}</Link>
|
||||
</label>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<button type="button" onClick={() => router.push('/auth/choose-role')}
|
||||
className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">{t('register.cancel')}</button>
|
||||
<button type="submit"
|
||||
className="flex-1 bg-gradient-to-r from-amber-500 to-amber-600 text-white py-3 px-4 rounded-xl font-medium hover:from-amber-600 hover:to-amber-700 transition-all">{t('register.next')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => setStep(1)}
|
||||
className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">{t('register.previous')}</button>
|
||||
<button type="submit" disabled={isLoading || !formData.agreeTerms}
|
||||
className="flex-1 bg-gradient-to-r from-amber-500 to-amber-600 text-white py-3 px-4 rounded-xl font-medium hover:from-amber-600 hover:to-amber-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>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showOtpModal && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-amber-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Shield className="w-8 h-8 text-amber-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="text-amber-400 font-medium text-sm">{formData.email}</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">{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 from-amber-500 to-amber-600 text-white py-3 rounded-xl font-medium hover:from-amber-600 hover:to-amber-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 text-amber-400 hover:text-amber-300 text-sm mt-3 disabled:opacity-50">
|
||||
{t('register.resendOtp')}
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<>
|
||||
<Owner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,419 +1,133 @@
|
||||
"use client";
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
User, Mail, Phone, Lock, Eye, EyeOff,
|
||||
CheckCircle, XCircle, ArrowLeft, Home, Loader2,
|
||||
Shield, KeyRound
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addCustomer, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { CustomerType, CustomerTypeLabels } from '../../enums';
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import { addCustomer, loginWithEmail } from "../../utils/api";
|
||||
import AuthService from "../../services/AuthService";
|
||||
import { CustomerType } from "../../enums";
|
||||
import { fadeInUp } from "../../components/Animation/OwnerPageAnimation";
|
||||
import { staggerContainer } from "../../components/Animation/OwnerPageAnimation";
|
||||
import InteractiveBackground from "@/app/components/Animation/Background";
|
||||
import HeaderOfSteps from "@/app/components/OwnerComponents/HeaderOfSteps";
|
||||
import HeaderOfForm from "@/app/components/OwnerComponents/HeaderOfForm";
|
||||
import OwnerFormStepOne from "@/app/components/OwnerComponents/OwnerFormStepOne";
|
||||
import Policy from "@/app/components/OwnerComponents/PolicyAgree";
|
||||
import ButtomStepOne from "@/app/components/OwnerComponents/ButtomStepOne";
|
||||
import ButtomStepTwo from "@/app/components/OwnerComponents/ButtomStepTwo";
|
||||
import HavAccount from "@/app/components/HavAccount";
|
||||
import OtpDialog from "@/app/components/OwnerComponents/OtpDialog";
|
||||
import { validateStep1 } from "@/app/validations/OwnerValidatoin";
|
||||
export default function TenantRegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
whatsapp: '',
|
||||
phone2: '',
|
||||
nationalNumber: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
whatsapp: "",
|
||||
phone2: "",
|
||||
nationalNumber: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
customerType: CustomerType.PERSONAL,
|
||||
agreeTerms: false
|
||||
agreeTerms: false,
|
||||
});
|
||||
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
|
||||
|
||||
const validateStep1 = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.firstName) newErrors.firstName = t('register.firstNameRequired');
|
||||
if (!formData.lastName) newErrors.lastName = t('register.lastNameRequired');
|
||||
if (!formData.email) newErrors.email = t('register.emailRequired');
|
||||
else if (!validateEmail(formData.email)) newErrors.email = t('register.emailInvalid');
|
||||
if (!formData.phone) newErrors.phone = t('register.phoneRequired');
|
||||
else if (!validatePhone(formData.phone)) newErrors.phone = t('register.phoneInvalid') + ` (${t('register.phoneMustStartWith')} 09/05)`;
|
||||
if (!formData.password) newErrors.password = t('register.passwordRequired');
|
||||
else if (formData.password.length < 6) newErrors.password = t('validation.passwordMinLength');
|
||||
if (!formData.whatsapp) newErrors.whatsapp = t('register.whatsappRequired');
|
||||
if (!formData.phone2 || formData.phone2.length !== 7) newErrors.phone2 = t('validation.phone7Digits');
|
||||
if (!formData.nationalNumber) newErrors.nationalNumber = t('register.nationalNumberRequired');
|
||||
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = t('validation.passwordsMatch');
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (validateStep1()) {
|
||||
if (validateStep1(formData, setErrors, t)) {
|
||||
setStep(2);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else {
|
||||
toast.error(t('validation.correctErrors'));
|
||||
toast.error(t("validation.correctErrors"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.agreeTerms) {
|
||||
toast.error(t('validation.agreeToTerms'));
|
||||
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,
|
||||
phone: formData.phone2 || "" ,
|
||||
nationalNumber: formData.nationalNumber,
|
||||
password: formData.password,
|
||||
customerType: formData.customerType,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await addCustomer(payload, null, null);
|
||||
|
||||
if (res.status === 200 || res.ok) {
|
||||
const tempToken = res.data;
|
||||
if (tempToken) {
|
||||
AuthService.addToken(tempToken);
|
||||
}
|
||||
|
||||
const apiMessage = res.message || res.data?.message;
|
||||
toast.success(apiMessage || t('register.accountCreated'), { duration: 4000 });
|
||||
|
||||
const apiMessage = res.message || res.data?.message || res.data[0]?.message;
|
||||
toast.success(apiMessage || t("register.accountCreated"), { 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: '📧' });
|
||||
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'));
|
||||
router.push('/');
|
||||
toast.success(loginRes.message || t("register.loginSuccess"));
|
||||
}
|
||||
} else {
|
||||
const errMsg = res.message || res.data?.message || t('register.accountCreationFailed');
|
||||
const errMsg = res.message || res.data?.message || res.data[0]?.message || t("register.accountCreationFailed");
|
||||
toast.error(errMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('register.registrationError'));
|
||||
toast.error(err.message || t("register.registrationError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.5 }
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
animate: { transition: { staggerChildren: 0.1 } }
|
||||
};
|
||||
|
||||
const backgroundElements = useMemo(() => {
|
||||
const circles = [
|
||||
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-blue-500/10' },
|
||||
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/10' },
|
||||
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-blue-500/10' },
|
||||
];
|
||||
const dots = [
|
||||
{ left: '5%', top: '10%', size: '120px' },
|
||||
{ left: '15%', top: '70%', size: '80px' },
|
||||
{ left: '25%', top: '30%', size: '150px' },
|
||||
{ left: '35%', top: '85%', size: '100px' },
|
||||
{ left: '45%', top: '15%', size: '90px' },
|
||||
{ left: '55%', top: '60%', size: '130px' },
|
||||
{ left: '65%', top: '40%', size: '70px' },
|
||||
{ left: '75%', top: '80%', size: '110px' },
|
||||
{ left: '85%', top: '20%', size: '140px' },
|
||||
{ left: '95%', top: '50%', size: '85px' },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{circles.map((circle, i) => (
|
||||
<div key={`circle-${i}`} className={`absolute rounded-full ${circle.className}`} style={circle.style} />
|
||||
))}
|
||||
{dots.map((dot, i) => (
|
||||
<div key={`dot-${i}`} className="absolute rounded-full bg-blue-500/10" style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<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}</div>
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<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-md">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-8">
|
||||
<Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
|
||||
<motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
|
||||
<span>{t('forgotPassword.backToLogin')}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
{[1, 2].map((s) => (
|
||||
<motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? 'bg-blue-500' : 'bg-gray-700'}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<HeaderOfSteps step={step} colors={"bg-blue-500"} numberStep={[1, 2]} />
|
||||
<div className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-8 text-center relative overflow-hidden">
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2, type: "spring" }} className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
|
||||
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="relative z-10">
|
||||
<motion.div animate={{ rotate: [0, 10, -10, 0] }} transition={{ duration: 2, repeat: Infinity }} className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{step === 1 ? t('register.tenant.step1Title') : t('register.tenant.step2Title')}</h1>
|
||||
<p className="text-blue-100">{step === 1 ? t('register.tenant.step1Desc') : t('register.tenant.step2Desc')}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<HeaderOfForm num={1} step={step} />
|
||||
<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 && (
|
||||
<>
|
||||
<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-blue-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-blue-500 focus:border-transparent text-white 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-blue-500 focus:border-transparent text-white 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-blue-500'}`} />
|
||||
</div>
|
||||
<input type="email" value={formData.email} onChange={(e) => { setFormData({...formData, email: e.target.value}); setErrors({...errors, email: null}); }} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.email ? 'border-red-500' : 'border-gray-700'}`} placeholder={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-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Phone className={`w-5 h-5 ${errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
|
||||
</div>
|
||||
<input type="tel" value={formData.phone} onChange={(e) => { setFormData({...formData, phone: e.target.value}); setErrors({...errors, phone: null}); }} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone ? 'border-red-500' : 'border-gray-700'}`} placeholder={t('register.phonePlaceholder')} />
|
||||
</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">
|
||||
<Phone className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-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-blue-500 focus:border-transparent text-white 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-blue-500'}`} />
|
||||
</div>
|
||||
<input type="tel" value={formData.phone2} onChange={(e) => { setFormData({...formData, phone2: e.target.value}); setErrors({...errors, phone2: null}); }} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone2 ? 'border-red-500' : 'border-gray-700'}`} placeholder={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-blue-500'}`} />
|
||||
</div>
|
||||
<input type="text" value={formData.nationalNumber} onChange={(e) => { setFormData({...formData, nationalNumber: e.target.value}); setErrors({...errors, nationalNumber: null}); }} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.nationalNumber ? 'border-red-500' : 'border-gray-700'}`} placeholder={t('register.nationalNumberPlaceholder')} />
|
||||
</div>
|
||||
{errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">{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 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-blue-500'}`} />
|
||||
</div>
|
||||
<input type={showPassword ? "text" : "password"} value={formData.password} onChange={(e) => { setFormData({...formData, password: e.target.value}); setErrors({...errors, password: null}); }} className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.password ? 'border-red-500' : 'border-gray-700'}`} placeholder={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-blue-500'}`} />
|
||||
</div>
|
||||
<input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword} onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: null}); }} className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.confirmPassword ? 'border-red-500' : 'border-gray-700'}`} placeholder={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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<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 text-blue-500 focus:ring-blue-500" required />
|
||||
<label htmlFor="terms" className="text-sm text-gray-300">
|
||||
{t('register.agreeTerms')} <Link href="/terms" className="text-blue-400 hover:text-blue-300">{t('register.termsOfUse')}</Link> {t('register.and')} <Link href="/privacy" className="text-blue-400 hover:text-blue-300">{t('register.privacyPolicy')}</Link>
|
||||
</label>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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} type={"customer"} />}
|
||||
{step === 2 && <Policy formData={formData} setFormData={setFormData} type={"customer"} />}
|
||||
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<button type="button" onClick={() => router.push('/auth/choose-role')} className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">{t('register.cancel')}</button>
|
||||
<button type="submit" className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transition-all">{t('register.next')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => setStep(1)} className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">{t('register.previous')}</button>
|
||||
<button type="submit" disabled={isLoading || !formData.agreeTerms} className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isLoading ? (<div className="flex items-center justify-center gap-2"><Loader2 className="w-5 h-5 animate-spin" /><span>{t('register.registering')}</span></div>) : t('register.createAccount')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === 1 ? <ButtomStepOne type={"custoemr"} /> : <ButtomStepTwo formData={formData} setFormData={setFormData} isLoading={isLoading} type={"type"} />}
|
||||
</motion.div>
|
||||
|
||||
{step === 1 && (
|
||||
<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>
|
||||
)}
|
||||
{step === 1 && <HavAccount />}
|
||||
</motion.form>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showOtpModal && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }} className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-3"><Shield className="w-8 h-8 text-blue-500" /></div>
|
||||
<h2 className="text-xl font-bold text-white">{t('register.otpTitle')}</h2>
|
||||
<p className="text-gray-400 text-sm mt-1">{t('register.otpSentTo')}</p>
|
||||
<p className="text-blue-400 font-medium text-sm">{formData.email}</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">{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-blue-500 text-white text-center tracking-[0.5em] text-xl" placeholder="------" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleVerifyOTP} disabled={isLoading || !otpCode} className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{isLoading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>{t('register.verifying')}</span></> : t('register.verify')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleResendOTP} disabled={isLoading} className="w-full text-center text-blue-400 hover:text-blue-300 text-sm mt-3 disabled:opacity-50">{t('register.resendOtp')}</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>{showOtpModal && <OtpDialog formData={formData} setShowOtpModal={setShowOtpModal} type={"customer"} />}</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,9 +12,7 @@ import {
|
||||
Send,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
User,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { submitReport, submitReservationReport, submitSaleReport } from '../utils/api';
|
||||
@ -138,14 +136,14 @@ export default function ReportsPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('reports.subjectLabel')}</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<div className="absolute inset-y-0 end-0 pe-3 flex items-center pointer-events-none">
|
||||
<FileText className={`w-5 h-5 ${errors.subject ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={generalForm.subject}
|
||||
onChange={(e) => { setGeneralForm({ ...generalForm, subject: e.target.value }); if (errors.subject) setErrors({ ...errors, subject: null }); }}
|
||||
className={`w-full pr-12 pl-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all ${errors.subject ? 'border-red-500' : 'border-gray-300'}`}
|
||||
className={`w-full pe-12 ps-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all ${errors.subject ? 'border-red-500' : 'border-gray-300'}`}
|
||||
placeholder={t('reports.subjectPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@ -184,14 +182,14 @@ export default function ReportsPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('reports.reservationIdLabel')}</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<div className="absolute inset-y-0 end-0 pe-3 flex items-center pointer-events-none">
|
||||
<Hash className={`w-5 h-5 ${errors.reservationId ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={reservationForm.reservationId}
|
||||
onChange={(e) => { setReservationForm({ ...reservationForm, reservationId: e.target.value }); if (errors.reservationId) setErrors({ ...errors, reservationId: null }); }}
|
||||
className={`w-full pr-12 pl-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all ${errors.reservationId ? 'border-red-500' : 'border-gray-300'}`}
|
||||
className={`w-full pe-12 ps-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all ${errors.reservationId ? 'border-red-500' : 'border-gray-300'}`}
|
||||
placeholder={t('reports.reservationIdPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@ -250,14 +248,14 @@ export default function ReportsPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('reports.saleIdLabel')}</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<div className="absolute inset-y-0 end-0 pe-3 flex items-center pointer-events-none">
|
||||
<Hash className={`w-5 h-5 ${errors.saleId ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={saleForm.saleId}
|
||||
onChange={(e) => { setSaleForm({ ...saleForm, saleId: e.target.value }); if (errors.saleId) setErrors({ ...errors, saleId: null }); }}
|
||||
className={`w-full pr-12 pl-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all ${errors.saleId ? 'border-red-500' : 'border-gray-300'}`}
|
||||
className={`w-full pe-12 ps-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all ${errors.saleId ? 'border-red-500' : 'border-gray-300'}`}
|
||||
placeholder={t('reports.saleIdPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@ -314,7 +312,7 @@ export default function ReportsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="min-h-screen py-8">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-3xl">
|
||||
<motion.div
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { getCustomerByUserId, getOwnerByUserId, loginWithEmail, loginWithPhone } from "../utils/api";
|
||||
/**
|
||||
* AuthService
|
||||
* Manages authentication tokens and user role detection via JWT decoding.
|
||||
@ -20,31 +22,42 @@
|
||||
* isAuthenticated() — check if token exists
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const USER_KEY = 'cached_user';
|
||||
const TOKEN_KEY = "auth_token";
|
||||
const USER_KEY = "cached_user";
|
||||
|
||||
const isSecureContext = () =>
|
||||
typeof window !== "undefined" && window.location?.protocol === "https:";
|
||||
|
||||
const AuthService = Object.freeze({
|
||||
addToken(token) {
|
||||
if (!token || typeof token !== 'string') return;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
if (!token || typeof token !== "string") return;
|
||||
Cookies.set(TOKEN_KEY, token, {
|
||||
expires: 7,
|
||||
secure: isSecureContext(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
},
|
||||
|
||||
getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
if (typeof window === "undefined") return null;
|
||||
return Cookies.get(TOKEN_KEY);
|
||||
},
|
||||
|
||||
deleteToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
Cookies.remove(USER_KEY);
|
||||
Cookies.remove(TOKEN_KEY);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache full user profile (from API)
|
||||
* @param {object} user — { name, email, phone, ... }
|
||||
*/
|
||||
cacheUser(user) {
|
||||
if (!user) return;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
Cookies.set(USER_KEY, JSON.stringify(user), {
|
||||
expires: 7,
|
||||
secure: isSecureContext(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@ -52,8 +65,12 @@ const AuthService = Object.freeze({
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getCachedUser() {
|
||||
const user = Cookies.get(USER_KEY);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY));
|
||||
return JSON.parse(user);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -67,7 +84,7 @@ const AuthService = Object.freeze({
|
||||
const token = this.getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
const payload = token.split(".")[1];
|
||||
return JSON.parse(atob(payload));
|
||||
} catch {
|
||||
return null;
|
||||
@ -81,15 +98,14 @@ const AuthService = Object.freeze({
|
||||
getUser() {
|
||||
const payload = this.decodeToken();
|
||||
if (!payload) return null;
|
||||
|
||||
const cached = this.getCachedUser();
|
||||
|
||||
return {
|
||||
id: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] || payload.sub || null,
|
||||
name: cached?.name || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null,
|
||||
email: cached?.email || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || null,
|
||||
phone: cached?.phone || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone'] || null,
|
||||
roles: this.getRoles(),
|
||||
id: payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"] || payload.sub || null,
|
||||
name: cached?.name || payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] || null,
|
||||
email: cached?.email || payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] || null,
|
||||
phone: cached?.phone || payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone"] || null,
|
||||
roles: cached?.roles,
|
||||
};
|
||||
},
|
||||
|
||||
@ -100,7 +116,6 @@ const AuthService = Object.freeze({
|
||||
getUserId() {
|
||||
const user = this.getUser();
|
||||
if (!user?.id) return null;
|
||||
|
||||
const parsedId = Number(user.id);
|
||||
return Number.isFinite(parsedId) ? parsedId : user.id;
|
||||
},
|
||||
@ -112,18 +127,32 @@ const AuthService = Object.freeze({
|
||||
getRoles() {
|
||||
const payload = this.decodeToken();
|
||||
if (!payload) return [];
|
||||
const roles = payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
|
||||
const roles = payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
|
||||
if (Array.isArray(roles)) return roles;
|
||||
if (typeof roles === 'string') return [roles];
|
||||
if (typeof roles === "string") return [roles];
|
||||
return [];
|
||||
},
|
||||
async cacheCurrentUser(getOwnerByUserId, getCustomerByUserId) {
|
||||
const authUser = this.getUser();
|
||||
if (!authUser?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchFn = this.isOwner() ? getOwnerByUserId : getCustomerByUserId;
|
||||
const profile = await fetchFn(authUser.id);
|
||||
this.cacheUser({
|
||||
name: profile.firstName + profile.lastName,
|
||||
email: profile.email,
|
||||
phone: profile.phoneNumber,
|
||||
roles: this.getRoles(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* User has Owner role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOwner() {
|
||||
return this.getRoles().includes('Owner');
|
||||
return this.getRoles().includes("Owner");
|
||||
},
|
||||
|
||||
/**
|
||||
@ -131,7 +160,7 @@ const AuthService = Object.freeze({
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAgent() {
|
||||
return this.getRoles().includes('RealEstateAgent');
|
||||
return this.getRoles().includes("RealEstateAgent");
|
||||
},
|
||||
|
||||
/**
|
||||
@ -157,7 +186,37 @@ const AuthService = Object.freeze({
|
||||
isAuthenticated() {
|
||||
return !!this.getToken();
|
||||
},
|
||||
|
||||
async login({ loginMethod, credential, password }) {
|
||||
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||
const result = await loginFn(credential, password);
|
||||
switch (result.status) {
|
||||
case 200: {
|
||||
const token = typeof result.data === "string" ? result.data : result.data.token;
|
||||
this.addToken(token);
|
||||
await this.cacheCurrentUser(getOwnerByUserId, getCustomerByUserId);
|
||||
return {
|
||||
type: "SUCCESS",
|
||||
};
|
||||
}
|
||||
|
||||
case 206: {
|
||||
const token = typeof result.data === "string" ? result.data : result.data.token;
|
||||
this.addToken(token);
|
||||
if (loginMethod === "email") await sendEmailOTP();
|
||||
else await sendPhoneOTP();
|
||||
return {
|
||||
type: "OTP_REQUIRED",
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
type: "ERROR",
|
||||
result,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default AuthService;
|
||||
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
MessageCircle,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Send,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { MessageCircle, Mail, Phone, MapPin, Send, Loader2 } from "lucide-react";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import { submitReport } from "../utils/api";
|
||||
import AuthService from "../services/AuthService";
|
||||
@ -24,84 +17,62 @@ export default function SupportPage() {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!subject.trim() || !body.trim()) {
|
||||
toast.error(t('support.fillAllFields'));
|
||||
toast.error(t("support.fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
toast.error(t('auth.loginRequired'));
|
||||
toast.error(t("auth.loginRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await submitReport(subject, body);
|
||||
toast.success(t('support.submittedSuccess'));
|
||||
toast.success(t("support.submittedSuccess"));
|
||||
setSubject("");
|
||||
setBody("");
|
||||
} catch (error) {
|
||||
toast.error(t('support.submitError'));
|
||||
toast.error(t("support.submitError"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-b from-amber-50/50 to-white py-12"
|
||||
dir={i18n.language === 'ar' ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className="min-h-screen bg-gradient-to-b py-12" dir={i18n.language === "ar" ? "rtl" : "ltr"}>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<div className="container mx-auto px-4 max-w-5xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="text-center mb-12">
|
||||
<div className="w-20 h-20 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-amber-100">
|
||||
<MessageCircle className="w-10 h-10 text-amber-600" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
{t('support.customerService')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{t('support.hereToHelp')}
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">{t("support.customerService")}</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{t("support.hereToHelp")}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.1 }} className="md:col-span-2">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{t('support.sendMessage')}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{t("support.sendMessage")}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('support.subjectLabel')}
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t("support.subjectLabel")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('support.subjectPlaceholder')}
|
||||
placeholder={t("support.subjectPlaceholder")}
|
||||
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl 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-medium text-gray-700 mb-2">
|
||||
{t('support.messageLabel')}
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t("support.messageLabel")}</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={6}
|
||||
placeholder={t('support.messagePlaceholder')}
|
||||
placeholder={t("support.messagePlaceholder")}
|
||||
className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-gray-900 placeholder-gray-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
@ -110,37 +81,24 @@ export default function SupportPage() {
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
{isSubmitting ? t('support.sending') : t('support.send')}
|
||||
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||
{isSubmitting ? t("support.sending") : t("support.send")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.2 }} className="space-y-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">
|
||||
{t('support.contactInfo')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">{t("support.contactInfo")}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center shrink-0">
|
||||
<Mail className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('support.email')}</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
support@sweethome.com
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t("support.email")}</p>
|
||||
<p className="font-medium text-gray-900">support@sweethome.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -148,7 +106,7 @@ export default function SupportPage() {
|
||||
<Phone className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('support.phone')}</p>
|
||||
<p className="text-sm text-gray-500">{t("support.phone")}</p>
|
||||
<p className="font-medium text-gray-900" dir="ltr">
|
||||
+963 11 234 5678
|
||||
</p>
|
||||
@ -159,25 +117,23 @@ export default function SupportPage() {
|
||||
<MapPin className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('support.address')}</p>
|
||||
<p className="font-medium text-gray-900">{t('support.addressValue')}</p>
|
||||
<p className="text-sm text-gray-500">{t("support.address")}</p>
|
||||
<p className="font-medium text-gray-900">{t("support.addressValue")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 rounded-2xl border border-amber-200 p-6">
|
||||
<h3 className="text-lg font-bold text-amber-800 mb-2">
|
||||
{t('support.workingHours')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-bold text-amber-800 mb-2">{t("support.workingHours")}</h3>
|
||||
<div className="space-y-2 text-amber-700">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.weekdays')}</span>
|
||||
<span>{t("support.weekdays")}</span>
|
||||
<span>9:00 - 18:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.friday')}</span>
|
||||
<span>{t('support.closed')}</span>
|
||||
<span>{t("support.friday")}</span>
|
||||
<span>{t("support.closed")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FileText, Shield, CheckCircle, Languages, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { getARTerms, getENTerms } from '../utils/api';
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { FileText, Shield, CheckCircle, Languages, Loader2, AlertCircle } from "lucide-react";
|
||||
import { getARTerms, getENTerms } from "../utils/api";
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
@ -21,75 +21,63 @@ const itemVariants = {
|
||||
const FALLBACK_TERMS = {
|
||||
ar: [
|
||||
{
|
||||
title: 'مقدمة',
|
||||
description:
|
||||
'مرحباً بك في منصة SweetHome. باستخدامك للمنصة، فإنك توافق على الالتزام بشروط الاستخدام هذه. إذا كنت لا توافق على أي جزء من هذه الشروط، يرجى عدم استخدام المنصة.',
|
||||
title: "مقدمة",
|
||||
description: "مرحباً بك في منصة SweetHome. باستخدامك للمنصة، فإنك توافق على الالتزام بشروط الاستخدام هذه. إذا كنت لا توافق على أي جزء من هذه الشروط، يرجى عدم استخدام المنصة.",
|
||||
},
|
||||
{
|
||||
title: 'استخدام المنصة',
|
||||
description:
|
||||
'يُسمح باستخدام المنصة للأغراض المشروعة فقط. يلتزم المستخدم بعدم استخدام المنصة في أي نشاط غير قانوني.',
|
||||
title: "استخدام المنصة",
|
||||
description: "يُسمح باستخدام المنصة للأغراض المشروعة فقط. يلتزم المستخدم بعدم استخدام المنصة في أي نشاط غير قانوني.",
|
||||
},
|
||||
{
|
||||
title: 'حقوق ومسؤوليات المالك',
|
||||
description:
|
||||
'يتحمل المالك مسؤولية دقة المعلومات المقدمة عن العقار بما في ذلك الصور والوصف والسعر والتوفر.',
|
||||
title: "حقوق ومسؤوليات المالك",
|
||||
description: "يتحمل المالك مسؤولية دقة المعلومات المقدمة عن العقار بما في ذلك الصور والوصف والسعر والتوفر.",
|
||||
},
|
||||
{
|
||||
title: 'حقوق ومسؤوليات المستأجر',
|
||||
description:
|
||||
'يلتزم المستأجر باستخدام العقار بطريقة مسؤولة وعدم التسبب في أي ضرر للممتلكات.',
|
||||
title: "حقوق ومسؤوليات المستأجر",
|
||||
description: "يلتزم المستأجر باستخدام العقار بطريقة مسؤولة وعدم التسبب في أي ضرر للممتلكات.",
|
||||
},
|
||||
{
|
||||
title: 'الدفع والعمولات',
|
||||
description:
|
||||
'تتقاضى المنصة عمولة على كل حصة ناجحة وفقاً للنسبة المحددة في وقت الحجز.',
|
||||
title: "الدفع والعمولات",
|
||||
description: "تتقاضى المنصة عمولة على كل حصة ناجحة وفقاً للنسبة المحددة في وقت الحجز.",
|
||||
},
|
||||
{
|
||||
title: 'خصوصية البيانات',
|
||||
description:
|
||||
'نحن نأخذ خصوصية بياناتك على محمل الجد. يتم جمع واستخدام البيانات الشخصية وفقاً لسياسة الخصوصية الخاصة بنا.',
|
||||
title: "خصوصية البيانات",
|
||||
description: "نحن نأخذ خصوصية بياناتك على محمل الجد. يتم جمع واستخدام البيانات الشخصية وفقاً لسياسة الخصوصية الخاصة بنا.",
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
title: 'Introduction',
|
||||
description:
|
||||
'Welcome to SweetHome. By using our platform, you agree to comply with these terms. If you do not agree, please do not use the platform.',
|
||||
title: "Introduction",
|
||||
description: "Welcome to SweetHome. By using our platform, you agree to comply with these terms. If you do not agree, please do not use the platform.",
|
||||
},
|
||||
{
|
||||
title: 'Platform Usage',
|
||||
description:
|
||||
'The platform may only be used for lawful purposes. Users must not engage in any illegal activity.',
|
||||
title: "Platform Usage",
|
||||
description: "The platform may only be used for lawful purposes. Users must not engage in any illegal activity.",
|
||||
},
|
||||
{
|
||||
title: 'Owner Rights & Responsibilities',
|
||||
description:
|
||||
'Owners are responsible for the accuracy of property information including images, description, price, and availability.',
|
||||
title: "Owner Rights & Responsibilities",
|
||||
description: "Owners are responsible for the accuracy of property information including images, description, price, and availability.",
|
||||
},
|
||||
{
|
||||
title: 'Tenant Rights & Responsibilities',
|
||||
description:
|
||||
'Tenants must use the property responsibly and not cause any damage to the property.',
|
||||
title: "Tenant Rights & Responsibilities",
|
||||
description: "Tenants must use the property responsibly and not cause any damage to the property.",
|
||||
},
|
||||
{
|
||||
title: 'Payment & Commissions',
|
||||
description:
|
||||
'The platform charges a commission on each successful booking according to the rate specified at the time of booking.',
|
||||
title: "Payment & Commissions",
|
||||
description: "The platform charges a commission on each successful booking according to the rate specified at the time of booking.",
|
||||
},
|
||||
{
|
||||
title: 'Data Privacy',
|
||||
description:
|
||||
'We take your data privacy seriously. Personal data is collected and used in accordance with our Privacy Policy.',
|
||||
title: "Data Privacy",
|
||||
description: "We take your data privacy seriously. Personal data is collected and used in accordance with our Privacy Policy.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
const [terms, setTerms] = useState([]);
|
||||
const [language, setLanguage] = useState('ar');
|
||||
const [language, setLanguage] = useState("ar");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@ -97,9 +85,9 @@ export default function TermsPage() {
|
||||
const fetchTerms = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setError("");
|
||||
|
||||
const fetcher = language === 'ar' ? getARTerms : getENTerms;
|
||||
const fetcher = language === "ar" ? getARTerms : getENTerms;
|
||||
const data = await fetcher();
|
||||
|
||||
if (!data) {
|
||||
@ -115,14 +103,14 @@ export default function TermsPage() {
|
||||
}
|
||||
|
||||
const mapped = raw.map((item) => ({
|
||||
title: item.title || item.name || '',
|
||||
description: item.description || item.content || item.body || item.text || '',
|
||||
title: item.title || item.name || "",
|
||||
description: item.description || item.content || item.body || item.text || "",
|
||||
}));
|
||||
|
||||
setTerms(mapped);
|
||||
} catch {
|
||||
setTerms(FALLBACK_TERMS[language]);
|
||||
setError('');
|
||||
setError("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -134,102 +122,62 @@ export default function TermsPage() {
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<div
|
||||
dir={language === 'ar' ? 'rtl' : 'ltr'}
|
||||
className="min-h-screen bg-gradient-to-b from-amber-50/50 to-white py-12"
|
||||
>
|
||||
<div dir={language === "ar" ? "rtl" : "ltr"} className="min-h-screen bg-gradient-to-b py-12">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="text-center mb-12">
|
||||
<div className="w-20 h-20 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-amber-100">
|
||||
<FileText className="w-10 h-10 text-amber-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900">
|
||||
{language === 'ar' ? 'شروط الاستخدام' : 'Terms of Use'}
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold text-gray-900">{language === "ar" ? "شروط الاستخدام" : "Terms of Use"}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLanguage(language === 'ar' ? 'en' : 'ar')}
|
||||
onClick={() => setLanguage(language === "ar" ? "en" : "ar")}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
{language === 'ar' ? 'English' : 'العربية'}
|
||||
{language === "ar" ? "English" : "العربية"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{language === 'ar'
|
||||
? 'يرجى قراءة شروط الاستخدام التالية بعناية قبل استخدام المنصة'
|
||||
: 'Please read the following terms of use carefully before using the platform'}
|
||||
{language === "ar" ? "يرجى قراءة شروط الاستخدام التالية بعناية قبل استخدام المنصة" : "Please read the following terms of use carefully before using the platform"}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-3 mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-amber-800">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>
|
||||
{language === 'ar'
|
||||
? 'جاري تحميل شروط الاستخدام...'
|
||||
: 'Loading terms of use...'}
|
||||
</span>
|
||||
<span>{language === "ar" ? "جاري تحميل شروط الاستخدام..." : "Loading terms of use..."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8 rounded-2xl border border-red-200 bg-red-50 p-4 flex items-center gap-3 text-red-700"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-8 rounded-2xl border border-red-200 bg-red-50 p-4 flex items-center gap-3 text-red-700">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!loading && terms.length === 0 && !error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-16 text-gray-500"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16 text-gray-500">
|
||||
<FileText className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-xl font-medium">
|
||||
{language === 'ar'
|
||||
? 'لا توجد شروط استخدام متاحة حالياً'
|
||||
: 'No terms of use available'}
|
||||
</p>
|
||||
<p className="text-xl font-medium">{language === "ar" ? "لا توجد شروط استخدام متاحة حالياً" : "No terms of use available"}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{terms.length > 0 && (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-6">
|
||||
{terms.map((term, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<motion.div key={index} variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center shrink-0 mt-1">
|
||||
<Shield className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{term.title && (
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">{term.title}</h2>
|
||||
)}
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">
|
||||
{term.description}
|
||||
</p>
|
||||
{term.title && <h2 className="text-xl font-bold text-gray-900 mb-3">{term.title}</h2>}
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">{term.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -237,21 +185,14 @@ export default function TermsPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="mt-8 bg-amber-50 rounded-2xl border border-amber-200 p-6 flex items-start gap-4"
|
||||
>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }} className="mt-8 bg-amber-50 rounded-2xl border border-amber-200 p-6 flex items-start gap-4">
|
||||
<CheckCircle className="w-6 h-6 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-bold text-amber-800 mb-1">
|
||||
{language === 'ar' ? 'آخر تحديث' : 'Last Updated'}
|
||||
</p>
|
||||
<p className="font-bold text-amber-800 mb-1">{language === "ar" ? "آخر تحديث" : "Last Updated"}</p>
|
||||
<p className="text-amber-700">
|
||||
{language === 'ar'
|
||||
? 'تم آخر تحديث لشروط الاستخدام في 1 مايو 2026. يرجى مراجعة هذه الصفحة بشكل دوري للاطلاع على أي تغييرات.'
|
||||
: 'Last updated on May 1, 2026. Please review this page periodically for any changes.'}
|
||||
{language === "ar"
|
||||
? "تم آخر تحديث لشروط الاستخدام في 1 مايو 2026. يرجى مراجعة هذه الصفحة بشكل دوري للاطلاع على أي تغييرات."
|
||||
: "Last updated on May 1, 2026. Please review this page periodically for any changes."}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
210
app/utils/api.js
210
app/utils/api.js
@ -1,5 +1,4 @@
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
|
||||
const REPORT_API_BASE = process.env.NEXT_PUBLIC_REPORT_API_URL || "http://45.93.137.91/api";
|
||||
|
||||
@ -20,10 +19,7 @@ export function isApiBlockedError(error) {
|
||||
}
|
||||
|
||||
function redirectToBlockedPage() {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname !== "/blocked"
|
||||
) {
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/blocked") {
|
||||
window.location.replace("/blocked");
|
||||
}
|
||||
}
|
||||
@ -50,12 +46,7 @@ async function apiFetch(endpoint, options = {}) {
|
||||
const hasBody = options.body != null;
|
||||
const bodyIsFormData = isFormData(options.body);
|
||||
|
||||
if (
|
||||
hasBody &&
|
||||
!bodyIsFormData &&
|
||||
!headers["Content-Type"] &&
|
||||
!headers["content-type"]
|
||||
) {
|
||||
if (hasBody && !bodyIsFormData && !headers["Content-Type"] && !headers["content-type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
@ -65,10 +56,7 @@ async function apiFetch(endpoint, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
body:
|
||||
hasBody && !bodyIsFormData && typeof options.body !== "string"
|
||||
? JSON.stringify(options.body)
|
||||
: options.body,
|
||||
body: hasBody && !bodyIsFormData && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body,
|
||||
});
|
||||
|
||||
assertNotBlocked(res);
|
||||
@ -91,10 +79,8 @@ async function apiFetch(endpoint, options = {}) {
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message === 'Failed to fetch') {
|
||||
throw new Error(
|
||||
`تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت أو حاول مرة أخرى لاحقاً. (${API_BASE}${endpoint})`,
|
||||
);
|
||||
if (error instanceof TypeError && error.message === "Failed to fetch") {
|
||||
throw new Error(`تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت أو حاول مرة أخرى لاحقاً. (${API_BASE}${endpoint})`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -137,8 +123,7 @@ async function authFetch(endpoint, body, token = null) {
|
||||
data = text;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof data === "object" && data?.message ? data.message : null;
|
||||
const message = typeof data === "object" && data?.message ? data.message : null;
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
@ -176,8 +161,7 @@ async function reportFetch(endpoint, body) {
|
||||
data = text;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof data === "object" && data?.message ? data.message : null;
|
||||
const message = typeof data === "object" && data?.message ? data.message : null;
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
@ -200,9 +184,7 @@ export async function getRentPropertyLocations(params = {}) {
|
||||
if (params.maxOffset != null) qs.set("maxOffset", params.maxOffset);
|
||||
if (params.minOffset != null) qs.set("minOffset", params.minOffset);
|
||||
const query = qs.toString();
|
||||
return apiFetch(
|
||||
`/RentProperties/GetRentPropertiesLocations${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return apiFetch(`/RentProperties/GetRentPropertiesLocations${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function getSaleProperties() {
|
||||
@ -210,7 +192,7 @@ export async function getSaleProperties() {
|
||||
}
|
||||
|
||||
export async function getSaleProperty(id) {
|
||||
const items = await apiFetch("/SaleProperties/GetSaleProperties");
|
||||
const items = await apiFetch(`/SaleProperties/GetSalePropertyById/${id}`);
|
||||
if (!Array.isArray(items)) return items;
|
||||
return items.find((p) => p.id == id) || items[0];
|
||||
}
|
||||
@ -227,41 +209,32 @@ export async function getTopRecommendations(count = 10) {
|
||||
return apiFetch(`/Recommendations/GetTopRecommendations?count=${count}`);
|
||||
}
|
||||
|
||||
export async function getAvailableDateRanges(
|
||||
propertyId,
|
||||
fromDate = null,
|
||||
toDate = null,
|
||||
) {
|
||||
export async function getAvailableDateRanges(propertyId, fromDate = null, toDate = null) {
|
||||
const qs = new URLSearchParams();
|
||||
if (fromDate) qs.set("fromDate", fromDate);
|
||||
if (toDate) qs.set("toDate", toDate);
|
||||
const query = qs.toString();
|
||||
|
||||
return apiFetch(
|
||||
`/Reservations/GetAvailableDates/available/${propertyId}${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return apiFetch(`/Reservations/GetAvailableDates/available/${propertyId}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function getReservations() {
|
||||
return apiFetch("/Reservations/GetAllReservations");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export async function getReservation(id) {
|
||||
return apiFetch(`/Reservations/GetReservation?id=${id}`);
|
||||
}
|
||||
|
||||
export async function checkAvailability(
|
||||
propertyId,
|
||||
fromDate = null,
|
||||
toDate = null,
|
||||
) {
|
||||
export async function checkAvailability(propertyId, fromDate = null, toDate = null) {
|
||||
const qs = new URLSearchParams();
|
||||
if (fromDate) qs.set("fromDate", fromDate);
|
||||
if (toDate) qs.set("toDate", toDate);
|
||||
const query = qs.toString();
|
||||
return apiFetch(
|
||||
`/Reservations/GetAvailable/${propertyId}${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return apiFetch(`/Reservations/GetAvailable/${propertyId}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function bookReservation(propertyInfoId, startDate, endDate) {
|
||||
@ -298,17 +271,11 @@ export async function getMyRentListings() {
|
||||
export function buildRentPropertyPayload(data = {}) {
|
||||
const propertyInformation = data?.propertyInformation || {};
|
||||
|
||||
const rawCityValue =
|
||||
data?.city ??
|
||||
data?.governorate ??
|
||||
propertyInformation?.city ??
|
||||
propertyInformation?.governorate ??
|
||||
1;
|
||||
const rawCityValue = data?.city ?? data?.governorate ?? propertyInformation?.city ?? propertyInformation?.governorate ?? 1;
|
||||
|
||||
const cityInt = parseInt(rawCityValue, 10) || 1;
|
||||
|
||||
const documentTypeValue =
|
||||
data?.documentType ?? propertyInformation?.documentType ?? 1;
|
||||
const documentTypeValue = data?.documentType ?? propertyInformation?.documentType ?? 1;
|
||||
|
||||
return {
|
||||
...data,
|
||||
@ -332,9 +299,44 @@ export async function addRentProperty(data) {
|
||||
}
|
||||
|
||||
export async function editRentProperty(id, data) {
|
||||
const pi = data?.propertyInformation || {};
|
||||
const images = Array.isArray(pi.images) ? pi.images : [];
|
||||
|
||||
const body = {
|
||||
propertyInformation: {
|
||||
activityStatus: 1,
|
||||
images: images.length > 0 ? images : [""],
|
||||
cordsX: pi.cordsX ?? null,
|
||||
cordsY: pi.cordsY ?? null,
|
||||
address: pi.address ?? "",
|
||||
description: pi.description ?? null,
|
||||
numberOfBathRooms: pi.numberOfBathRooms ?? 0,
|
||||
numberOfRooms: pi.numberOfRooms ?? 0,
|
||||
numberOfBedRooms: pi.numberOfBedRooms ?? 0,
|
||||
space: pi.space ?? 0,
|
||||
detailsJSON: pi.detailsJSON ?? "",
|
||||
buildingType: pi.buildingType ?? 0,
|
||||
status: pi.status ?? 0,
|
||||
propertyType: pi.propertyType ?? 0,
|
||||
city: data.city ?? pi.city ?? 1,
|
||||
},
|
||||
deposit: data.deposit ?? 0,
|
||||
acceptedCertificate: data.acceptedCertificate ?? 0,
|
||||
monthlyRent: data.monthlyRent ?? 0,
|
||||
dailyRent: data.dailyRent ?? 0,
|
||||
rating: data.rating ?? 1,
|
||||
currencyId: data.currencyId ?? 1,
|
||||
rentType: data.rentType ?? 0,
|
||||
isSmokeAllow: data.isSmokeAllow ?? false,
|
||||
specializedFor: data.specializedFor ?? false,
|
||||
isVisitorAllow: data.isVisitorAllow ?? false,
|
||||
allowedPaymentPeriod: data.allowedPaymentPeriod ?? "",
|
||||
type: data.type ?? 0,
|
||||
};
|
||||
|
||||
return apiFetch(`/RentProperties/EditRentProperty/${id}`, {
|
||||
method: "PUT",
|
||||
body: buildRentPropertyPayload(data),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
@ -346,9 +348,10 @@ export async function editSaleProperty(id, data) {
|
||||
}
|
||||
|
||||
export async function addSaleProperty(data) {
|
||||
|
||||
return apiFetch("/SaleProperties/AddSaleProperty", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
body: data ,
|
||||
});
|
||||
}
|
||||
|
||||
@ -417,7 +420,6 @@ export async function uploadPicture(file) {
|
||||
|
||||
async function multipartAuthFetch(endpoint, formData) {
|
||||
const token = AuthService.getToken();
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@ -448,46 +450,28 @@ async function multipartAuthFetch(endpoint, formData) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function addOwner(
|
||||
data,
|
||||
frontImage = null,
|
||||
backImage = null,
|
||||
licenseImage = null,
|
||||
) {
|
||||
export async function addOwner(data, frontImage = null, backImage = null, licenseImage = null) {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("FirstName", data.firstName || data.FirstName || "");
|
||||
formData.append("LastName", data.lastName || data.LastName || "");
|
||||
formData.append("Email", data.email || data.Email || "");
|
||||
|
||||
const phoneValue =
|
||||
data.phone || data.phoneNumber || data.Phone || data.PhoneNumber || "";
|
||||
const whatsappValue =
|
||||
data.whatsAppNumber ||
|
||||
data.whatsapp ||
|
||||
data.WhatsAppNumber ||
|
||||
data.WhatsApp ||
|
||||
"";
|
||||
const phoneValue = data.phone || data.phoneNumber || data.Phone || data.PhoneNumber || "";
|
||||
const whatsappValue = data.whatsAppNumber || data.whatsapp || data.WhatsAppNumber || data.WhatsApp || "";
|
||||
|
||||
formData.append("PhoneNumber", phoneValue);
|
||||
formData.append("Phone", phoneValue);
|
||||
formData.append("WhatsAppNumber", whatsappValue);
|
||||
|
||||
formData.append(
|
||||
"NationalNumber",
|
||||
data.nationalNumber || data.NationalNumber || "",
|
||||
);
|
||||
formData.append("NationalNumber", data.nationalNumber || data.NationalNumber || "");
|
||||
formData.append("Password", data.password || data.Password || "");
|
||||
formData.append(
|
||||
"Type",
|
||||
String(data.type ?? data.ownerType ?? data.Type ?? 0),
|
||||
);
|
||||
formData.append("Type", String(data.type ?? data.ownerType ?? data.Type ?? 0));
|
||||
formData.append("Language", String(data.language ?? data.Language ?? 1));
|
||||
|
||||
if (frontImage) formData.append("FrontIdCarImagePath", frontImage);
|
||||
if (backImage) formData.append("RearIdCarImagePath", backImage);
|
||||
if (licenseImage) formData.append("LicenseImagePath", licenseImage);
|
||||
|
||||
if (licenseImage) formData.append("RearIdCarImagePath", licenseImage);
|
||||
return multipartAuthFetch("/Owner/Add", formData);
|
||||
}
|
||||
|
||||
@ -538,20 +522,12 @@ export async function sendPhoneOTP() {
|
||||
|
||||
export async function verifyEmail(code) {
|
||||
const token = AuthService.getToken();
|
||||
return authFetch(
|
||||
`/Auth/VerifyEmail?code=${encodeURIComponent(code)}`,
|
||||
{},
|
||||
token,
|
||||
);
|
||||
return authFetch(`/Auth/VerifyEmail?code=${encodeURIComponent(code)}`, {}, token);
|
||||
}
|
||||
|
||||
export async function verifyPhone(code) {
|
||||
const token = AuthService.getToken();
|
||||
return authFetch(
|
||||
`/Auth/VerifyPhoneNumber?code=${encodeURIComponent(code)}`,
|
||||
{},
|
||||
token,
|
||||
);
|
||||
return authFetch(`/Auth/VerifyPhoneNumber?code=${encodeURIComponent(code)}`, {}, token);
|
||||
}
|
||||
|
||||
export function isEmail(value) {
|
||||
@ -587,14 +563,9 @@ export async function confirmDepositPayment(bookingId) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminConfirmDeposit(
|
||||
reservationId,
|
||||
adminId,
|
||||
comment = null,
|
||||
) {
|
||||
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
|
||||
const token = AuthService.getToken();
|
||||
const normalizedComment =
|
||||
typeof comment === "string" && comment.trim() ? comment.trim() : null;
|
||||
const normalizedComment = typeof comment === "string" && comment.trim() ? comment.trim() : null;
|
||||
const payload = {
|
||||
reservationId,
|
||||
adminId,
|
||||
@ -622,8 +593,7 @@ export async function adminConfirmDeposit(
|
||||
data = text;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof data === "object" && data?.message ? data.message : null;
|
||||
const message = typeof data === "object" && data?.message ? data.message : null;
|
||||
|
||||
return { status: res.status, data, ok: res.ok, message };
|
||||
}
|
||||
@ -663,15 +633,9 @@ export async function getMyTransaction() {
|
||||
export async function payDeposit(data) {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
"ReservationId",
|
||||
String(data.reservationId ?? data.ReservationId ?? "")
|
||||
);
|
||||
formData.append("ReservationId", String(data.reservationId ?? data.ReservationId ?? ""));
|
||||
|
||||
formData.append(
|
||||
"PaymentTypeId",
|
||||
String(data.paymentTypeId ?? data.PaymentTypeId ?? "")
|
||||
);
|
||||
formData.append("PaymentTypeId", String(data.paymentTypeId ?? data.PaymentTypeId ?? ""));
|
||||
|
||||
if (data.comment) {
|
||||
formData.append("Comment", data.comment);
|
||||
@ -680,7 +644,6 @@ export async function payDeposit(data) {
|
||||
}
|
||||
|
||||
if (data.paymentImage) {
|
||||
formData.append("TransactionType", data.paymentImage);
|
||||
formData.append("paymentImage", data.paymentImage);
|
||||
}
|
||||
|
||||
@ -691,9 +654,7 @@ export async function payDeposit(data) {
|
||||
}
|
||||
|
||||
export async function getOwnerContactInformation(propertyInformationId) {
|
||||
return apiFetch(
|
||||
`/Owner/GetOwnerContactInformation?propertyInformationId=${propertyInformationId}`,
|
||||
);
|
||||
return apiFetch(`/Owner/GetOwnerContactInformation?propertyInformationId=${propertyInformationId}`);
|
||||
}
|
||||
|
||||
export async function getOwnerStatistics() {
|
||||
@ -732,12 +693,9 @@ export async function registerRealEstateAgent(formData) {
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword, newPassword) {
|
||||
return apiFetch(
|
||||
`/User/ChangePassword?oldPassword=${encodeURIComponent(oldPassword)}&newPassword=${encodeURIComponent(newPassword)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
return apiFetch(`/User/ChangePassword?oldPassword=${encodeURIComponent(oldPassword)}&newPassword=${encodeURIComponent(newPassword)}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestForgetPasswordOtp(email) {
|
||||
@ -747,12 +705,9 @@ export async function requestForgetPasswordOtp(email) {
|
||||
}
|
||||
|
||||
export async function verifyForgetPasswordOtp(email, code, newPassword) {
|
||||
return apiFetch(
|
||||
`/User/VerifyForgetPasswordOTP?email=${encodeURIComponent(email)}&code=${encodeURIComponent(code)}&newPassword=${encodeURIComponent(newPassword)}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
return apiFetch(`/User/VerifyForgetPasswordOTP?email=${encodeURIComponent(email)}&code=${encodeURIComponent(code)}&newPassword=${encodeURIComponent(newPassword)}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetPassword(token) {
|
||||
@ -760,12 +715,9 @@ export async function resetPassword(token) {
|
||||
}
|
||||
|
||||
export async function deleteMyAccount(password) {
|
||||
return apiFetch(
|
||||
`/User/DeleteMyAccount?password=${encodeURIComponent(password)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
return apiFetch(`/User/DeleteMyAccount?password=${encodeURIComponent(password)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function setFCMToken(token, deviceType = 2) {
|
||||
@ -781,9 +733,7 @@ export async function filterRentProperties(params = {}) {
|
||||
if (v != null && v !== "") qs.set(k, v);
|
||||
});
|
||||
const query = qs.toString();
|
||||
return apiFetch(
|
||||
`/RentProperties/FilterRentProperties${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return apiFetch(`/RentProperties/FilterRentProperties${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function sendGeneralReport(subject, reportBody) {
|
||||
|
||||
@ -38,8 +38,7 @@ export async function requestNotificationPermission() {
|
||||
);
|
||||
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey:
|
||||
"BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
vapidKey:"BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
|
||||
|
||||
49
app/validations/OwnerValidatoin.js
Normal file
49
app/validations/OwnerValidatoin.js
Normal file
@ -0,0 +1,49 @@
|
||||
// utils/registerValidation.js
|
||||
|
||||
const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email?.trim());
|
||||
|
||||
// يدعم الأرقام مع البادئات اختياريًا
|
||||
const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone?.trim());
|
||||
|
||||
export const validateStep1 = (formData, setErrors, t) => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.firstName?.trim()) newErrors.firstName = t("register.firstNameRequired");
|
||||
if (!formData.lastName?.trim()) newErrors.lastName = t("register.lastNameRequired");
|
||||
|
||||
if (!formData.email?.trim()) {
|
||||
newErrors.email = t("register.emailRequired");
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = t("register.emailInvalid");
|
||||
}
|
||||
|
||||
|
||||
if (!formData.whatsapp?.trim()) {
|
||||
newErrors.whatsapp = t("register.whatsappRequired");
|
||||
} else if (!validatePhone(formData.whatsapp)) {
|
||||
newErrors.whatsapp = `${t("register.phoneInvalid")} (${t("register.phoneMustStartWith")} 09/05)`;
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = t("register.passwordRequired");
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t("validation.passwordMinLength");
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = t("validation.passwordsMatch");
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
export const validateStep2 = (idImages, setErrors, t) => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!idImages?.front) newErrors.front = t("register.frontIdRequired");
|
||||
if (!idImages?.back) newErrors.back = t("register.backIdRequired");
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user