Compare commits
64 Commits
d375ed9d89
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97126c5776 | |||
| 1e167c447a | |||
| dd0a9c401d | |||
| 32f6c7af5a | |||
| 7e0d5eaf8d | |||
| beccd8b24f | |||
| 7e9a9d79f2 | |||
| 39f494aecb | |||
| 485baffdc2 | |||
| c46173d7c6 | |||
| 04fa34107b | |||
| 5a7d0ef265 | |||
| 0ba435fd7e | |||
| 9c2a748ae9 | |||
| db949aaeba | |||
| ae600ad41b | |||
| 16b1c7c6f6 | |||
| f761ab6f48 | |||
| 78138e6445 | |||
| 0891974440 | |||
| f925af0272 | |||
| 2346f518ce | |||
| 149058ddfc | |||
| e6249e845e | |||
| 3bdb99f2e5 | |||
| 2a1f00740f | |||
| 50836d3ec7 | |||
| d850f921b5 | |||
| 77dd052951 | |||
| 1207dbe20d | |||
| c9f52f64cb | |||
| 5fd22f0e01 | |||
| 2998a6bd75 | |||
| 571c85f14f | |||
| 9e1f8f517b | |||
| 700b446463 | |||
| be14250a08 | |||
| eec7a9a75d | |||
| 4ca7106b48 | |||
| 7685134a39 | |||
| 6ad2457e74 | |||
| 98c3f51df2 | |||
| 5d44fb56ec | |||
| ba389042c2 | |||
| c546e11ed3 | |||
| 8d7efe82a4 | |||
| 52758eae9d | |||
| a824fb0c7c | |||
| 9e87aa90e8 | |||
| 199e78d6b1 | |||
| df9711f539 | |||
| 2bea2d190c | |||
| 81674c4aa7 | |||
| cf7f51b514 | |||
| 0171c7a2bf | |||
| 9f6a730a94 | |||
| 2c04cd751f | |||
| db184bbace | |||
| 230805e02b | |||
| 3b9831a513 | |||
| 1f40c6a4fd | |||
| 4dd60ec14a | |||
| 68cb802d60 | |||
| d22248248d |
@ -5,6 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
|
||||
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
|
||||
import FloatingSidebar from '@/app/components/FloatingSidebar';
|
||||
import {
|
||||
Globe,
|
||||
LogIn,
|
||||
@ -39,6 +42,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import AuthService from "./services/AuthService";
|
||||
import { UserRole, UserRoleLabels } from "./enums/UserRole";
|
||||
import "./i18n/config";
|
||||
import NotificationHandler from "./components/NotificationHandler";
|
||||
|
||||
export default function ClientLayout({ children }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@ -245,18 +249,18 @@ export default function ClientLayout({ children }) {
|
||||
عقاراتي
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/bookings">
|
||||
<NavLink href="/owner/reservations">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
الحجوزات
|
||||
</span>
|
||||
</NavLink>
|
||||
<NavLink href="/owner/calendar">
|
||||
{/* <NavLink href="/owner/calendar">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
التقويم
|
||||
</span>
|
||||
</NavLink>
|
||||
</NavLink> */}
|
||||
<NavLink href="/owner/profits">
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
@ -395,7 +399,7 @@ export default function ClientLayout({ children }) {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/owner/bookings"
|
||||
href="/owner/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
@ -519,7 +523,7 @@ export default function ClientLayout({ children }) {
|
||||
<div className="border-t border-gray-100 my-2"></div>
|
||||
|
||||
<Link
|
||||
href="/tenant/bookings"
|
||||
href="/reservations"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
@ -647,7 +651,7 @@ export default function ClientLayout({ children }) {
|
||||
</span>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/owner/bookings"
|
||||
href="/owner/reservations"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@ -705,7 +709,12 @@ export default function ClientLayout({ children }) {
|
||||
<main
|
||||
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{children}
|
||||
<NotificationsProvider>
|
||||
<FavoritesProvider>
|
||||
{children}
|
||||
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
|
||||
</FavoritesProvider>
|
||||
</NotificationsProvider>
|
||||
</main>
|
||||
|
||||
{!isAuthPage && !isProfilePage && (
|
||||
@ -776,7 +785,7 @@ export default function ClientLayout({ children }) {
|
||||
<ul className="space-y-3 text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="w-5 h-5" />
|
||||
<span>{t("phone")}</span>
|
||||
<span dir="ltr" className="text-right">{t("phone")}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
@ -793,6 +802,7 @@ export default function ClientLayout({ children }) {
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
<NotificationHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
113
app/admin/add-admin/page.js
Normal file
113
app/admin/add-admin/page.js
Normal file
@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AddAdminPage() {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [formState, setFormState] = useState({ fullName: '', email: '', password: '' });
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleChange = (field) => (event) => {
|
||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
setSaved(true);
|
||||
console.log('Add admin payload', formState);
|
||||
};
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mt-3">إضافة مدير جديد</h1>
|
||||
<p className="text-slate-500 mt-2">انشئ حساب مسؤول جديد مع صلاحيات الإدارة.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[1.5fr_0.8fr]">
|
||||
<section className="rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<h2 className="text-xl font-semibold mb-6">بيانات المدير</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">الاسم الكامل</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.fullName}
|
||||
onChange={handleChange('fullName')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="مثال: محمد الأحمد"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">البريد الإلكتروني</span>
|
||||
<input
|
||||
type="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange('email')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700">كلمة المرور</span>
|
||||
<input
|
||||
type="password"
|
||||
value={formState.password}
|
||||
onChange={handleChange('password')}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="inline-flex items-center justify-center rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||
حفظ المدير الجديد
|
||||
</button>
|
||||
</form>
|
||||
{saved && (
|
||||
<div className="mt-6 rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||
تم حفظ بيانات المدير بنجاح
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
85
app/admin/privacy/page.js
Normal file
85
app/admin/privacy/page.js
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
const initialPolicy = `1. نحترم خصوصيتك ونلتزم بحماية بياناتك الشخصية.
|
||||
2. يتم استخدام المعلومات لتحسين تجربة المستخدم وتأمين الخدمة.
|
||||
3. لا نشارك البيانات مع أطراف خارجية بدون موافقتك.
|
||||
4. يمكنك طلب حذف بياناتك من النظام في أي وقت.`;
|
||||
|
||||
export default function PrivacyPolicyAdminPage() {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [policyText, setPolicyText] = useState(initialPolicy);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||
setChecked(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = (event) => {
|
||||
event.preventDefault();
|
||||
setSaved(true);
|
||||
console.log('Privacy policy updated:', policyText);
|
||||
};
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||
<p className="text-gray-600 mb-6">هذه الصفحة لتحرير سياسة الخصوصية ولا يمكن الوصول إليها إلا للمدير.</p>
|
||||
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||
<p className="text-slate-500 mt-2">قم بتحديث نص سياسة الخصوصية</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-6 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">نص سياسة الخصوصية</label>
|
||||
<textarea
|
||||
value={policyText}
|
||||
onChange={(e) => setPolicyText(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4 text-slate-700 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<button type="submit" className="rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||
حفظ السياسة
|
||||
</button>
|
||||
</div>
|
||||
{saved && (
|
||||
<div className="rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||
تمت حفظ سياسة الخصوصية بنجاح
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
190
app/components/FloatingSidebar.js
Normal file
190
app/components/FloatingSidebar.js
Normal file
@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { Heart, Bell, CreditCard, Shield, UserPlus } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
export default function FloatingSidebar({ isRTL, isAdmin }) {
|
||||
const { favorites } = useFavorites();
|
||||
const { unreadCount } = useNotifications();
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
let timeoutId = null;
|
||||
|
||||
const showTooltip = (id) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
setTooltip(id);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(timeoutId);
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const side = isRTL ? 'left' : 'right';
|
||||
const positionStyle = {
|
||||
[side]: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
initial: { opacity: 0, x: isRTL ? -20 : 20 },
|
||||
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: 'easeOut' } },
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
rest: { scale: 1, backgroundColor: 'rgba(255,255,255,0)' },
|
||||
hover: { scale: 1.05, backgroundColor: 'rgba(245,158,11,0.1)', transition: { duration: 0.2 } },
|
||||
tap: { scale: 0.95 },
|
||||
};
|
||||
|
||||
const renderTooltip = (id, label) => {
|
||||
if (tooltip !== id) return null;
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||
>
|
||||
<span className="relative">
|
||||
{label}
|
||||
<span
|
||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||
}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed z-50"
|
||||
style={positionStyle}
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/60 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/95">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('addAdmin')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/admin/add-admin"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl bg-amber-50 border border-amber-200 text-amber-600 hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-6 h-6" />
|
||||
</Link>
|
||||
{renderTooltip('addAdmin', 'إضافة أدمن')}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('editPrivacy')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/admin/privacy"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Shield className="w-6 h-6" />
|
||||
</Link>
|
||||
{renderTooltip('editPrivacy', 'تعديل سياسة الخصوصية')}
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('favorites')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/favorites"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
{favorites.length > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
{favorites.length}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('favorites', 'المفضلة')}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('notifications')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||
>
|
||||
{unreadCount}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{renderTooltip('notifications', 'الإشعارات')}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={buttonVariants}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onMouseEnter={() => showTooltip('payments')}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
<Link
|
||||
href="/payments"
|
||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||
>
|
||||
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||
</Link>
|
||||
{renderTooltip('payments', 'المدفوعات')}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
165
app/components/NotificationHandler.js
Normal file
165
app/components/NotificationHandler.js
Normal file
@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { initializeApp, getApps } from "firebase/app";
|
||||
import { getMessaging, getToken, onMessage } from "firebase/messaging";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
export default function NotificationHandler() {
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
function checkAuth() {
|
||||
if (initialized.current) return;
|
||||
|
||||
if (!AuthService.getToken()) return;
|
||||
initialized.current = true;
|
||||
|
||||
if ("Notification" in window) {
|
||||
if (Notification.permission === "default") {
|
||||
setShowPrompt(true);
|
||||
} else if (Notification.permission === "granted") {
|
||||
setupFCM();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Also check when auth token changes (login via client-side navigation)
|
||||
const interval = setInterval(() => {
|
||||
if (!initialized.current && AuthService.getToken()) {
|
||||
checkAuth();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Check on route change (visibility)
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") checkAuth();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function setupFCM() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
|
||||
const messaging = getMessaging(app);
|
||||
|
||||
const fcmToken = await getToken(messaging, {
|
||||
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
|
||||
if (fcmToken) {
|
||||
console.log("[FCM] Token:", fcmToken.substring(0, 20) + "...");
|
||||
|
||||
const authToken = AuthService.getToken();
|
||||
if (authToken) {
|
||||
const apiBase = "https://45.93.137.91.nip.io/api";
|
||||
await fetch(`${apiBase}/User/SetFCMToken`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token: fcmToken, deviceType: 2 }),
|
||||
});
|
||||
console.log("[FCM] Token sent to backend");
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(messaging, (payload) => {
|
||||
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
|
||||
const body = payload.notification?.body || payload.data?.body || "";
|
||||
setNotification({ title, body });
|
||||
setTimeout(() => setNotification(null), 5000);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[FCM] Setup error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable() {
|
||||
setShowPrompt(false);
|
||||
|
||||
// This MUST be synchronous from a user gesture
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log("[FCM] Permission result:", permission);
|
||||
|
||||
if (permission === "granted") {
|
||||
await setupFCM();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPrompt && (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">🔔</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-gray-900 text-sm">تفعيل الإشعارات</p>
|
||||
<p className="text-gray-600 text-sm mt-0.5">اسمح بالإشعارات للبقاء على اطلاع بحجوزاتك وعروضنا.</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تفعيل
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPrompt(false)}
|
||||
className="px-4 py-1.5 text-gray-500 text-sm hover:text-gray-700 transition-colors"
|
||||
>
|
||||
لاحقاً
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification && (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999] animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">🏠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-gray-900 text-sm">{notification.title}</p>
|
||||
<p className="text-gray-600 text-sm mt-0.5">{notification.body}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className="text-gray-400 hover:text-gray-600 flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
||||
import { CommissionType, City, CitiesList } from '@/app/enums';
|
||||
import { CommissionType, CitiesList } from '@/app/enums';
|
||||
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
||||
|
||||
export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
@ -131,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||
required
|
||||
>
|
||||
<option value="">اختر المدينة</option>
|
||||
{Object.values(CITIES).map(city => (
|
||||
{CitiesList.map(city => (
|
||||
<option key={city} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
|
||||
import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
|
||||
|
||||
export default function HeroSearch({ onSearch }) {
|
||||
export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('rent');
|
||||
const [activeTab, setActiveTab] = useState('buy');
|
||||
const [filters, setFilters] = useState({
|
||||
city: '',
|
||||
propertyType: '',
|
||||
priceRange: '',
|
||||
identityType: 'syrian'
|
||||
city: 'all',
|
||||
propertyType: 'all',
|
||||
priceRange: 'all',
|
||||
identityType: 'syrian',
|
||||
ownerSource: 'all',
|
||||
rentPeriod: 'all',
|
||||
availableToday: false
|
||||
});
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
|
||||
const cities = [
|
||||
{ id: 'all', label: 'جميع المدن' },
|
||||
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
|
||||
|
||||
const propertyTypes = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'apartment', label: 'شقة' },
|
||||
{ id: 'villa', label: 'فيلا' },
|
||||
{ id: 'house', label: 'بيت' },
|
||||
{ id: 'studio', label: 'استوديو' }
|
||||
{ id: 'apartment', label: 'شقق سكنية' },
|
||||
{ id: 'studio', label: 'استوديو' },
|
||||
{ id: 'commercial', label: 'عقار تجاري' },
|
||||
{ id: 'villa', label: 'فيلا / مزرعة' }
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
@ -46,17 +51,45 @@ export default function HeroSearch({ onSearch }) {
|
||||
{ id: 'passport', label: 'جواز سفر' }
|
||||
];
|
||||
|
||||
const ownerSources = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'owner', label: 'من المالك' },
|
||||
{ id: 'agency', label: 'من مكتب عقاري' }
|
||||
];
|
||||
|
||||
const rentPeriods = [
|
||||
{ id: 'all', label: 'الكل' },
|
||||
{ id: 'daily', label: 'إيجار يومي' },
|
||||
{ id: 'monthly', label: 'إيجار شهري' }
|
||||
];
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch({
|
||||
...filters,
|
||||
propertyType: filters.propertyType || 'all',
|
||||
mode: activeTab,
|
||||
city: filters.city || 'all',
|
||||
priceRange: filters.priceRange || 'all'
|
||||
propertyType: filters.propertyType || 'all',
|
||||
priceRange: filters.priceRange || 'all',
|
||||
ownerSource: filters.ownerSource || 'all',
|
||||
rentPeriod: filters.rentPeriod || 'all'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<>
|
||||
<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 }}
|
||||
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
|
||||
{['rent', 'buy', 'sell'].map((tab) => (
|
||||
<motion.button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||
activeTab === tab
|
||||
? 'bg-amber-500 text-white'
|
||||
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
|
||||
<select
|
||||
value={filters.ownerSource}
|
||||
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{ownerSources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
|
||||
<select
|
||||
value={filters.rentPeriod}
|
||||
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left 1rem center',
|
||||
backgroundSize: '1rem',
|
||||
paddingLeft: '2.5rem'
|
||||
}}
|
||||
>
|
||||
{rentPeriods.map((period) => (
|
||||
<option key={period.id} value={period.id}>
|
||||
{period.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
|
||||
<label className="mt-4 flex items-center gap-3 text-white text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.availableToday}
|
||||
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||
/>
|
||||
<span className="font-medium">عرض فقط العقارات المتاحة من اليوم</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<motion.button
|
||||
onClick={handleSearch}
|
||||
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showLoginDialog && !isAuthenticated && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<ShieldCheck className="w-7 h-7 text-amber-500" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
|
||||
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
216
app/components/ratings/RatingForm.js
Normal file
216
app/components/ratings/RatingForm.js
Normal file
@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star, Edit2, X, Check, Clock } from 'lucide-react';
|
||||
import StarRating from './StarRating.js';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { rateProperty, rateCustomer, getUserPropertyRating, canRateProperty } from '../../utils/ratings.js';
|
||||
|
||||
const RatingForm = ({
|
||||
propertyId,
|
||||
userId,
|
||||
propertyOwner = false,
|
||||
initialRating = 0,
|
||||
initialComment = '',
|
||||
onSubmitSuccess
|
||||
}) => {
|
||||
const [rating, setRating] = useState(initialRating);
|
||||
const [comment, setComment] = useState(initialComment);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [userRating, setUserRating] = useState(null);
|
||||
|
||||
// Check if user has already rated
|
||||
useState(() => {
|
||||
async function fetchUserRating() {
|
||||
try {
|
||||
const rating = await getUserPropertyRating(propertyId, userId);
|
||||
if (rating) {
|
||||
setUserRating(rating);
|
||||
setRating(rating.rating);
|
||||
setComment(rating.comment || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RatingForm] Failed to fetch user rating:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (propertyId && userId) {
|
||||
fetchUserRating();
|
||||
}
|
||||
}, [propertyId, userId]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!rating) {
|
||||
toast.error('يرجى إعطاء تقييم من 1 إلى 5 نجوم');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const ratingData = {
|
||||
propertyId,
|
||||
customerId: userId,
|
||||
rating,
|
||||
comment: comment.trim() || null
|
||||
};
|
||||
|
||||
await rateProperty(ratingData);
|
||||
|
||||
toast.success('تم إرسال التقييم بنجاح!');
|
||||
|
||||
// Reset form
|
||||
setRating(0);
|
||||
setComment('');
|
||||
setShowForm(false);
|
||||
|
||||
if (onSubmitSuccess) {
|
||||
onSubmitSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RatingForm] Failed to submit rating:', error);
|
||||
toast.error('حدث خطأ أثناء إرسال التقييم. يرجى المحاولة مرة أخرى.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setShowForm(true);
|
||||
setRating(userRating?.rating || 0);
|
||||
setComment(userRating?.comment || '');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setRating(userRating?.rating || 0);
|
||||
setComment(userRating?.comment || '');
|
||||
};
|
||||
|
||||
if (!propertyId || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
{/* Display existing rating */}
|
||||
{userRating && !showForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gray-50 rounded-xl p-4 border border-gray-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-500" />
|
||||
<span className="font-medium text-gray-900">{userRating.rating}</span>
|
||||
<span className="text-sm text-gray-500">من 5</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm hover:bg-gray-200 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
تعديل
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{userRating.comment && (
|
||||
<div className="text-gray-600 text-sm mb-3 line-clamp-3">
|
||||
"{userRating.comment}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{userRating.createdAt ? new Date(userRating.createdAt).toLocaleDateString('ar-SA') : ''}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Rating form */}
|
||||
{showForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-xl p-6 border border-gray-200 shadow-sm"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تقييمك للعقار
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating
|
||||
rating={rating}
|
||||
onRatingChange={setRating}
|
||||
readOnly={false}
|
||||
size={28}
|
||||
color="#ffc107"
|
||||
/>
|
||||
<span className="text-lg font-bold text-gray-900">{rating || '1'}</span>
|
||||
<span className="text-sm text-gray-400">/5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تعليق (اختياري)
|
||||
</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="شارك تجربتك مع العقار..."
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !rating}
|
||||
className="flex-1 px-4 py-2 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Check className="w-5 h-5" />
|
||||
)}
|
||||
{loading ? 'إرسال' : 'إرسال التقييم'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Add rating button */}
|
||||
{!userRating && !showForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-amber-50 border-2 border-amber-200 rounded-xl p-4 text-center cursor-pointer hover:border-amber-300 transition-all"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
<Star className="w-8 h-8 text-amber-500 mx-auto mb-2" />
|
||||
<h3 className="font-bold text-amber-700 mb-2">قيّم هذا العقار</h3>
|
||||
<p className="text-sm text-amber-600">شارك تجربتك مع المستأجرين الآخرين</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingForm;
|
||||
149
app/components/ratings/RatingList.js
Normal file
149
app/components/ratings/RatingList.js
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star } from 'lucide-react';
|
||||
import { getPropertyRatings } from '../../utils/ratings.js';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
const RatingList = ({ propertyId, userId }) => {
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReviews = async () => {
|
||||
if (!propertyId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getPropertyReviews(propertyId);
|
||||
setReviews(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[RatingList] Failed to fetch reviews:', err);
|
||||
setError('فشل تحميل التقييمات');
|
||||
setReviews([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReviews();
|
||||
}, [propertyId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<div className="w-10 h-10 border-2 border-gray-200 border-t-gray-500 rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500">جاري تحميل التقييمات...</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<p className="text-red-500">{error}</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reviews.length === 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<p className="text-gray-500">لا توجد تقييمات حتى الآن. كن أول من يقيم هذا العقار!</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate average rating
|
||||
const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Header with average rating */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">تقييمات المستأجرين</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{reviews.length} تقييمات
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`w-5 h-5 ${index < Math.floor(averageRating) ? 'text-amber-500' : 'text-gray-300'}`}
|
||||
/>
|
||||
))}
|
||||
{averageRating % 1 !== 0 && (
|
||||
<Star className="w-5 h-5 text-amber-400" />
|
||||
)}
|
||||
<span className="font-bold text-gray-900 ml-2">{averageRating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews list */}
|
||||
<div className="space-y-4">
|
||||
{reviews.map((review, index) => (
|
||||
<div key={index} className="border-t border-gray-100 pt-4 first:border-t-0 first:pt-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Star className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{review.userName || 'مستأجر'}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm">
|
||||
{Array.from({ length: 5 }).map((_, starIndex) => (
|
||||
<Star
|
||||
key={starIndex}
|
||||
className={`w-4 h-4 ${starIndex < review.rating ? 'text-amber-500' : 'text-gray-300'}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-1 text-xs text-gray-500">({review.rating}/5)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{review.createdAt ? new Date(review.createdAt).toLocaleDateString('ar-SA') : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{review.comment && (
|
||||
<p className="text-gray-700 text-sm leading-relaxed">{review.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingList;
|
||||
90
app/components/ratings/StarRating.js
Normal file
90
app/components/ratings/StarRating.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const StarRating = ({
|
||||
rating,
|
||||
onRatingChange,
|
||||
maxStars = 5,
|
||||
size = 24,
|
||||
color = '#ffc107',
|
||||
readOnly = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const [hoverRating, setHoverRating] = useState(null);
|
||||
|
||||
const handleClick = (value) => {
|
||||
if (!readOnly && onRatingChange) {
|
||||
onRatingChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (value) => {
|
||||
if (!readOnly) {
|
||||
setHoverRating(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!readOnly) {
|
||||
setHoverRating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStarIcon = (index) => {
|
||||
const currentRating = hoverRating !== null ? hoverRating : rating;
|
||||
|
||||
if (currentRating > index) {
|
||||
const hasHalfStar = currentRating % 1 > 0.5 && index + 0.5 <= currentRating;
|
||||
if (hasHalfStar) {
|
||||
// For half star, we'll use a combination approach or just show full star
|
||||
// Since we don't have StarOutline, we'll approximate with full stars
|
||||
return <Star className={`w-${size} h-${size} text-${color}`} />;
|
||||
}
|
||||
return <Star className={`w-${size} h-${size} text-${color}`} />;
|
||||
}
|
||||
return <Star className={`w-${size} h-${size} text-gray-400`} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex gap-1 ${className}`} onMouseLeave={handleMouseLeave}>
|
||||
{[...Array(maxStars)].map((_, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
whileHover={{ scale: readOnly ? 1 : 1.1 }}
|
||||
onClick={() => handleClick(index + 1)}
|
||||
onMouseEnter={() => handleMouseEnter(index + 1)}
|
||||
>
|
||||
{getStarIcon(index)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StarRating;
|
||||
|
||||
// Helper functions
|
||||
export function getStarCount(rating, maxStars = 5) {
|
||||
return Math.round(rating * maxStars) / maxStars;
|
||||
}
|
||||
|
||||
export function formatRating(rating) {
|
||||
if (rating === 0) return 'لا يوجد تقييم';
|
||||
return `${rating.toFixed(1)}`; // Show 1 decimal place
|
||||
}
|
||||
|
||||
export function getRatingColor(rating) {
|
||||
if (rating >= 4.5) return 'text-green-600';
|
||||
if (rating >= 3.5) return 'text-yellow-600';
|
||||
if (rating >= 2.5) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
export function getRatingText(rating) {
|
||||
if (rating >= 4.5) return 'ممتاز';
|
||||
if (rating >= 3.5) return 'جيد جداً';
|
||||
if (rating >= 2.5) return 'جيد';
|
||||
if (rating >= 1.5) return 'مقبول';
|
||||
return 'ضعيف';
|
||||
}
|
||||
123
app/contexts/FavoritesContext.js
Normal file
123
app/contexts/FavoritesContext.js
Normal file
@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getUserFavoriteProperties, addFavoriteProperty, removeFavoriteProperty } from '../utils/api';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const FavoritesContext = createContext();
|
||||
|
||||
export const useFavorites = () => {
|
||||
const context = useContext(FavoritesContext);
|
||||
if (!context) {
|
||||
throw new Error('useFavorites must be used within FavoritesProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
function mapApiFavorite(item) {
|
||||
const info = item.propertyInformation || {};
|
||||
let details = {};
|
||||
try { details = JSON.parse(info.detailsJSON || '{}'); } catch {}
|
||||
|
||||
const price = item.monthlyRent || item.dailyRent || 0;
|
||||
const priceUnit = item.monthlyRent ? 'monthly' : 'daily';
|
||||
const buildingType = info.buildingType ?? 0;
|
||||
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
|
||||
const address = info.address || '';
|
||||
const addressParts = address.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const images = info.images || [];
|
||||
const resolvedImages = images.map(img => {
|
||||
if (!img) return '';
|
||||
if (img.startsWith('http')) return img;
|
||||
return `http://45.93.137.91${img.startsWith('/') ? '' : '/'}${img}`;
|
||||
});
|
||||
|
||||
return {
|
||||
id: info.id || item.propertyInformationId,
|
||||
faveId: item.id, // needed to remove from favorites
|
||||
title: `${typeLabel} في ${addressParts[0] || address}`,
|
||||
type,
|
||||
typeLabel,
|
||||
price,
|
||||
priceUnit,
|
||||
bedrooms: info.numberOfBedRooms || 0,
|
||||
bathrooms: info.numberOfBathRooms || 0,
|
||||
area: info.space || 0,
|
||||
location: {
|
||||
city: addressParts[addressParts.length - 1] || '',
|
||||
district: addressParts[0] || '',
|
||||
},
|
||||
images: resolvedImages,
|
||||
rating: item.rating || 0,
|
||||
deposit: item.deposit || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const FavoritesProvider = ({ children }) => {
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchFavorites = useCallback(async () => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
setFavorites([]);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getUserFavoriteProperties();
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setFavorites(items.map(mapApiFavorite));
|
||||
} catch (err) {
|
||||
console.error('[Favorites] Failed to fetch:', err);
|
||||
setFavorites([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFavorites();
|
||||
}, [fetchFavorites]);
|
||||
|
||||
const addFavorite = async (propId) => {
|
||||
if (!AuthService.isAuthenticated()) return false;
|
||||
try {
|
||||
await addFavoriteProperty(propId);
|
||||
// Refresh to get the full object with faveId
|
||||
await fetchFavorites();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Favorites] Add failed:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeFavorite = async (propId) => {
|
||||
if (!AuthService.isAuthenticated()) return false;
|
||||
const fav = favorites.find(f => f.id === propId);
|
||||
if (!fav) return false;
|
||||
// Optimistic update — remove immediately from UI
|
||||
const previous = [...favorites];
|
||||
setFavorites(prev => prev.filter(f => f.id !== propId));
|
||||
try {
|
||||
await removeFavoriteProperty(fav.faveId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Favorites] Remove failed:', err);
|
||||
// Rollback on failure
|
||||
setFavorites(previous);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isFavorite = (propId) => {
|
||||
return favorites.some(f => f.id === propId);
|
||||
};
|
||||
|
||||
return (
|
||||
<FavoritesContext.Provider value={{ favorites, isLoading, addFavorite, removeFavorite, isFavorite, refreshFavorites: fetchFavorites }}>
|
||||
{children}
|
||||
</FavoritesContext.Provider>
|
||||
);
|
||||
};
|
||||
75
app/contexts/NotificationsContext.js
Normal file
75
app/contexts/NotificationsContext.js
Normal file
@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getUserNotifications } from '../utils/api';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const NotificationsContext = createContext();
|
||||
|
||||
export const useNotifications = () => {
|
||||
const context = useContext(NotificationsContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotifications must be used within NotificationsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export function NotificationsProvider({ children }) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getUserNotifications();
|
||||
const notificationsArray = Array.isArray(data) ? data : [];
|
||||
setNotifications(notificationsArray);
|
||||
// Assuming all are unread for now, or add logic to check 'read' field if exists
|
||||
setUnreadCount(notificationsArray.length);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const markAsRead = useCallback((id) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(() => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationsContext.Provider>
|
||||
);
|
||||
}
|
||||
144
app/favorites/page.js
Normal file
144
app/favorites/page.js
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Heart, MapPin, Bed, Bath, Square, X, ImageIcon } from 'lucide-react';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const router = useRouter();
|
||||
const { favorites, isLoading: favoritesLoading, removeFavorite } = useFavorites();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setIsAdmin(AuthService.isAdmin());
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
if (favoritesLoading && favorites.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">المفضلة</h1>
|
||||
<p className="text-gray-600">العقارات التي قمت بحفظها</p>
|
||||
</div>
|
||||
|
||||
{favorites.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد عقارات في المفضلة</h3>
|
||||
<p className="text-gray-500 mb-6">يمكنك إضافة العقارات التي تعجبك بالنقر على أيقونة القلب</p>
|
||||
<Link
|
||||
href="/properties"
|
||||
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
استعرض العقارات
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{favorites.map((property) => (
|
||||
<motion.div
|
||||
key={property.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-gray-200"
|
||||
>
|
||||
<div className="relative h-48 bg-gray-100">
|
||||
{property.images && property.images[0] ? (
|
||||
<Image
|
||||
src={property.images[0]}
|
||||
alt={property.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFavorite(property.id)}
|
||||
className="absolute top-2 right-2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center hover:bg-red-50 transition-colors shadow-sm"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium">
|
||||
{property.type === 'apartment' ? 'شقة' : property.type === 'villa' ? 'فيلا' : 'بيت'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-xs mb-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="line-clamp-1">
|
||||
{property.location.city}، {property.location.district}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
|
||||
<div className="text-xs text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-600 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bed className="w-4 h-4" />
|
||||
<span>{property.bedrooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{property.bathrooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>{property.area}م²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/property/${property.id}`}
|
||||
className="block w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
عرض التفاصيل
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
@ -18,7 +18,7 @@ import {
|
||||
Shield,
|
||||
Phone,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
} from "lucide-react";
|
||||
import {
|
||||
loginWithEmail,
|
||||
loginWithPhone,
|
||||
@ -30,44 +30,45 @@ import {
|
||||
isPhoneNumber,
|
||||
getOwnerByUserId,
|
||||
getCustomerByUserId,
|
||||
} from '../utils/api';
|
||||
import AuthService from '../services/AuthService';
|
||||
} from "../utils/api";
|
||||
import AuthService from "../services/AuthService";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Step: 'login' | 'otp'
|
||||
const [step, setStep] = useState('login');
|
||||
const [loginMethod, setLoginMethod] = useState('email'); // 'email' | 'phone'
|
||||
const [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: '',
|
||||
credential: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [otpError, setOtpError] = useState('');
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpError, setOtpError] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.credential) {
|
||||
newErrors.credential = loginMethod === 'email'
|
||||
? 'البريد الإلكتروني مطلوب'
|
||||
: 'رقم الهاتف مطلوب';
|
||||
} else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||
newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||
} else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||
newErrors.credential = 'رقم الهاتف غير صالح';
|
||||
newErrors.credential =
|
||||
loginMethod === "email"
|
||||
? "البريد الإلكتروني مطلوب"
|
||||
: "رقم الهاتف مطلوب";
|
||||
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||
newErrors.credential = "رقم الهاتف غير صالح";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'كلمة المرور مطلوبة';
|
||||
newErrors.password = "كلمة المرور مطلوبة";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@ -82,17 +83,25 @@ export default function LoginPage() {
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const loginFn = loginMethod === 'email' ? loginWithEmail : loginWithPhone;
|
||||
console.log('[Login] Attempting login via', loginMethod, ':', formData.credential);
|
||||
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||
console.log(
|
||||
"[Login] Attempting login via",
|
||||
loginMethod,
|
||||
":",
|
||||
formData.credential,
|
||||
);
|
||||
|
||||
const result = await loginFn(formData.credential, formData.password);
|
||||
|
||||
console.log('[Login] Response status:', result.status);
|
||||
console.log("[Login] Response status:", result.status);
|
||||
|
||||
if (result.status === 200) {
|
||||
const token = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken;
|
||||
const token =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
AuthService.addToken(token);
|
||||
console.log('[Login] Token stored');
|
||||
console.log("[Login] Token stored");
|
||||
|
||||
// Fetch user profile to get full name
|
||||
const authUser = AuthService.getUser();
|
||||
@ -103,71 +112,81 @@ export default function LoginPage() {
|
||||
const profile = await fetchFn(authUser.id);
|
||||
if (profile) {
|
||||
AuthService.cacheUser({
|
||||
name: profile.fullName || profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
|
||||
name:
|
||||
profile.fullName ||
|
||||
profile.name ||
|
||||
`${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email: profile.email || authUser.email,
|
||||
phone: profile.phone || profile.phoneNumber || authUser.phone,
|
||||
});
|
||||
console.log('[Login] User profile cached');
|
||||
console.log("[Login] User profile cached");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Login] Failed to fetch profile:', err);
|
||||
console.warn("[Login] Failed to fetch profile:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userRole = AuthService.isAdmin() ? 'admin'
|
||||
: AuthService.isOwner() ? 'owner'
|
||||
: 'customer';
|
||||
console.log('[Login] User role:', userRole);
|
||||
const userRole = AuthService.isAdmin()
|
||||
? "admin"
|
||||
: AuthService.isOwner()
|
||||
? "owner"
|
||||
: "customer";
|
||||
console.log("[Login] User role:", userRole);
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success('تم تسجيل الدخول بنجاح!', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
toast.success("تم تسجيل الدخول بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (userRole === 'admin') {
|
||||
router.push('/admin');
|
||||
if (userRole === "admin") {
|
||||
router.push("/admin");
|
||||
} else {
|
||||
router.push('/');
|
||||
router.push("/");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
} else if (result.status === 206) {
|
||||
console.log('[Login] 206 — OTP required');
|
||||
const tempToken = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken;
|
||||
console.log("[Login] 206 — OTP required");
|
||||
const tempToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (tempToken) {
|
||||
AuthService.addToken(tempToken);
|
||||
console.log('[Login] Temp token stored for OTP');
|
||||
console.log("[Login] Temp token stored for OTP");
|
||||
}
|
||||
toast('يرجى إدخال رمز التحقق', {
|
||||
icon: '🔐',
|
||||
style: { background: '#fef3c7', color: '#92400e' },
|
||||
toast("يرجى إدخال رمز التحقق", {
|
||||
icon: "🔐",
|
||||
style: { background: "#fef3c7", color: "#92400e" },
|
||||
});
|
||||
|
||||
// Send OTP
|
||||
try {
|
||||
if (loginMethod === 'email') {
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
console.log('[Login] OTP sent successfully');
|
||||
console.log("[Login] OTP sent successfully");
|
||||
} catch (otpErr) {
|
||||
console.warn('[Login] OTP send failed, proceeding anyway:', otpErr);
|
||||
console.warn("[Login] OTP send failed, proceeding anyway:", otpErr);
|
||||
}
|
||||
|
||||
setStep('otp');
|
||||
setStep("otp");
|
||||
} else {
|
||||
// Other error
|
||||
console.error('[Login] Unexpected status:', result.status, result.data);
|
||||
toast.error(result.data?.message || result.data || 'بيانات الدخول غير صحيحة', {
|
||||
style: { background: '#fee2e2', color: '#991b1b' },
|
||||
});
|
||||
console.error("[Login] Unexpected status:", result.status, result.data);
|
||||
toast.error(
|
||||
result.data?.message || result.data || "بيانات الدخول غير صحيحة",
|
||||
{
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Error:', err);
|
||||
toast.error(err.message || 'حدث خطأ في الاتصال', {
|
||||
style: { background: '#fee2e2', color: '#991b1b' },
|
||||
console.error("[Login] Error:", err);
|
||||
toast.error(err.message || "حدث خطأ في الاتصال", {
|
||||
style: { background: "#fee2e2", color: "#991b1b" },
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -177,43 +196,46 @@ export default function LoginPage() {
|
||||
const handleVerifyOTP = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
setOtpError('يرجى إدخال رمز التحقق');
|
||||
setOtpError("يرجى إدخال رمز التحقق");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setOtpError('');
|
||||
setOtpError("");
|
||||
|
||||
try {
|
||||
const verifyFn = loginMethod === 'email' ? verifyEmail : verifyPhone;
|
||||
console.log('[OTP] Verifying code:', otpCode);
|
||||
const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
|
||||
console.log("[OTP] Verifying code:", otpCode);
|
||||
|
||||
const result = await verifyFn(otpCode);
|
||||
console.log('[OTP] Verify response status:', result.status);
|
||||
console.log("[OTP] Verify response status:", result.status);
|
||||
|
||||
if (result.ok) {
|
||||
const finalToken = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken;
|
||||
if (finalToken && typeof finalToken === 'string') {
|
||||
const finalToken =
|
||||
typeof result.data === "string"
|
||||
? result.data
|
||||
: result.data?.token || result.data?.accessToken;
|
||||
if (finalToken && typeof finalToken === "string") {
|
||||
AuthService.addToken(finalToken);
|
||||
console.log('[OTP] Final token stored');
|
||||
console.log("[OTP] Final token stored");
|
||||
}
|
||||
|
||||
setIsSuccess(true);
|
||||
toast.success('تم التحقق بنجاح!', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
toast.success("تم التحقق بنجاح!", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('[OTP] Redirecting to home');
|
||||
router.push('/');
|
||||
console.log("[OTP] Redirecting to home");
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} else {
|
||||
console.error('[OTP] Verification failed:', result.data);
|
||||
setOtpError(result.data?.message || 'رمز التحقق غير صحيح');
|
||||
console.error("[OTP] Verification failed:", result.data);
|
||||
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[OTP] Error:', err);
|
||||
setOtpError(err.message || 'حدث خطأ في التحقق');
|
||||
console.error("[OTP] Error:", err);
|
||||
setOtpError(err.message || "حدث خطأ في التحقق");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -221,18 +243,18 @@ export default function LoginPage() {
|
||||
|
||||
const resendOTP = async () => {
|
||||
try {
|
||||
console.log('[OTP] Resending OTP via', loginMethod);
|
||||
if (loginMethod === 'email') {
|
||||
console.log("[OTP] Resending OTP via", loginMethod);
|
||||
if (loginMethod === "email") {
|
||||
await sendEmailOTP();
|
||||
} else {
|
||||
await sendPhoneOTP();
|
||||
}
|
||||
toast.success('تم إرسال رمز التحقق مجدداً', {
|
||||
style: { background: '#dcfce7', color: '#166534' },
|
||||
toast.success("تم إرسال رمز التحقق مجدداً", {
|
||||
style: { background: "#dcfce7", color: "#166534" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[OTP] Resend failed:', err);
|
||||
toast.error('فشل إرسال الرمز');
|
||||
console.error("[OTP] Resend failed:", err);
|
||||
toast.error("فشل إرسال الرمز");
|
||||
}
|
||||
};
|
||||
|
||||
@ -242,11 +264,11 @@ export default function LoginPage() {
|
||||
if (errors.credential) setErrors({ ...errors, credential: null });
|
||||
|
||||
// Auto-switch method
|
||||
if (isEmail(value)) {
|
||||
setLoginMethod('email');
|
||||
} else if (isPhoneNumber(value)) {
|
||||
setLoginMethod('phone');
|
||||
}
|
||||
// if (isEmail(value)) {
|
||||
// setLoginMethod('email');
|
||||
// } else if (isPhoneNumber(value)) {
|
||||
// setLoginMethod('phone');
|
||||
// }
|
||||
};
|
||||
|
||||
const particles = Array.from({ length: 20 }, (_, i) => ({
|
||||
@ -271,7 +293,7 @@ export default function LoginPage() {
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { type: 'spring', stiffness: 100 },
|
||||
transition: { type: "spring", stiffness: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
@ -285,9 +307,23 @@ export default function LoginPage() {
|
||||
<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' }}
|
||||
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>
|
||||
@ -312,8 +348,14 @@ export default function LoginPage() {
|
||||
>
|
||||
{/* 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 }}>
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ x: -5 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</motion.div>
|
||||
<span>العودة للرئيسية</span>
|
||||
@ -329,23 +371,28 @@ export default function LoginPage() {
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring' }}
|
||||
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' }}
|
||||
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
|
||||
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' ? (
|
||||
{step === "otp" ? (
|
||||
<KeyRound className="w-10 h-10 text-white" />
|
||||
) : (
|
||||
<Home className="w-10 h-10 text-white" />
|
||||
@ -353,14 +400,14 @@ export default function LoginPage() {
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
|
||||
<p className="text-amber-100">
|
||||
{step === 'otp' ? 'أدخل رمز التحقق' : 'مرحباً بعودتك!'}
|
||||
{step === "otp" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 'login' ? (
|
||||
{step === "login" ? (
|
||||
<motion.form
|
||||
key="login"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
@ -374,13 +421,13 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginMethod('email');
|
||||
setFormData({ ...formData, credential: '' });
|
||||
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'
|
||||
loginMethod === "email"
|
||||
? "bg-amber-500 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
@ -389,13 +436,13 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginMethod('phone');
|
||||
setFormData({ ...formData, credential: '' });
|
||||
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'
|
||||
loginMethod === "phone"
|
||||
? "bg-amber-500 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
@ -406,29 +453,46 @@ export default function LoginPage() {
|
||||
{/* Credential input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
{loginMethod === 'email' ? 'البريد الإلكتروني' : 'رقم الهاتف'}
|
||||
{loginMethod === "email"
|
||||
? "البريد الإلكتروني"
|
||||
: "رقم الهاتف"}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
{loginMethod === 'email' ? (
|
||||
<Mail className={`w-5 h-5 transition-colors ${errors.credential ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
{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'}`} />
|
||||
<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={loginMethod === 'email' ? 'email' : 'tel'}
|
||||
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'
|
||||
errors.credential
|
||||
? "border-red-500"
|
||||
: "border-gray-700"
|
||||
}`}
|
||||
placeholder={loginMethod === 'email' ? 'example@email.com' : '+963XXXXXXXXX'}
|
||||
placeholder={
|
||||
loginMethod === "email"
|
||||
? "example@email.com"
|
||||
: "+963XXXXXXXXX"
|
||||
}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
{errors.credential && (
|
||||
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.credential}
|
||||
</motion.p>
|
||||
)}
|
||||
@ -436,24 +500,36 @@ export default function LoginPage() {
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور</label>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
كلمة المرور
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
<Lock
|
||||
className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, password: e.target.value });
|
||||
if (errors.password) setErrors({ ...errors, password: null });
|
||||
setFormData({
|
||||
...formData,
|
||||
password: e.target.value,
|
||||
});
|
||||
if (errors.password)
|
||||
setErrors({ ...errors, password: null });
|
||||
}}
|
||||
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-700'
|
||||
errors.password ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
placeholder="أدخل كلمة المرور"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||
<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" />
|
||||
) : (
|
||||
@ -462,7 +538,11 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errors.password}
|
||||
</motion.p>
|
||||
)}
|
||||
@ -474,12 +554,22 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
rememberMe: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">تذكرني</span>
|
||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||
تذكرني
|
||||
</span>
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-sm text-amber-400 hover:text-amber-300 transition-colors">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</div>
|
||||
@ -525,7 +615,7 @@ export default function LoginPage() {
|
||||
<div className="text-center mb-4">
|
||||
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
|
||||
<p className="text-gray-300 text-sm">
|
||||
تم إرسال رمز التحقق إلى{' '}
|
||||
تم إرسال رمز التحقق إلى{" "}
|
||||
<span className="text-white font-medium" dir="ltr">
|
||||
{formData.credential}
|
||||
</span>
|
||||
@ -533,23 +623,29 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رمز التحقق</label>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
رمز التحقق
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => {
|
||||
setOtpCode(e.target.value);
|
||||
if (otpError) setOtpError('');
|
||||
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'
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
@ -586,10 +682,10 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('login');
|
||||
setOtpCode('');
|
||||
setOtpError('');
|
||||
console.log('[OTP] Going back to login');
|
||||
setStep("login");
|
||||
setOtpCode("");
|
||||
setOtpError("");
|
||||
console.log("[OTP] Going back to login");
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
@ -607,20 +703,39 @@ export default function LoginPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.p variants={itemVariants} className="text-center text-gray-400 mt-6">
|
||||
ليس لديك حساب؟{' '}
|
||||
<Link href="/auth/choose-role" className="text-amber-400 hover:text-amber-300 font-medium transition-colors">
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-400 mt-6"
|
||||
>
|
||||
ليس لديك حساب؟{" "}
|
||||
<Link
|
||||
href="/auth/choose-role"
|
||||
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
إنشاء حساب جديد
|
||||
</Link>
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p variants={itemVariants} className="text-center text-gray-500 text-xs mt-4">
|
||||
بتسجيل الدخول، أنت توافق على{' '}
|
||||
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors">شروط الاستخدام</Link>
|
||||
{' '}و{' '}
|
||||
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">سياسة الخصوصية</Link>
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="text-center text-gray-500 text-xs mt-4"
|
||||
>
|
||||
بتسجيل الدخول، أنت توافق على{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
شروط الاستخدام
|
||||
</Link>{" "}
|
||||
و{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
سياسة الخصوصية
|
||||
</Link>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
102
app/notifications/page.js
Normal file
102
app/notifications/page.js
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const { notifications, unreadCount, isLoading } = useNotifications();
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const markAsRead = (id) => {
|
||||
// This will be handled by context if needed
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
// This will be handled by context if needed
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">خطأ في التحميل</h3>
|
||||
<p className="text-gray-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الإشعارات</h1>
|
||||
<p className="text-gray-600">
|
||||
{unreadCount > 0 ? `لديك ${unreadCount} إشعار غير مقروء` : 'جميع الإشعارات مقروءة'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد إشعارات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا الإشعارات المتعلقة بحجوزاتك ومدفوعاتك</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
|
||||
>
|
||||
<div className="p-5 flex gap-4">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-full flex items-center justify-center shrink-0">
|
||||
<Bell className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{notification.title}</h3>
|
||||
{notification.message && (
|
||||
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
|
||||
)}
|
||||
{notification.date && (
|
||||
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,328 +1,459 @@
|
||||
// 'use client';
|
||||
|
||||
// import { useState, useEffect } from 'react';
|
||||
// import { motion } from 'framer-motion';
|
||||
// import { useRouter } from 'next/navigation';
|
||||
// import {
|
||||
// DollarSign,
|
||||
// TrendingUp,
|
||||
// Wallet,
|
||||
// Star,
|
||||
// Eye,
|
||||
// Download,
|
||||
// CalendarDays
|
||||
// } from 'lucide-react';
|
||||
// import toast, { Toaster } from 'react-hot-toast';
|
||||
// import AuthService from '@/app/services/AuthService';
|
||||
|
||||
// const StatCard = ({ title, value, icon: Icon, color }) => {
|
||||
// return (
|
||||
// <motion.div
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
|
||||
// >
|
||||
// <div className="flex items-center justify-between mb-4">
|
||||
// <div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
|
||||
// <Icon className="w-6 h-6 text-white" />
|
||||
// </div>
|
||||
// </div>
|
||||
// <h3 className="text-sm text-gray-500 mb-1">{title}</h3>
|
||||
// <div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
// </motion.div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const PropertyProfitCard = ({ property, onViewDetails }) => {
|
||||
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
|
||||
|
||||
// return (
|
||||
// <motion.div
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
// >
|
||||
// <div className="p-5">
|
||||
// <div className="flex justify-between items-start mb-4">
|
||||
// <div>
|
||||
// <h3 className="font-bold text-lg text-gray-900">{property.title}</h3>
|
||||
// {property.isNotSeized && (
|
||||
// <span className="inline-block mt-1 px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
|
||||
// غير محجوز
|
||||
// </span>
|
||||
// )}
|
||||
// </div>
|
||||
// <span className="text-xs text-gray-500">{property.location}</span>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-3 gap-4 mb-4">
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">الإيرادات</div>
|
||||
// <div className="text-lg font-bold text-amber-600">{formatCurrency(property.revenue)}</div>
|
||||
// </div>
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">العمولة</div>
|
||||
// <div className="text-lg font-bold text-blue-600">{formatCurrency(property.commission)}</div>
|
||||
// </div>
|
||||
// <div className="text-center">
|
||||
// <div className="text-sm text-gray-500">المتبقي</div>
|
||||
// <div className="text-lg font-bold text-green-600">{formatCurrency(property.remaining)}</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="flex justify-between items-center pt-3 border-t border-gray-100">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <Star className="w-4 h-4 text-amber-500" />
|
||||
// <span className="text-sm font-medium text-gray-700">التقييم العام:</span>
|
||||
// <span className="text-sm font-medium text-gray-900">{property.valuation}</span>
|
||||
// </div>
|
||||
// <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
// <CalendarDays className="w-4 h-4" />
|
||||
// <span>مؤجر {property.rentedCount} مرة</span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <button
|
||||
// onClick={() => onViewDetails(property)}
|
||||
// className="w-full mt-4 py-2 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
|
||||
// >
|
||||
// <Eye className="w-4 h-4" />
|
||||
// عرض التفاصيل
|
||||
// </button>
|
||||
// </div>
|
||||
// </motion.div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const PropertyCalendar = ({ year, month }) => {
|
||||
// const [currentMonth, setCurrentMonth] = useState(new Date(year, month - 1));
|
||||
// const monthNames = ['يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'];
|
||||
// const weekDays = ['إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت', 'أحد'];
|
||||
|
||||
// const getDaysInMonth = (date) => {
|
||||
// return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
// };
|
||||
|
||||
// const getFirstDayOfMonth = (date) => {
|
||||
// const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
// return day === 0 ? 6 : day - 1;
|
||||
// };
|
||||
|
||||
// const daysInMonth = getDaysInMonth(currentMonth);
|
||||
// const firstDayIndex = getFirstDayOfMonth(currentMonth);
|
||||
|
||||
// const cells = [];
|
||||
// for (let i = 0; i < firstDayIndex; i++) {
|
||||
// cells.push(<div key={`empty-${i}`} className="p-2 md:p-3 text-center" />);
|
||||
// }
|
||||
// for (let d = 1; d <= daysInMonth; d++) {
|
||||
// cells.push(
|
||||
// <div
|
||||
// key={d}
|
||||
// className="p-2 md:p-3 text-center rounded-xl hover:bg-gray-100 transition-colors"
|
||||
// >
|
||||
// {d}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||||
// <div className="flex justify-between items-center mb-6">
|
||||
// <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
// <CalendarDays className="w-5 h-5 text-amber-500" />
|
||||
// {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
// </h3>
|
||||
// <div className="flex gap-2">
|
||||
// <button
|
||||
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
|
||||
// className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
// >
|
||||
// ←
|
||||
// </button>
|
||||
// <button
|
||||
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
|
||||
// className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
// >
|
||||
// →
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
|
||||
// {weekDays.map(day => (
|
||||
// <div key={day}>{day}</div>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-7 gap-1">
|
||||
// {cells}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default function OwnerProfitsPage() {
|
||||
// const router = useRouter();
|
||||
// const [user, setUser] = useState(null);
|
||||
// const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// const [summary] = useState({
|
||||
// totalRevenue: 4290,
|
||||
// totalCommission: 644,
|
||||
// remainingBalance: 3647,
|
||||
// });
|
||||
|
||||
// const [properties] = useState([
|
||||
// {
|
||||
// id: 1,
|
||||
// title: 'Damascus Olive Residence',
|
||||
// location: 'دمشق، المزة',
|
||||
// isNotSeized: true,
|
||||
// revenue: 3240,
|
||||
// commission: 486,
|
||||
// remaining: 2754,
|
||||
// valuation: 'جيد جدا',
|
||||
// rentedCount: 18,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (AuthService.isGuest()) {
|
||||
// router.push('/auth/choose-role');
|
||||
// return;
|
||||
// }
|
||||
// if (!AuthService.isOwner()) {
|
||||
// router.push('/');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const authUser = AuthService.getUser();
|
||||
// if (authUser) {
|
||||
// setUser({
|
||||
// name: authUser.name || authUser.email,
|
||||
// email: authUser.email,
|
||||
// });
|
||||
// }
|
||||
// setIsLoading(false);
|
||||
// }, [router]);
|
||||
|
||||
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
|
||||
|
||||
// const handleViewDetails = (property) => {
|
||||
// toast.info(`عرض تفاصيل ${property.title}`);
|
||||
// };
|
||||
|
||||
// const handleExportReport = () => {
|
||||
// toast.success('جاري تصدير التقرير...');
|
||||
// };
|
||||
|
||||
// if (isLoading) {
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center">
|
||||
// <div className="text-center">
|
||||
// <div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
// <p className="text-gray-600">جاري التحميل...</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="min-h-screen bg-gray-50 py-8">
|
||||
// <Toaster position="top-center" reverseOrder={false} />
|
||||
// <div className="container mx-auto px-4 max-w-6xl">
|
||||
// <div className="mb-8">
|
||||
// <h1 className="text-3xl font-bold text-gray-900 mb-2">دفتر الحسابات</h1>
|
||||
// <p className="text-gray-600">نظرة عامة على أرباح المالك</p>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
// <StatCard
|
||||
// title="الإيرادات"
|
||||
// value={formatCurrency(summary.totalRevenue)}
|
||||
// icon={DollarSign}
|
||||
// color="bg-green-500"
|
||||
// />
|
||||
// <StatCard
|
||||
// title="العمولة"
|
||||
// value={formatCurrency(summary.totalCommission)}
|
||||
// icon={TrendingUp}
|
||||
// color="bg-blue-500"
|
||||
// />
|
||||
// <StatCard
|
||||
// title="المتبقي"
|
||||
// value={formatCurrency(summary.remainingBalance)}
|
||||
// icon={Wallet}
|
||||
// color="bg-amber-500"
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <div className="mb-12">
|
||||
// <h2 className="text-xl font-bold text-gray-900 mb-4">عقاراتي</h2>
|
||||
// <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
// {properties.map((property) => (
|
||||
// <PropertyProfitCard
|
||||
// key={property.id}
|
||||
// property={property}
|
||||
// onViewDetails={handleViewDetails}
|
||||
// />
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mb-12">
|
||||
// <h2 className="text-xl font-bold text-gray-900 mb-4">تقويم العقار</h2>
|
||||
// <PropertyCalendar year={2026} month={3} />
|
||||
// </div>
|
||||
|
||||
// {/* <div className="flex justify-end">
|
||||
// <button
|
||||
// onClick={handleExportReport}
|
||||
// className="px-6 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
|
||||
// >
|
||||
// <Download className="w-5 h-5" />
|
||||
// تصدير التقرير
|
||||
// </button>
|
||||
// </div> */}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar,
|
||||
Home,
|
||||
Building,
|
||||
Download,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Eye,
|
||||
PieChart,
|
||||
BarChart,
|
||||
LineChart,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
|
||||
const StatCard = ({ title, value, change, icon: Icon, color, trend }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex justify-between items-start 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 className={`flex items-center gap-1 text-sm ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend === 'up' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
<span>{Math.abs(change)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm text-gray-600 mb-1">{title}</h3>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyProfitCard = ({ property, onViewDetails }) => {
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 mb-1">{property.title}</h3>
|
||||
<p className="text-sm text-gray-500">{property.location}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
property.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{property.status === 'active' ? 'نشط' : 'غير نشط'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
|
||||
<div className="text-xs text-gray-500">إجمالي الأرباح</div>
|
||||
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-xl text-center">
|
||||
<Calendar className="w-5 h-5 text-blue-500 mx-auto mb-1" />
|
||||
<div className="text-xs text-gray-500">عدد الحجوزات</div>
|
||||
<div className="text-lg font-bold text-blue-600">{property.totalBookings}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">هذا الشهر</span>
|
||||
<span className="font-medium text-gray-900">{formatCurrency(property.monthlyProfit)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">الأسبوع الماضي</span>
|
||||
<span className="font-medium text-gray-900">{formatCurrency(property.weeklyProfit)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">متوسط السعر اليومي</span>
|
||||
<span className="font-medium text-gray-900">{formatCurrency(property.avgDailyPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onViewDetails(property)}
|
||||
className="w-full bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
عرض التفاصيل
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfitDetailsModal = ({ property, isOpen, onClose }) => {
|
||||
if (!isOpen || !property) return null;
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
const monthlyData = [
|
||||
{ month: 'يناير', profit: 1250000 },
|
||||
{ month: 'فبراير', profit: 1500000 },
|
||||
{ month: 'مارس', profit: 1800000 },
|
||||
{ month: 'إبريل', profit: 2100000 },
|
||||
{ month: 'مايو', profit: 2500000 },
|
||||
{ month: 'يونيو', profit: 2300000 }
|
||||
];
|
||||
|
||||
const maxProfit = Math.max(...monthlyData.map(d => d.profit));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{property.title}</h2>
|
||||
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-amber-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">إجمالي الأرباح</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{property.totalBookings}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">عدد الحجوزات</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{property.occupancyRate}%</div>
|
||||
<div className="text-xs text-gray-600 mt-1">نسبة الإشغال</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatCurrency(property.avgDailyPrice)}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">متوسط السعر اليومي</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-4">الأرباح الشهرية</h3>
|
||||
<div className="space-y-3">
|
||||
{monthlyData.map((data, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">{data.month}</span>
|
||||
<span className="font-medium text-gray-900">{formatCurrency(data.profit)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(data.profit / maxProfit) * 100}%` }}
|
||||
transition={{ duration: 0.8, delay: index * 0.1 }}
|
||||
className="bg-amber-500 h-2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-4">آخر الحجوزات</h3>
|
||||
<div className="space-y-3">
|
||||
{property.recentBookings?.map((booking, index) => (
|
||||
<div key={index} className="bg-white p-3 rounded-lg flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{booking.tenantName}</p>
|
||||
<p className="text-xs text-gray-500">{booking.startDate} - {booking.endDate}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-amber-600">{formatCurrency(booking.amount)}</p>
|
||||
<p className="text-xs text-gray-500">{booking.status === 'completed' ? 'مكتمل' : 'قيد التنفيذ'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
import * as XLSX from 'xlsx';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
|
||||
export default function OwnerProfitsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(null);
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [filteredProperties, setFilteredProperties] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedProperty, setSelectedProperty] = useState(null);
|
||||
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||
const [tableData, setTableData] = useState([]);
|
||||
|
||||
const sampleData = [
|
||||
{
|
||||
id: 1,
|
||||
property: 'A000000001',
|
||||
bookingNumber: 'XX-101',
|
||||
fromDate: '2025-05-01',
|
||||
toDate: '2025-05-07',
|
||||
amountReceived: 500,
|
||||
platformCommission: 0,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: '—',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
property: 'A000000002',
|
||||
bookingNumber: 'XX-202',
|
||||
fromDate: '2025-05-10',
|
||||
toDate: '2025-05-15',
|
||||
amountReceived: 300,
|
||||
platformCommission: 0,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: '—',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
property: 'A000000003',
|
||||
bookingNumber: 'XX-309',
|
||||
fromDate: '2025-06-01',
|
||||
toDate: '2025-06-05',
|
||||
amountReceived: 800,
|
||||
platformCommission: 150,
|
||||
transferredToOwner: 0,
|
||||
transferReceipt: 'قيد الانتظار',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const computeRows = (data) => {
|
||||
return data.map((item) => {
|
||||
const platformProfit = item.amountReceived * 0.05;
|
||||
const ownerDue = item.amountReceived - platformProfit;
|
||||
return {
|
||||
...item,
|
||||
platformProfit,
|
||||
ownerDue,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isGuest()) {
|
||||
router.push('/auth/choose-role');
|
||||
return;
|
||||
}
|
||||
if (!AuthService.isOwner()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const authUser = AuthService.getUser();
|
||||
if (authUser && AuthService.isOwner()) {
|
||||
if (authUser) {
|
||||
setUser({
|
||||
name: authUser.name || authUser.email,
|
||||
email: authUser.email,
|
||||
role: 'owner',
|
||||
});
|
||||
loadData();
|
||||
} else {
|
||||
router.push('/auth/choose-role');
|
||||
}
|
||||
}, [router]); // month, year, all
|
||||
|
||||
|
||||
|
||||
const loadProfitsData = () => {
|
||||
const storedProfits = localStorage.getItem('ownerProfits');
|
||||
if (storedProfits) {
|
||||
setProperties(JSON.parse(storedProfits));
|
||||
setFilteredProperties(JSON.parse(storedProfits));
|
||||
const stored = localStorage.getItem('ownerProfitsTable');
|
||||
if (stored) {
|
||||
setTableData(computeRows(JSON.parse(stored)));
|
||||
} else {
|
||||
const mockProperties = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'فيلا فاخرة في المزة',
|
||||
location: 'دمشق، المزة',
|
||||
status: 'active',
|
||||
totalProfit: 12500000,
|
||||
totalBookings: 24,
|
||||
monthlyProfit: 3200000,
|
||||
weeklyProfit: 850000,
|
||||
avgDailyPrice: 500000,
|
||||
occupancyRate: 78,
|
||||
recentBookings: [
|
||||
{ tenantName: 'أحمد محمد', startDate: '2024-03-10', endDate: '2024-03-15', amount: 2500000, status: 'completed' },
|
||||
{ tenantName: 'سارة أحمد', startDate: '2024-03-05', endDate: '2024-03-08', amount: 1500000, status: 'completed' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'شقة حديثة في الشهباء',
|
||||
location: 'حلب، الشهباء',
|
||||
status: 'active',
|
||||
totalProfit: 5800000,
|
||||
totalBookings: 18,
|
||||
monthlyProfit: 1500000,
|
||||
weeklyProfit: 400000,
|
||||
avgDailyPrice: 250000,
|
||||
occupancyRate: 65,
|
||||
recentBookings: [
|
||||
{ tenantName: 'محمد علي', startDate: '2024-03-12', endDate: '2024-03-14', amount: 750000, status: 'completed' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'بيت عائلي في بابا عمرو',
|
||||
location: 'حمص، بابا عمرو',
|
||||
status: 'active',
|
||||
totalProfit: 8400000,
|
||||
totalBookings: 12,
|
||||
monthlyProfit: 2100000,
|
||||
weeklyProfit: 525000,
|
||||
avgDailyPrice: 350000,
|
||||
occupancyRate: 45,
|
||||
recentBookings: []
|
||||
}
|
||||
];
|
||||
setProperties(mockProperties);
|
||||
setFilteredProperties(mockProperties);
|
||||
localStorage.setItem('ownerProfits', JSON.stringify(mockProperties));
|
||||
setTableData(computeRows(sampleData));
|
||||
localStorage.setItem('ownerProfitsTable', JSON.stringify(sampleData));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const totalStats = {
|
||||
totalProfit: properties.reduce((sum, p) => sum + p.totalProfit, 0),
|
||||
totalBookings: properties.reduce((sum, p) => sum + p.totalBookings, 0),
|
||||
avgOccupancy: Math.round(properties.reduce((sum, p) => sum + p.occupancyRate, 0) / properties.length),
|
||||
activeProperties: properties.filter(p => p.status === 'active').length
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (amount >= 1000000) {
|
||||
return (amount / 1000000).toFixed(1) + ' مليون ل.س';
|
||||
const totals = tableData.reduce(
|
||||
(acc, row) => {
|
||||
acc.totalAmountReceived += row.amountReceived;
|
||||
acc.totalCommission += row.platformCommission;
|
||||
acc.totalPlatformProfit += row.platformProfit;
|
||||
acc.totalOwnerDue += row.ownerDue;
|
||||
acc.totalTransferred += row.transferredToOwner;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalAmountReceived: 0,
|
||||
totalCommission: 0,
|
||||
totalPlatformProfit: 0,
|
||||
totalOwnerDue: 0,
|
||||
totalTransferred: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const handleExportReport = () => {
|
||||
try {
|
||||
const exportData = tableData.map((row) => ({
|
||||
'العقار': row.property,
|
||||
'رقم الحجز': row.bookingNumber,
|
||||
'من تاريخ': row.fromDate,
|
||||
'حتى تاريخ': row.toDate,
|
||||
'العروض المستلم': row.amountReceived,
|
||||
'عمولة المنصة': row.platformCommission,
|
||||
'ربح المنصة (5%)': row.platformProfit,
|
||||
'المستحق للمالك': row.ownerDue,
|
||||
'تم التحويل للمالك': row.transferredToOwner,
|
||||
'رقم وصل التحويل': row.transferReceipt,
|
||||
}));
|
||||
|
||||
exportData.push({
|
||||
'العقار': 'الإجمالي العام',
|
||||
'رقم الحجز': '',
|
||||
'من تاريخ': '',
|
||||
'حتى تاريخ': '',
|
||||
'العروض المستلم': totals.totalAmountReceived,
|
||||
'عمولة المنصة': totals.totalCommission,
|
||||
'ربح المنصة (5%)': totals.totalPlatformProfit,
|
||||
'المستحق للمالك': totals.totalOwnerDue,
|
||||
'تم التحويل للمالك': totals.totalTransferred,
|
||||
'رقم وصل التحويل': '—',
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||
const colWidths = [
|
||||
{ wch: 15 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 12 },
|
||||
{ wch: 14 },
|
||||
{ wch: 14 },
|
||||
{ wch: 16 },
|
||||
{ wch: 16 },
|
||||
{ wch: 16 },
|
||||
{ wch: 18 },
|
||||
];
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'أرباح المالك');
|
||||
|
||||
XLSX.writeFile(workbook, `تقرير_الأرباح_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
|
||||
|
||||
toast.success('تم تصدير التقرير بنجاح!');
|
||||
} catch (error) {
|
||||
console.error('خطأ في التصدير:', error);
|
||||
toast.error('حدث خطأ أثناء تصدير التقرير');
|
||||
}
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@ -337,132 +468,125 @@ export default function OwnerProfitsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
|
||||
<ProfitDetailsModal
|
||||
property={selectedProperty}
|
||||
isOpen={!!selectedProperty}
|
||||
onClose={() => setSelectedProperty(null)}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأرباح والإحصائيات</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.name}، إليك ملخص أرباحك</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
<option value="month">آخر 30 يوم</option>
|
||||
<option value="year">آخر 12 شهر</option>
|
||||
<option value="all">جميع الفترات</option>
|
||||
</select>
|
||||
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
</button> */}
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
|
||||
<p className="text-gray-600">
|
||||
مرحباً {user?.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportReport}
|
||||
className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
تصدير التقرير
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="إجمالي الأرباح"
|
||||
value={formatCurrency(totalStats.totalProfit)}
|
||||
change={12.5}
|
||||
icon={Wallet}
|
||||
color="bg-amber-500"
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="عدد الحجوزات"
|
||||
value={totalStats.totalBookings}
|
||||
change={8.2}
|
||||
icon={Calendar}
|
||||
color="bg-blue-500"
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="متوسط نسبة الإشغال"
|
||||
value={`${totalStats.avgOccupancy}%`}
|
||||
change={5.3}
|
||||
icon={PieChart}
|
||||
color="bg-green-500"
|
||||
trend="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="العقارات النشطة"
|
||||
value={totalStats.activeProperties}
|
||||
change={0}
|
||||
icon={Building}
|
||||
color="bg-purple-500"
|
||||
trend="up"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">أرباح العقارات</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilteredProperties(properties)}
|
||||
className="px-3 py-1.5 bg-gray-100 rounded-lg text-sm hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
عرض الكل
|
||||
</button>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-800 text-gray-100">
|
||||
<tr>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العقار</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم الحجز</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">من تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">حتى تاريخ</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العروض المستلم</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">عمولة المنصة</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider bg-amber-50 text-amber-800">
|
||||
ربح المنصة <span className="font-normal text-[11px] block">(5% من العربون)</span>
|
||||
</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">المستحق للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">تم التحويل للمالك</th>
|
||||
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم وصل التحويل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{tableData.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`hover:bg-amber-50/40 transition-colors ${
|
||||
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-medium text-gray-800">
|
||||
{row.property}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.bookingNumber}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.fromDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
|
||||
{row.toDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-gray-800">
|
||||
{row.amountReceived}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
|
||||
{row.platformCommission}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-bold text-amber-700 bg-amber-50/50">
|
||||
{row.platformProfit}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-emerald-700">
|
||||
{row.ownerDue}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
|
||||
{row.transferredToOwner}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-500 text-xs">
|
||||
{row.transferReceipt}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-100 border-t-2 border-gray-300">
|
||||
<tr>
|
||||
<td colSpan="4" className="px-4 py-4 text-right font-bold text-gray-800">
|
||||
الإجمالي العام
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalAmountReceived}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalCommission}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-amber-700 bg-amber-100/60">
|
||||
{totals.totalPlatformProfit}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-emerald-700">
|
||||
{totals.totalOwnerDue}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
|
||||
{totals.totalTransferred}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center text-gray-500">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredProperties.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<div className="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<DollarSign className="w-12 h-12 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد بيانات</h3>
|
||||
<p className="text-gray-600">لا توجد أرباح مسجلة حتى الآن</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{filteredProperties.map((property) => (
|
||||
<PropertyProfitCard
|
||||
key={property.id}
|
||||
property={property}
|
||||
onViewDetails={setSelectedProperty}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="bg-gradient-to-r from-amber-500 to-amber-600 rounded-2xl p-6 text-white mt-8"
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">احصل على المزيد من الأرباح</h3>
|
||||
<p className="text-amber-100 text-sm">أضف عقارات جديدة وحسّن أسعارك لزيادة الإشغال</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/owner/properties/add"
|
||||
className="px-6 py-2 bg-white text-amber-600 rounded-xl font-medium hover:bg-amber-50 transition-colors"
|
||||
>
|
||||
إضافة عقار جديد
|
||||
</Link>
|
||||
<div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
|
||||
<span className="inline-flex items-center gap-1"></span> ملاحظة:
|
||||
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,7 @@ const MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapCo
|
||||
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false });
|
||||
const useMapEvents = dynamic(() => import('react-leaflet').then(mod => mod.useMapEvents), { ssr: false });
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
|
||||
function MapClickHandler({ onMapClick }) {
|
||||
const map = useMapEvents({
|
||||
|
||||
@ -721,7 +721,7 @@ export default function OwnerPropertiesPage() {
|
||||
|
||||
try {
|
||||
console.log('[OwnerProperties] Fetching listings for user:', userId);
|
||||
const data = await getMyRentListings(userId);
|
||||
const data = await getMyRentListings();
|
||||
const list = Array.isArray(data) ? data : (data ? [data] : []);
|
||||
console.log('[OwnerProperties] API returned:', list.length, 'properties');
|
||||
|
||||
@ -747,9 +747,9 @@ export default function OwnerPropertiesPage() {
|
||||
livingRooms: details.livingRooms || 0,
|
||||
status: { 0: 'available', 1: 'booked', 2: 'maintenance' }[info.status] || 'available',
|
||||
images: (() => {
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
|
||||
const raw = Array.isArray(info.images) ? info.images : [];
|
||||
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/'}${img}`) : ['/property-placeholder.jpg'];
|
||||
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`) : ['/property-placeholder.jpg'];
|
||||
})(),
|
||||
createdAt: item.createdAt || new Date().toISOString(),
|
||||
furnished: details.furnished || false,
|
||||
|
||||
262
app/owner/reservations/page.js
Normal file
262
app/owner/reservations/page.js
Normal file
@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
|
||||
MapPin, DollarSign, Home, ArrowLeft, User, RefreshCw, Mail, Phone,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { getRentProperty } from '../../utils/api';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
|
||||
const STATUS_UI = {
|
||||
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
ownerConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
|
||||
depositConfirmed: { label: 'مؤكد نهائياً', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
completed: { label: 'منتهي', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
||||
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
||||
};
|
||||
|
||||
const sLabel = c => STATUS_UI[STATUS_MAP[c]]?.label ?? String(c);
|
||||
const sColor = c => STATUS_UI[STATUS_MAP[c]]?.color ?? 'bg-gray-100 text-gray-700';
|
||||
const sIcon = c => STATUS_UI[STATUS_MAP[c]]?.icon ?? Clock;
|
||||
|
||||
function StatusBadge({ code }) {
|
||||
const Icon = sIcon(code);
|
||||
return <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${sColor(code)}`}><Icon className="w-3 h-3"/> {sLabel(code)}</span>;
|
||||
}
|
||||
|
||||
async function enrich(r) {
|
||||
if (!r.propertyId) return r;
|
||||
try {
|
||||
const prop = await getRentProperty(r.propertyId);
|
||||
r._prop = prop?.propertyInformation ?? prop ?? null;
|
||||
} catch { /* skip */ }
|
||||
return r;
|
||||
}
|
||||
|
||||
const pAddr = p => p?.address ?? '';
|
||||
const pImgs = p => Array.isArray(p?.images) ? p.images : [];
|
||||
const pBeds = p => p?.numberOfBedRooms ?? 0;
|
||||
const pBaths = p => p?.numberOfBathRooms ?? 0;
|
||||
const API = (token, method, path, body) => fetch(`${API_BASE}${path}`, {
|
||||
method: method || 'GET',
|
||||
headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
function OwnerCard({ r, onViewDetails, onConfirm, onReject }) {
|
||||
const p = r._prop;
|
||||
const imgs = pImgs(p);
|
||||
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
||||
const addr = pAddr(p);
|
||||
const isPending = r.status === 0; // Pending
|
||||
|
||||
return (
|
||||
<motion.div initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
|
||||
<div className="p-5">
|
||||
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover"/></div>}
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<StatusBadge code={r.status}/>
|
||||
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString()??'—'}</div>
|
||||
<div className="text-xs text-gray-500">السعر الإجمالي</div>
|
||||
</div>
|
||||
</div>
|
||||
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{pBeds(p)>0&&<span>{pBeds(p)} غرف</span>}{pBaths(p)>0&&<span>{pBaths(p)} حمامات</span>}</div>}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
|
||||
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
|
||||
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex gap-3 pt-3 border-t border-gray-100 ${!isPending?'justify-center':''}`}>
|
||||
<button onClick={()=>onViewDetails(r)}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||
<Eye className="w-4 h-4"/> التفاصيل
|
||||
</button>
|
||||
{isPending && <>
|
||||
<button onClick={()=>onConfirm(r)}
|
||||
className="flex-1 bg-green-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-green-600 transition-colors flex items-center justify-center gap-2">
|
||||
<CheckCircle className="w-4 h-4"/> قبول
|
||||
</button>
|
||||
<button onClick={()=>onReject(r)}
|
||||
className="flex-1 bg-red-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-red-600 transition-colors flex items-center justify-center gap-2">
|
||||
<XCircle className="w-4 h-4"/> رفض
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsModal({ r, isOpen, onClose }) {
|
||||
if (!isOpen || !r) return null;
|
||||
const p = r._prop;
|
||||
return (
|
||||
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
|
||||
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">طلب حجز #{r.id}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{p && <div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
|
||||
<p><span className="text-gray-500">العنوان:</span> {pAddr(p)||'—'}</p>
|
||||
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mt-2">
|
||||
{pBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBeds(p)} غرف</span>}
|
||||
{pBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBaths(p)} حمامات</span>}
|
||||
</div>}
|
||||
</div>}
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
|
||||
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
|
||||
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
|
||||
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/>المعلومات المالية</h3>
|
||||
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??'—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OwnerReservationRequestsPage() {
|
||||
const router = useRouter();
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [filtered, setFiltered] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthService.getUser() || !AuthService.isOwner()) { router.push('/auth/choose-role'); return; }
|
||||
loadReservations();
|
||||
}, [router]);
|
||||
|
||||
const loadReservations = useCallback(async () => {
|
||||
try {
|
||||
const token = AuthService.getToken();
|
||||
const res = await fetch(`${API_BASE}/Reservations/GetOwnerResevationRequests`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
const enriched = await Promise.all(list.map(enrich));
|
||||
setReservations(enriched);
|
||||
setFiltered(enriched);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('فشل تحميل طلبات الحجز');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let r = reservations;
|
||||
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
|
||||
if (searchTerm) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
r = r.filter(x => pAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q));
|
||||
}
|
||||
setFiltered(r);
|
||||
}, [reservations, filterStatus, searchTerm]);
|
||||
|
||||
const handleConfirm = async (r) => {
|
||||
try {
|
||||
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/owner-confirm/${r.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
toast.success('تم قبول الحجز بنجاح');
|
||||
await loadReservations();
|
||||
} catch (err) { console.error(err); toast.error('فشل قبول الحجز'); }
|
||||
};
|
||||
|
||||
const handleReject = async (r) => {
|
||||
if (!confirm('هل أنت متأكد من رفض هذا الحجز؟')) return;
|
||||
try {
|
||||
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/reject/${r.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
toast.success('تم رفض الحجز');
|
||||
await loadReservations();
|
||||
} catch (err) { console.error(err); toast.error('فشل رفض الحجز'); }
|
||||
};
|
||||
|
||||
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
|
||||
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">طلبات الحجز</h1>
|
||||
<p className="text-gray-600">لديك {reservations.length} طلب</p>
|
||||
</div>
|
||||
<button onClick={loadReservations} className="p-2 bg-white shadow rounded-xl hover:shadow-md transition-all"><RefreshCw className="w-5 h-5 text-gray-600"/></button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{Object.entries(counts).map(([s, c]) => (
|
||||
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
|
||||
onClick={() => setFilterStatus(s)}>
|
||||
<div className="text-2xl font-bold text-amber-600">{c}</div>
|
||||
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-6 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
|
||||
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد طلبات</h3>
|
||||
<p className="text-gray-600">لم يتم استلام أي طلبات حجز حتى الآن</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filtered.map(r => <OwnerCard key={r.id} r={r} onViewDetails={setSelected} onConfirm={handleConfirm} onReject={handleReject} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
app/page.js
73
app/page.js
@ -38,10 +38,15 @@ import AuthService from './services/AuthService';
|
||||
function mapApiProperty(item, index) {
|
||||
const info = item.propertyInformation || {};
|
||||
|
||||
const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
|
||||
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 propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? 'apartment';
|
||||
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 = [];
|
||||
@ -52,20 +57,27 @@ function mapApiProperty(item, index) {
|
||||
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
|
||||
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('/') ? '' : '/'}${img}`)
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg'];
|
||||
|
||||
const ownerSource = info.ownerType == null && item.ownerType == null
|
||||
? 'all'
|
||||
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
|
||||
? 'agency'
|
||||
: 'owner';
|
||||
|
||||
return {
|
||||
id: item.id ?? index + 1,
|
||||
title: info.address || `عقار #${item.id || index + 1}`,
|
||||
description: info.description || '',
|
||||
type: propType,
|
||||
price: dailyPrice,
|
||||
priceUSD: dailyPrice,
|
||||
priceUnit: 'daily',
|
||||
price: price,
|
||||
priceUSD: price,
|
||||
priceUnit,
|
||||
listingType: isRentListing ? 'rent' : 'sale',
|
||||
location: {
|
||||
city: extractCity(info.address) || 'دمشق',
|
||||
district: info.address || '',
|
||||
@ -85,7 +97,9 @@ function mapApiProperty(item, index) {
|
||||
priceDisplay: {
|
||||
daily: dailyPrice,
|
||||
monthly: monthlyPrice,
|
||||
sale: salePrice,
|
||||
},
|
||||
ownerSource,
|
||||
bookings: [],
|
||||
_raw: item,
|
||||
};
|
||||
@ -175,6 +189,16 @@ export default function HomePage() {
|
||||
setSearchFilters(filters);
|
||||
|
||||
const filtered = allProperties.filter(property => {
|
||||
if (filters.mode === 'rent' && property.listingType !== 'rent') {
|
||||
return false;
|
||||
}
|
||||
if (filters.mode === 'sell' && property.listingType !== 'sale') {
|
||||
return false;
|
||||
}
|
||||
if (filters.mode === 'buy' && property.listingType !== 'sale') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
|
||||
return false;
|
||||
}
|
||||
@ -194,6 +218,20 @@ export default function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.ownerSource && filters.ownerSource !== 'all') {
|
||||
if (filters.ownerSource === 'owner' && property.ownerSource !== 'owner') return false;
|
||||
if (filters.ownerSource === 'agency' && property.ownerSource !== 'agency') return false;
|
||||
}
|
||||
|
||||
if (filters.rentPeriod && filters.rentPeriod !== 'all' && property.listingType === 'rent') {
|
||||
if (filters.rentPeriod === 'daily' && !property.priceDisplay.daily) return false;
|
||||
if (filters.rentPeriod === 'monthly' && !property.priceDisplay.monthly) return false;
|
||||
}
|
||||
|
||||
if (filters.availableToday) {
|
||||
if (property.status !== 'available') return false;
|
||||
}
|
||||
|
||||
if (filters.identityType && property.allowedIdentities) {
|
||||
if (!property.allowedIdentities.includes(filters.identityType)) {
|
||||
return false;
|
||||
@ -312,7 +350,7 @@ export default function HomePage() {
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} />}
|
||||
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
|
||||
|
||||
{isOwner && (
|
||||
<motion.div
|
||||
@ -477,6 +515,25 @@ export default function HomePage() {
|
||||
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">مصدر العرض: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.ownerSource === 'all' ? 'الكل' :
|
||||
searchFilters.ownerSource === 'owner' ? 'من المالك' : 'من مكتب عقاري'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="text-gray-600">نوع الإيجار: </span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{searchFilters.rentPeriod === 'all' ? 'الكل' :
|
||||
searchFilters.rentPeriod === 'daily' ? 'إيجار يومي' : 'إيجار شهري'}
|
||||
</span>
|
||||
</div>
|
||||
{searchFilters.availableToday && (
|
||||
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||
<span className="font-bold text-gray-900">فقط المتاحة من اليوم</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
96
app/payments/page.js
Normal file
96
app/payments/page.js
Normal file
@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Download, Eye } from 'lucide-react';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import Link from 'next/link';
|
||||
|
||||
const mockPayments = [
|
||||
{
|
||||
id: 1,
|
||||
property: 'فيلا فاخرة في المزة',
|
||||
amount: 2500000,
|
||||
date: '2024-03-10',
|
||||
status: 'completed',
|
||||
invoiceId: 'INV-001'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
property: 'شقة حديثة في الشهباء',
|
||||
amount: 750000,
|
||||
date: '2024-03-05',
|
||||
status: 'completed',
|
||||
invoiceId: 'INV-002'
|
||||
}
|
||||
];
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const router = useRouter();
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (AuthService.isAdmin()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setPayments(mockPayments);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, [router]);
|
||||
|
||||
const formatCurrency = (amount) => amount?.toLocaleString() + ' ل.س';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">جاري التحميل...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">المدفوعات</h1>
|
||||
<p className="text-gray-600">سجل المعاملات المالية والفواتير</p>
|
||||
</div>
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<CreditCard className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد معاملات</h3>
|
||||
<p className="text-gray-500">ستظهر هنا مدفوعاتك للحجوزات</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => (
|
||||
<div key={payment.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 hover:shadow-md transition-all">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{payment.property}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">رقم الفاتورة: {payment.invoiceId}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">{payment.date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-amber-600">{formatCurrency(payment.amount)}</div>
|
||||
<span className="inline-block px-2 py-1 bg-green-100 text-green-800 rounded-lg text-xs mt-1">
|
||||
مكتمل
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,6 +32,9 @@ import {
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getRentProperties, getSaleProperties } from '../utils/api';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import AuthService from '@/app/services/AuthService';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Map API data to UI format
|
||||
function mapApiProperty(item, index) {
|
||||
@ -54,10 +57,10 @@ function mapApiProperty(item, index) {
|
||||
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
|
||||
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('/') ? '' : '/'}${img}`)
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg'];
|
||||
|
||||
return {
|
||||
@ -94,10 +97,26 @@ function extractCity(address) {
|
||||
|
||||
// API-only — no fallback data
|
||||
|
||||
const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const PropertyCard = ({ property, viewMode = 'grid', onLoginRequired }) => {
|
||||
const { isFavorite: checkFavorite, addFavorite, removeFavorite } = useFavorites();
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
|
||||
const isFav = checkFavorite(property.id);
|
||||
|
||||
const toggleFavorite = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!AuthService.isAuthenticated()) { onLoginRequired?.(); return; }
|
||||
setFavLoading(true);
|
||||
if (isFav) {
|
||||
await removeFavorite(property.id);
|
||||
} else {
|
||||
await addFavorite(property.id);
|
||||
}
|
||||
setFavLoading(false);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return amount?.toLocaleString() + ' ل.س';
|
||||
};
|
||||
@ -150,10 +169,11 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
onClick={toggleFavorite}
|
||||
disabled={favLoading}
|
||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -231,10 +251,11 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
onClick={toggleFavorite}
|
||||
disabled={favLoading}
|
||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -472,6 +493,7 @@ export default function PropertiesPage() {
|
||||
const [sortBy, setSortBy] = useState('newest');
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
propertyType: 'all',
|
||||
@ -604,7 +626,7 @@ export default function PropertiesPage() {
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredProperties.map((property) => (
|
||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} />
|
||||
<PropertyCard key={property.id} property={property} viewMode={viewMode} onLoginRequired={() => setShowLoginDialog(true)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -622,6 +644,37 @@ export default function PropertiesPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
{showLoginDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowLoginDialog(false)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl text-center"
|
||||
>
|
||||
<div className="w-14 h-14 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-7 h-7 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">تسجيل الدخول مطلوب</h3>
|
||||
<p className="text-gray-500 mb-6">يجب تسجيل الدخول لإضافة العقارات إلى المفضلة</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLoginDialog(false)}
|
||||
className="flex-1 py-3 border border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
تسجيل الدخول
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -47,7 +47,35 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getRentProperty, getSaleProperty, bookReservation, checkAvailability, getAvailableDateRanges } from '../../utils/api';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums';
|
||||
import RatingForm from '@/app/components/ratings/RatingForm.js';
|
||||
import RatingList from '@/app/components/ratings/RatingList.js';
|
||||
import StarRating from '@/app/components/ratings/StarRating.js';
|
||||
|
||||
// Copy to clipboard that works on HTTP too
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback for HTTP / older browsers
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map API response to the UI format
|
||||
function mapApiDetail(item) {
|
||||
@ -72,10 +100,10 @@ function mapApiDetail(item) {
|
||||
const typeLabels = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' };
|
||||
|
||||
// Extract images from API and build full URLs
|
||||
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
|
||||
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('/') ? '' : '/'}${img}`)
|
||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||
: ['/property-placeholder.jpg', '/villa1.jpg', '/villa2.jpg'];
|
||||
|
||||
return {
|
||||
@ -137,6 +165,7 @@ function mapApiDetail(item) {
|
||||
|
||||
export default function PropertyDetailsPage() {
|
||||
const params = useParams();
|
||||
const { isFavorite, addFavorite, removeFavorite } = useFavorites();
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
const [showContact, setShowContact] = useState(false);
|
||||
const [showShareMenu, setShowShareMenu] = useState(false);
|
||||
@ -148,8 +177,14 @@ export default function PropertyDetailsPage() {
|
||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||
const [availableRanges, setAvailableRanges] = useState([]);
|
||||
const [calendarMonth, setCalendarMonth] = useState(new Date());
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
const [selectingEnd, setSelectingEnd] = useState(false);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [showRatingForm, setShowRatingForm] = useState(false);
|
||||
const [currentRating, setCurrentRating] = useState(0);
|
||||
const [currentComment, setCurrentComment] = useState('');
|
||||
const [userRating, setUserRating] = useState(null);
|
||||
const [canRate, setCanRate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = params.id;
|
||||
@ -191,20 +226,29 @@ export default function PropertyDetailsPage() {
|
||||
fetchProperty();
|
||||
}, [params.id]);
|
||||
|
||||
// Fetch available date ranges
|
||||
// Fetch user rating and check if they can rate
|
||||
useEffect(() => {
|
||||
if (!property) return;
|
||||
const propId = property._raw?.id || params.id;
|
||||
console.log('[Property] Fetching available dates for:', propId);
|
||||
getAvailableDateRanges(propId)
|
||||
.then((data) => {
|
||||
const ranges = Array.isArray(data) ? data : [];
|
||||
console.log('[Property] Available date ranges:', ranges);
|
||||
setAvailableRanges(ranges);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[Property] Failed to fetch available dates:', err);
|
||||
});
|
||||
async function fetchUserRatingAndCheck() {
|
||||
if (!property || !AuthService.isAuthenticated()) return;
|
||||
|
||||
try {
|
||||
// Check if user has already rated
|
||||
const rating = await getUserPropertyRating(property._raw?.id || parseInt(params.id), AuthService.getUserId());
|
||||
if (rating) {
|
||||
setUserRating(rating);
|
||||
setCurrentRating(rating.rating);
|
||||
setCurrentComment(rating.comment || '');
|
||||
}
|
||||
|
||||
// Check if user can rate (e.g., after renting)
|
||||
const canRateProperty = await canRateProperty(property._raw?.id || parseInt(params.id), AuthService.getUserId());
|
||||
setCanRate(canRateProperty);
|
||||
} catch (error) {
|
||||
console.error('[Property] Failed to fetch user rating:', error);
|
||||
}
|
||||
}
|
||||
|
||||
fetchUserRatingAndCheck();
|
||||
}, [property, params.id]);
|
||||
|
||||
// Set Open Graph meta tags dynamically for Facebook/Twitter sharing
|
||||
@ -393,8 +437,24 @@ export default function PropertyDetailsPage() {
|
||||
<span>العودة إلى العقارات</span>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<Heart className="w-5 h-5 text-gray-600" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!AuthService.isAuthenticated()) { setShowLoginDialog(true); return; }
|
||||
const propId = property?._raw?.id || parseInt(params.id);
|
||||
setFavLoading(true);
|
||||
if (isFavorite(propId)) {
|
||||
await removeFavorite(propId);
|
||||
toast.success('تمت الإزالة من المفضلة');
|
||||
} else {
|
||||
await addFavorite(propId);
|
||||
toast.success('تمت الإضافة إلى المفضلة');
|
||||
}
|
||||
setFavLoading(false);
|
||||
}}
|
||||
disabled={favLoading}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<Heart className={`w-5 h-5 ${isFavorite(property?._raw?.id || parseInt(params.id)) ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||
</button>
|
||||
{/* Share Dropdown */}
|
||||
<div className="relative">
|
||||
@ -478,9 +538,13 @@ export default function PropertyDetailsPage() {
|
||||
|
||||
{/* Instagram (copy link) */}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success('تم نسخ الرابط! الصقه في انستغرام');
|
||||
onClick={async () => {
|
||||
const ok = await copyToClipboard(window.location.href);
|
||||
if (ok) {
|
||||
toast.success('تم نسخ الرابط! الصقه في انستغرام');
|
||||
} else {
|
||||
toast.error('فشل نسخ الرابط');
|
||||
}
|
||||
window.open('https://www.instagram.com', '_blank');
|
||||
setShowShareMenu(false);
|
||||
}}
|
||||
@ -494,9 +558,13 @@ export default function PropertyDetailsPage() {
|
||||
|
||||
{/* Copy Link */}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success('تم نسخ الرابط');
|
||||
onClick={async () => {
|
||||
const ok = await copyToClipboard(window.location.href);
|
||||
if (ok) {
|
||||
toast.success('✅ تم نسخ الرابط');
|
||||
} else {
|
||||
toast.error('فشل نسخ الرابط');
|
||||
}
|
||||
setShowShareMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
@ -703,12 +771,17 @@ export default function PropertyDetailsPage() {
|
||||
{property.reviewList.map((review, idx) => (
|
||||
<div key={idx} className="border-b border-gray-100 last:border-0 pb-4 last:pb-0">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className="font-bold text-gray-900">{review.user}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className={`w-4 h-4 ${i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'}`} />
|
||||
))}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-900">{review.user}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className={`w-4 h-4 ${i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{review.date}</span>
|
||||
@ -720,24 +793,32 @@ export default function PropertyDetailsPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{property.rules && property.rules.length > 0 && (
|
||||
{/* New Rating Components */}
|
||||
{AuthService.isAuthenticated() && canRate && !userRating && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
|
||||
transition={{ delay: 0.65 }}
|
||||
className="bg-amber-50 border-2 border-amber-200 rounded-xl p-4 text-center cursor-pointer hover:border-amber-300 transition-all"
|
||||
onClick={() => setShowRatingForm(true)}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900">قوانين المنزل</h2>
|
||||
<ul className="space-y-2">
|
||||
{property.rules.map((rule, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 text-gray-600">
|
||||
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Star className="w-8 h-8 text-amber-500 mx-auto mb-2" />
|
||||
<h3 className="font-bold text-amber-700 mb-2">قيّم هذا العقار</h3>
|
||||
<p className="text-sm text-amber-600">شارك تجربتك مع المستأجرين الآخرين</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-200"
|
||||
>
|
||||
<RatingList
|
||||
propertyId={property._raw?.id || parseInt(params.id)}
|
||||
userId={AuthService.getUserId()}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
@ -1,6 +1,624 @@
|
||||
// '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 {
|
||||
// User, Mail, Phone, Lock, Eye, EyeOff, MessageCircle,
|
||||
// Camera, X, CheckCircle, XCircle, ArrowLeft, Building,
|
||||
// Loader2, Shield, KeyRound
|
||||
// } 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';
|
||||
|
||||
// export default function OwnerRegisterPage() {
|
||||
// const router = useRouter();
|
||||
// const [step, setStep] = useState(1); // 1=form, 2=id images
|
||||
// const [showOtpModal, setShowOtpModal] = useState(false);
|
||||
// const [showPassword, setShowPassword] = useState(false);
|
||||
// const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
// const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// const [formData, setFormData] = useState({
|
||||
// firstName: '',
|
||||
// lastName: '',
|
||||
// email: '',
|
||||
// phone: '',
|
||||
// whatsapp: '',
|
||||
// phone2: '',
|
||||
// nationalNumber: '',
|
||||
// password: '',
|
||||
// confirmPassword: '',
|
||||
// ownerType: OwnerType.PERSON,
|
||||
// agreeTerms: false
|
||||
// });
|
||||
|
||||
// const [idImages, setIdImages] = useState({ front: null, back: null });
|
||||
// const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
|
||||
// const [otpCode, setOtpCode] = useState('');
|
||||
// const [errors, setErrors] = useState({});
|
||||
|
||||
// const fileInputFrontRef = useRef(null);
|
||||
// const fileInputBackRef = useRef(null);
|
||||
|
||||
// const handleImageUpload = (side, file) => {
|
||||
// if (!file) return;
|
||||
// if (!file.type.startsWith('image/')) {
|
||||
// toast.error('الرجاء اختيار صورة صالحة');
|
||||
// return;
|
||||
// }
|
||||
// if (file.size > 5 * 1024 * 1024) {
|
||||
// toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
|
||||
// return;
|
||||
// }
|
||||
// const reader = new FileReader();
|
||||
// reader.onloadend = () => {
|
||||
// setIdImagePreviews(prev => ({ ...prev, [side]: reader.result }));
|
||||
// };
|
||||
// reader.readAsDataURL(file);
|
||||
// setIdImages(prev => ({ ...prev, [side]: file }));
|
||||
// console.log('[OwnerRegister] Image uploaded:', side);
|
||||
// toast.success('تم رفع الصورة بنجاح', { 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 = 'الاسم الأول مطلوب';
|
||||
// if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
|
||||
|
||||
|
||||
// if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
// else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
|
||||
// if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
|
||||
// else if (!validatePhone(formData.whatsapp)) newErrors.whatsapp = 'رقم الواتساب غير صالح (يجب أن يبدأ 09 أو 05)';
|
||||
|
||||
// if (formData.phone && !validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح';
|
||||
|
||||
// if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
|
||||
// else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
|
||||
|
||||
// if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
|
||||
// setErrors(newErrors);
|
||||
// return Object.keys(newErrors).length === 0;
|
||||
// };
|
||||
|
||||
// const validateStep2 = () => {
|
||||
// const newErrors = {};
|
||||
// if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
|
||||
// if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
|
||||
// setErrors(newErrors);
|
||||
// return Object.keys(newErrors).length === 0;
|
||||
// };
|
||||
|
||||
// const handleNextStep = () => {
|
||||
// if (validateStep1()) {
|
||||
// console.log('[OwnerRegister] Step 1 valid, moving to step 2');
|
||||
// setStep(2);
|
||||
// window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// } else {
|
||||
// toast.error('يرجى تصحيح الأخطاء في النموذج');
|
||||
// }
|
||||
// };
|
||||
|
||||
// // ─── Main signup handler ───
|
||||
// const handleSubmit = async (e) => {
|
||||
// e.preventDefault();
|
||||
|
||||
// if (!validateStep2()) {
|
||||
// toast.error('يرجى إكمال جميع الصور المطلوبة');
|
||||
// return;
|
||||
// }
|
||||
// if (!formData.agreeTerms) {
|
||||
// toast.error('يجب الموافقة على الشروط والأحكام');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// setIsLoading(true);
|
||||
// console.log('[OwnerRegister] Submitting owner registration...');
|
||||
|
||||
// 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 res = await addOwner(payload, idImages.front, idImages.back);
|
||||
// console.log('[OwnerRegister] addOwner response:', res);
|
||||
|
||||
// if (res.status === 200 || res.ok) {
|
||||
// const tempToken = res.data;
|
||||
// if (tempToken) {
|
||||
// AuthService.addToken(tempToken);
|
||||
// console.log('[OwnerRegister] Temp token stored for OTP');
|
||||
// }
|
||||
|
||||
// const apiMessage = res.message || res.data?.message;
|
||||
// toast.success(apiMessage || 'تم إنشاء الحساب! يرجى التحقق من بريدك الإلكتروني', { duration: 4000 });
|
||||
|
||||
// // Auto-login to trigger OTP
|
||||
// console.log('[OwnerRegister] Auto-login to send OTP...');
|
||||
// const loginRes = await loginWithEmail(formData.email, formData.password);
|
||||
// console.log('[OwnerRegister] login response:', loginRes);
|
||||
|
||||
// if (loginRes.status === 206) {
|
||||
// const otpToken = loginRes.data;
|
||||
// if (otpToken) AuthService.addToken(otpToken);
|
||||
// const loginMsg = loginRes.message || loginRes.data?.message;
|
||||
// toast(loginMsg || 'تم إرسال رمز التحقق إلى بريدك الإلكتروني', { icon: '📧' });
|
||||
// setShowOtpModal(true);
|
||||
// } else if (loginRes.status === 200) {
|
||||
// const loginToken = loginRes.data;
|
||||
// if (loginToken) AuthService.addToken(loginToken);
|
||||
// toast.success(loginRes.message || 'تم تسجيل الدخول بنجاح!');
|
||||
// router.push('/');
|
||||
// }
|
||||
// } else {
|
||||
// const errMsg = res.message || res.data?.message || 'فشل في إنشاء الحساب';
|
||||
// console.error('[OwnerRegister] Registration failed:', errMsg);
|
||||
// toast.error(errMsg);
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.error('[OwnerRegister] Error:', err);
|
||||
// toast.error(err.message || 'حدث خطأ أثناء التسجيل');
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // ─── OTP verification handler ───
|
||||
// const handleVerifyOTP = async () => {
|
||||
// if (!otpCode || otpCode.length < 4) {
|
||||
// toast.error('يرجى إدخال رمز التحقق');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// setIsLoading(true);
|
||||
// console.log('[OwnerRegister] Verifying OTP:', otpCode);
|
||||
|
||||
// try {
|
||||
// const res = await verifyEmail(otpCode);
|
||||
// console.log('[OwnerRegister] VerifyEmail response:', res);
|
||||
|
||||
// if (res.status === 200) {
|
||||
// AuthService.deleteToken();
|
||||
// console.log('[OwnerRegister] Temp token removed after verification');
|
||||
// toast.success(res.message || 'تم التحقق من البريد الإلكتروني بنجاح!', { duration: 3000 });
|
||||
// setShowOtpModal(false);
|
||||
// setTimeout(() => router.push('/login'), 1500);
|
||||
// } else {
|
||||
// const errMsg = res.message || res.data?.message || 'رمز التحقق غير صحيح';
|
||||
// console.error('[OwnerRegister] Verification failed:', errMsg);
|
||||
// toast.error(errMsg);
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.error('[OwnerRegister] Verify error:', err);
|
||||
// toast.error(err.message || 'حدث خطأ أثناء التحقق');
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleResendOTP = async () => {
|
||||
// setIsLoading(true);
|
||||
// console.log('[OwnerRegister] Resending email OTP...');
|
||||
// try {
|
||||
// await sendEmailOTP();
|
||||
// toast.success('تم إرسال رمز تحقق جديد');
|
||||
// } catch (err) {
|
||||
// console.error('[OwnerRegister] Resend OTP error:', err);
|
||||
// toast.error('فشل في إرسال الرمز');
|
||||
// } 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 = [
|
||||
// { 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-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">
|
||||
// {[...Array(20)].map((_, i) => (
|
||||
// <motion.div key={i} className="absolute rounded-full bg-amber-500/10"
|
||||
// style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
|
||||
// animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
|
||||
// transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
|
||||
// ))}
|
||||
// </div> */}
|
||||
|
||||
// <div className="absolute inset-0 overflow-hidden">
|
||||
// {backgroundElements}
|
||||
// </div>
|
||||
|
||||
// <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
|
||||
// className="relative z-10 w-full max-w-2xl">
|
||||
// {/* Progress */}
|
||||
// <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>العودة</span>
|
||||
// </Link>
|
||||
// <span className="text-sm text-gray-400">خطوة {step} من 2</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 ? 'معلومات المالك' : 'الوثائق الرسمية'}
|
||||
// </h1>
|
||||
// <p className="text-amber-100">
|
||||
// {step === 1 ? 'أدخل معلوماتك الأساسية' : 'يرجى رفع صور الهوية للتحقق'}
|
||||
// </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: Form ─── */}
|
||||
// {step === 1 && (
|
||||
// <>
|
||||
// <motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
|
||||
// <div>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
|
||||
// <div className="relative group">
|
||||
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
// <User className={`w-5 h-5 ${errors.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="الاسم الأول" />
|
||||
// </div>
|
||||
// {errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
|
||||
// </div>
|
||||
// <div>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">اسم العائلة <span className="text-red-500">*</span></label>
|
||||
// <input type="text" value={formData.lastName}
|
||||
// onChange={(e) => { setFormData({...formData, lastName: e.target.value}); setErrors({...errors, lastName: null}); }}
|
||||
// className={`w-full px-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.lastName ? 'border-red-500' : 'border-gray-700'}`}
|
||||
// placeholder="اسم العائلة" />
|
||||
// {errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
|
||||
// </div>
|
||||
// </motion.div>
|
||||
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">البريد الإلكتروني <span className="text-red-500">*</span></label>
|
||||
// <div className="relative group">
|
||||
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
// <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="أدخل بريدك الإلكتروني" />
|
||||
// </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">رقم الهاتف <span className="text-gray-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 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="أدخل رقم هاتفك (اختياري)" />
|
||||
// </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">رقم الواتساب <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="أدخل رقم الواتساب" />
|
||||
// </div>
|
||||
// {errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
|
||||
// </motion.div>
|
||||
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
|
||||
// <div className="relative group">
|
||||
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
// <Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-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="أدخل رقم الهاتف" maxLength={7} />
|
||||
// </div>
|
||||
// {errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
|
||||
// </motion.div>
|
||||
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">الرقم الوطني <span className="text-red-500">*</span></label>
|
||||
// <div className="relative group">
|
||||
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
// <User className={`w-5 h-5 ${errors.nationalNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-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="أدخل الرقم الوطني" />
|
||||
// </div>
|
||||
// {errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
|
||||
// </motion.div>
|
||||
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">نوع المالك <span className="text-red-500">*</span></label>
|
||||
// <select value={formData.ownerType}
|
||||
// onChange={(e) => setFormData({...formData, ownerType: 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-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>
|
||||
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
|
||||
// <div className="relative group">
|
||||
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
// <Lock className={`w-5 h-5 ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-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="أدخل كلمة المرور" />
|
||||
// <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">تأكيد كلمة المرور <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="أعد إدخال كلمة المرور" />
|
||||
// <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: ID Images ─── */}
|
||||
// {step === 2 && (
|
||||
// <>
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الأمامي <span className="text-red-500">*</span></label>
|
||||
// <div onClick={() => fileInputFrontRef.current?.click()}
|
||||
// className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.front ? 'border-green-500 bg-green-500/10' : errors.front ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-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="Front ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
// <button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, front: null})); setIdImagePreviews(prev => ({...prev, front: ''})); }}
|
||||
// className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
// <X className="w-4 h-4 text-white" />
|
||||
// </button>
|
||||
// </div>
|
||||
// ) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
|
||||
// </div>
|
||||
// {errors.front && <p className="text-red-500 text-sm mt-1">{errors.front}</p>}
|
||||
// </motion.div>
|
||||
|
||||
// <motion.div variants={fadeInUp}>
|
||||
// <label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الخلفي <span className="text-red-500">*</span></label>
|
||||
// <div onClick={() => fileInputBackRef.current?.click()}
|
||||
// className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.back ? 'border-green-500 bg-green-500/10' : errors.back ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-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="Back ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
|
||||
// <button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, back: null})); setIdImagePreviews(prev => ({...prev, back: ''})); }}
|
||||
// className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
|
||||
// <X className="w-4 h-4 text-white" />
|
||||
// </button>
|
||||
// </div>
|
||||
// ) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
|
||||
// </div>
|
||||
// {errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
|
||||
// </motion.div>
|
||||
|
||||
// <motion.div variants={fadeInUp} className="flex items-center gap-2">
|
||||
// <input type="checkbox" id="terms" checked={formData.agreeTerms}
|
||||
// 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">
|
||||
// أوافق على <Link href="/terms" className="text-amber-400 hover:text-amber-300">شروط الاستخدام</Link> و <Link href="/privacy" className="text-amber-400 hover:text-amber-300">سياسة الخصوصية</Link>
|
||||
// </label>
|
||||
// </motion.div>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {/* ─── Buttons ─── */}
|
||||
// <motion.div variants={fadeInUp} className="flex gap-3 pt-4">
|
||||
// {step === 1 ? (
|
||||
// <>
|
||||
// <button type="button" onClick={() => router.push('/auth/choose-role')}
|
||||
// className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">إلغاء</button>
|
||||
// <button type="submit"
|
||||
// className="flex-1 bg-gradient-to-r from-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">التالي</button>
|
||||
// </>
|
||||
// ) : (
|
||||
// <>
|
||||
// <button type="button" onClick={() => setStep(1)}
|
||||
// className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">السابق</button>
|
||||
// <button type="submit" disabled={isLoading || !formData.agreeTerms}
|
||||
// className="flex-1 bg-gradient-to-r from-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>جاري التسجيل...</span></div>) : 'إنشاء حساب'}
|
||||
// </button>
|
||||
// </>
|
||||
// )}
|
||||
// </motion.div>
|
||||
// </motion.form>
|
||||
// </div>
|
||||
// </motion.div>
|
||||
// </motion.div>
|
||||
|
||||
// {/* ─── OTP Modal ─── */}
|
||||
// <AnimatePresence>
|
||||
// {showOtpModal && (
|
||||
// <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
// className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
// <motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
// className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
// <div className="text-center mb-6">
|
||||
// <div className="w-16 h-16 bg-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">التحقق من البريد</h2>
|
||||
// <p className="text-gray-400 text-sm mt-1">تم إرسال رمز التحقق إلى</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">رمز التحقق</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>جاري التحقق...</span></> : 'تحقق'}
|
||||
// </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">
|
||||
// إعادة إرسال الرمز
|
||||
// </button>
|
||||
// </motion.div>
|
||||
// </motion.div>
|
||||
// )}
|
||||
// </AnimatePresence>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
@ -8,7 +626,7 @@ import Image from 'next/image';
|
||||
import {
|
||||
User, Mail, Phone, Lock, Eye, EyeOff, MessageCircle,
|
||||
Camera, X, CheckCircle, XCircle, ArrowLeft, Building,
|
||||
Loader2, Shield, KeyRound
|
||||
Loader2, Shield, KeyRound, Briefcase, FileText, MapPin
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { addOwner, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
|
||||
@ -17,7 +635,7 @@ import { OwnerType, OwnerTypeLabels } from '../../enums';
|
||||
|
||||
export default function OwnerRegisterPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1); // 1=form, 2=id images
|
||||
const [step, setStep] = useState(1);
|
||||
const [showOtpModal, setShowOtpModal] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
@ -34,16 +652,21 @@ export default function OwnerRegisterPage() {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
ownerType: OwnerType.PERSON,
|
||||
licenseNumber: '',
|
||||
companyAddress: '',
|
||||
agreeTerms: false
|
||||
});
|
||||
|
||||
const [idImages, setIdImages] = useState({ front: null, back: null });
|
||||
const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
|
||||
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 = formData.ownerType === OwnerType.REAL_ESTATE_AGENCY;
|
||||
|
||||
const handleImageUpload = (side, file) => {
|
||||
if (!file) return;
|
||||
@ -73,7 +696,6 @@ export default function OwnerRegisterPage() {
|
||||
if (!formData.firstName) newErrors.firstName = 'الاسم الأول مطلوب';
|
||||
if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
|
||||
|
||||
|
||||
if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
|
||||
else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
|
||||
|
||||
@ -87,6 +709,11 @@ export default function OwnerRegisterPage() {
|
||||
|
||||
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
|
||||
if (isCompany) {
|
||||
if (!formData.licenseNumber) newErrors.licenseNumber = 'رقم الرخصة/السجل التجاري مطلوب';
|
||||
if (!formData.companyAddress) newErrors.companyAddress = 'عنوان المكتب مطلوب';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@ -95,6 +722,7 @@ export default function OwnerRegisterPage() {
|
||||
const newErrors = {};
|
||||
if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
|
||||
if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
|
||||
if (isCompany && !idImages.license) newErrors.license = 'صورة الرخصة/السجل التجاري مطلوبة';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@ -109,7 +737,6 @@ export default function OwnerRegisterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Main signup handler ───
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -137,8 +764,13 @@ export default function OwnerRegisterPage() {
|
||||
ownerType: formData.ownerType,
|
||||
};
|
||||
|
||||
if (isCompany) {
|
||||
payload.licenseNumber = formData.licenseNumber;
|
||||
payload.companyAddress = formData.companyAddress;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await addOwner(payload, idImages.front, idImages.back);
|
||||
const res = await addOwner(payload, idImages.front, idImages.back, isCompany ? idImages.license : null);
|
||||
console.log('[OwnerRegister] addOwner response:', res);
|
||||
|
||||
if (res.status === 200 || res.ok) {
|
||||
@ -181,7 +813,7 @@ export default function OwnerRegisterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── OTP verification handler ───
|
||||
// OTP verification handler
|
||||
const handleVerifyOTP = async () => {
|
||||
if (!otpCode || otpCode.length < 4) {
|
||||
toast.error('يرجى إدخال رمز التحقق');
|
||||
@ -238,64 +870,53 @@ export default function OwnerRegisterPage() {
|
||||
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 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 = [
|
||||
{ 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-amber-500/10"
|
||||
style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
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-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">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div key={i} className="absolute rounded-full bg-amber-500/10"
|
||||
style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
|
||||
animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
|
||||
transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{backgroundElements}
|
||||
</div>
|
||||
<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">
|
||||
@ -339,7 +960,6 @@ const backgroundElements = useMemo(() => {
|
||||
onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
|
||||
className="space-y-6">
|
||||
|
||||
{/* ─── STEP 1: Form ─── */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
|
||||
@ -447,6 +1067,45 @@ const backgroundElements = useMemo(() => {
|
||||
</select>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCompany && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 overflow-hidden"
|
||||
>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الرخصة/السجل التجاري <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<FileText className={`w-5 h-5 ${errors.licenseNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
</div>
|
||||
<input type="text" value={formData.licenseNumber}
|
||||
onChange={(e) => { setFormData({...formData, licenseNumber: e.target.value}); setErrors({...errors, licenseNumber: 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.licenseNumber ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل رقم الرخصة أو السجل التجاري" />
|
||||
</div>
|
||||
{errors.licenseNumber && <p className="text-red-500 text-sm mt-1">{errors.licenseNumber}</p>}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">عنوان المكتب <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<MapPin className={`w-5 h-5 ${errors.companyAddress ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
||||
</div>
|
||||
<input type="text" value={formData.companyAddress}
|
||||
onChange={(e) => { setFormData({...formData, companyAddress: e.target.value}); setErrors({...errors, companyAddress: 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.companyAddress ? 'border-red-500' : 'border-gray-700'}`}
|
||||
placeholder="أدخل عنوان المكتب" />
|
||||
</div>
|
||||
{errors.companyAddress && <p className="text-red-500 text-sm mt-1">{errors.companyAddress}</p>}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
|
||||
<div className="relative group">
|
||||
@ -488,7 +1147,6 @@ const backgroundElements = useMemo(() => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── STEP 2: ID Images ─── */}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<motion.div variants={fadeInUp}>
|
||||
@ -527,6 +1185,35 @@ const backgroundElements = useMemo(() => {
|
||||
{errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCompany && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">صورة الرخصة/السجل التجاري <span className="text-red-500">*</span></label>
|
||||
<div onClick={() => 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' : errors.license ? 'border-red-500 bg-red-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="License" 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">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
|
||||
</div>
|
||||
{errors.license && <p className="text-red-500 text-sm mt-1">{errors.license}</p>}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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})}
|
||||
@ -538,7 +1225,7 @@ const backgroundElements = useMemo(() => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Buttons ─── */}
|
||||
{/* Buttons */}
|
||||
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
@ -563,7 +1250,7 @@ const backgroundElements = useMemo(() => {
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── OTP Modal ─── */}
|
||||
{/* OTP Modal */}
|
||||
<AnimatePresence>
|
||||
{showOtpModal && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
@ -609,4 +1296,4 @@ const backgroundElements = useMemo(() => {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
226
app/reservations/page.js
Normal file
226
app/reservations/page.js
Normal file
@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
|
||||
MapPin, DollarSign, Home, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import AuthService from '../services/AuthService';
|
||||
import { getRentProperty } from '../utils/api';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
|
||||
|
||||
const STATUS_UI = {
|
||||
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
ownerConfirmed: { label: 'مؤكد من المالك', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
||||
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
|
||||
depositConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
completed: { label: 'منتهي', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
||||
};
|
||||
|
||||
function statusLabel(code) { return STATUS_UI[STATUS_MAP[code]]?.label ?? String(code); }
|
||||
function statusColor(code) { return STATUS_UI[STATUS_MAP[code]]?.color ?? 'bg-gray-100 text-gray-700'; }
|
||||
function statusIcon(code) { return STATUS_UI[STATUS_MAP[code]]?.icon ?? Clock; }
|
||||
|
||||
function StatusBadge({ code }) {
|
||||
const Icon = statusIcon(code);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${statusColor(code)}`}>
|
||||
<Icon className="w-3 h-3" /> {statusLabel(code)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function enrich(reservation) {
|
||||
if (!reservation.propertyId) return reservation;
|
||||
try {
|
||||
const prop = await getRentProperty(reservation.propertyId);
|
||||
reservation._prop = prop?.propertyInformation ?? prop ?? null;
|
||||
} catch { /* skip */ }
|
||||
return reservation;
|
||||
}
|
||||
|
||||
const propAddr = (p) => p?.address ?? '';
|
||||
const propImages = (p) => Array.isArray(p?.images) ? p.images : [];
|
||||
const propBeds = (p) => p?.numberOfBedRooms ?? 0;
|
||||
const propBaths = (p) => p?.numberOfBathRooms ?? 0;
|
||||
|
||||
function ReservationCard({ r, onViewDetails }) {
|
||||
const p = r._prop;
|
||||
const imgs = propImages(p);
|
||||
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
||||
const addr = propAddr(p);
|
||||
const beds = propBeds(p);
|
||||
const baths = propBaths(p);
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity:0,y:20 }} animate={{ opacity:1,y:0 }}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
|
||||
<div className="p-5">
|
||||
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover" /></div>}
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<StatusBadge code={r.status} />
|
||||
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString() ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">السعر الإجمالي</div>
|
||||
</div>
|
||||
</div>
|
||||
{(beds||baths) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{beds>0&&<span>{beds} غرف</span>}{baths>0&&<span>{baths} حمامات</span>}</div>}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
|
||||
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
|
||||
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-3 border-t border-gray-100">
|
||||
<button onClick={() => onViewDetails(r)}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||
<Eye className="w-4 h-4"/> التفاصيل
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsModal({ r, isOpen, onClose }) {
|
||||
if (!isOpen || !r) return null;
|
||||
const p = r._prop;
|
||||
|
||||
return (
|
||||
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
|
||||
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
|
||||
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold">تفاصيل الحجز</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
||||
</div>
|
||||
<p className="text-amber-100 text-sm mt-1">رقم الحجز: #{r.id}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{p && <div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
|
||||
<p><span className="text-gray-500">العنوان:</span> {propAddr(p)||'—'}</p>
|
||||
{(propBeds(p)||propBaths(p)) && <div className="flex gap-3 mt-2">
|
||||
{propBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBeds(p)} غرف</span>}
|
||||
{propBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBaths(p)} حمامات</span>}
|
||||
</div>}
|
||||
</div>}
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
|
||||
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
|
||||
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
|
||||
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl">
|
||||
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/> المعلومات المالية</h3>
|
||||
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??'—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserReservationsPage() {
|
||||
const router = useRouter();
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [filtered, setFiltered] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
|
||||
|
||||
const loadReservations = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/Reservations/GetUserResevations`, {
|
||||
headers: { Authorization: `Bearer ${AuthService.getToken()}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
let list = json.data || json || [];
|
||||
if (!Array.isArray(list)) list = [];
|
||||
const enriched = await Promise.all(list.map(enrich));
|
||||
setReservations(enriched);
|
||||
setFiltered(enriched);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('فشل تحميل الحجوزات');
|
||||
setReservations([]);
|
||||
setFiltered([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let r = reservations;
|
||||
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
|
||||
if (searchTerm) { const q = searchTerm.toLowerCase(); r = r.filter(x => propAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q)); }
|
||||
setFiltered(r);
|
||||
}, [reservations, filterStatus, searchTerm]);
|
||||
|
||||
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
|
||||
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
|
||||
<p className="text-gray-600">لديك {reservations.length} حجز</p>
|
||||
</motion.div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{Object.entries(counts).map(([s, c]) => (
|
||||
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
|
||||
onClick={() => setFilterStatus(s)}>
|
||||
<div className="text-2xl font-bold text-amber-600">{c}</div>
|
||||
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-6 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
|
||||
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
|
||||
<p className="text-gray-600">لم تقم بأي حجز حتى الآن</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filtered.map(r => <ReservationCard key={r.id} r={r} onViewDetails={setSelected} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -93,6 +93,18 @@ const AuthService = Object.freeze({
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current authenticated user id
|
||||
* @returns {number|string|null}
|
||||
*/
|
||||
getUserId() {
|
||||
const user = this.getUser();
|
||||
if (!user?.id) return null;
|
||||
|
||||
const parsedId = Number(user.id);
|
||||
return Number.isFinite(parsedId) ? parsedId : user.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get roles array from JWT
|
||||
* @returns {string[]}
|
||||
|
||||
106
app/utils/api.js
106
app/utils/api.js
@ -1,6 +1,6 @@
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api';
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
/**
|
||||
* Generic API fetch — attaches auth token, unwraps { data } envelope
|
||||
@ -134,7 +134,7 @@ export async function getAvailableDateRanges(propertyId) {
|
||||
}
|
||||
|
||||
export async function getReservations() {
|
||||
return apiFetch('/Reservations/GetReservations');
|
||||
return apiFetch('/Reservations/GetAllReservations');
|
||||
}
|
||||
|
||||
export async function getReservation(id) {
|
||||
@ -177,9 +177,9 @@ export async function getOwnerByUserId(userId) {
|
||||
|
||||
// ─── Properties ───
|
||||
|
||||
export async function getMyRentListings(userId) {
|
||||
console.log('[API] Fetching my rent listings for user:', userId);
|
||||
return apiFetch(`/RentProperties/GetMyRentListings/${userId}`);
|
||||
export async function getMyRentListings() {
|
||||
console.log('[API] Fetching my rent listings');
|
||||
return apiFetch(`/RentProperties/GetMyRentListings`);
|
||||
}
|
||||
|
||||
export async function addRentProperty(data) {
|
||||
@ -352,3 +352,99 @@ export function isEmail(value) {
|
||||
export function isPhoneNumber(value) {
|
||||
return /^\+?\d{7,15}$/.test(value.replace(/[\s\-()]/g, ''));
|
||||
}
|
||||
|
||||
// ─── Favorites ───
|
||||
|
||||
export async function getUserFavoriteProperties() {
|
||||
return apiFetch('/FavoriteProperty/GetUserFavoriteProperties');
|
||||
}
|
||||
|
||||
export async function addFavoriteProperty(propId) {
|
||||
return apiFetch(`/FavoriteProperty/Add?propId=${propId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function removeFavoriteProperty(favePropId) {
|
||||
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getUserNotifications() {
|
||||
return apiFetch('/Notifications/GetUserNotifications');
|
||||
}
|
||||
|
||||
// ─── Booking/Reservation Management ───
|
||||
|
||||
export async function confirmDepositPayment(bookingId) {
|
||||
return apiFetch('/Reservations/ConfirmDepositPayment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ bookingId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
|
||||
const token = AuthService.getToken();
|
||||
const endpoint = `${API_BASE}/Reservations/AdminConfirmDeposit/admin-confirm-deposit`;
|
||||
const normalizedComment =
|
||||
typeof comment === 'string' && comment.trim()
|
||||
? comment.trim()
|
||||
: null;
|
||||
const payload = {
|
||||
reservationId,
|
||||
adminId,
|
||||
comment: normalizedComment,
|
||||
};
|
||||
|
||||
console.log('[API] AdminConfirmDeposit request', {
|
||||
method: 'PUT',
|
||||
endpoint,
|
||||
payload,
|
||||
adminIdSource: 'jwt-user-id',
|
||||
hasToken: Boolean(token),
|
||||
tokenPreview: token ? `${token.slice(0, 18)}...${token.slice(-8)}` : null,
|
||||
});
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
|
||||
console.log('[API] AdminConfirmDeposit raw response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
endpoint,
|
||||
rawText: text,
|
||||
});
|
||||
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
if (data && typeof data === 'object' && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
const message = typeof data === 'object' && data?.message ? data.message : null;
|
||||
|
||||
console.log('[API] AdminConfirmDeposit parsed response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
message,
|
||||
data,
|
||||
});
|
||||
|
||||
return { status: res.status, data, ok: res.ok, message };
|
||||
}
|
||||
|
||||
export async function updateBookingStatus(bookingId, status) {
|
||||
return apiFetch('/Reservations/UpdateStatus', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ bookingId, status }),
|
||||
});
|
||||
}
|
||||
|
||||
85
app/utils/firebase.js
Normal file
85
app/utils/firebase.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { initializeApp, getApps } from "firebase/app";
|
||||
import { getMessaging, getToken, onMessage } from "firebase/messaging";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
};
|
||||
|
||||
// Initialize Firebase (avoid duplicate init in SSR)
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
|
||||
// Get messaging instance (only works in browser)
|
||||
let messaging = null;
|
||||
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
|
||||
try {
|
||||
messaging = getMessaging(app);
|
||||
} catch (e) {
|
||||
console.warn("[Firebase] Messaging init failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Request notification permission and get FCM token
|
||||
export async function requestNotificationPermission() {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
console.log("[FCM] Notification permission denied");
|
||||
return null;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
|
||||
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
|
||||
console.log("[FCM] Token:", token);
|
||||
|
||||
// Send token to backend
|
||||
if (token) {
|
||||
try {
|
||||
const authToken = localStorage.getItem("auth_token");
|
||||
if (authToken) {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
|
||||
await fetch(`${apiBase}/User/SetFCMToken`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token, deviceType: 2 }), // 2 = Web
|
||||
});
|
||||
console.log("[FCM] Token sent to backend");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[FCM] Failed to send token to backend:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (err) {
|
||||
console.error("[FCM] Error getting token:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for foreground messages
|
||||
export function onForegroundMessage(callback) {
|
||||
if (!messaging) return () => {};
|
||||
|
||||
return onMessage(messaging, (payload) => {
|
||||
console.log("[FCM] Foreground message:", payload);
|
||||
callback(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export { app, messaging };
|
||||
193
app/utils/ratings.js
Normal file
193
app/utils/ratings.js
Normal file
@ -0,0 +1,193 @@
|
||||
// Rating API endpoints for SweetHome
|
||||
// Handles both customer ratings and property ratings
|
||||
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||
|
||||
/**
|
||||
* Rate a property as a customer
|
||||
* @param {Object} data - Rating data
|
||||
* @param {number} data.propertyId - ID of the property being rated
|
||||
* @param {number} data.customerId - ID of the customer doing the rating
|
||||
* @param {number} data.rating - Rating value (1-5)
|
||||
* @param {string} data.comment - Optional comment
|
||||
* @returns {Promise} - API response
|
||||
*/
|
||||
export async function rateProperty(data) {
|
||||
console.log('[Rating] Customer rating property:', data);
|
||||
return apiFetch('/Ratings/CustomerRateProperty', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a customer as a property owner
|
||||
* @param {Object} data - Rating data
|
||||
* @param {number} data.propertyId - ID of the property
|
||||
* @param {number} data.customerId - ID of the customer being rated
|
||||
* @param {number} data.rating - Rating value (1-5)
|
||||
* @param {string} data.comment - Optional comment
|
||||
* @returns {Promise} - API response
|
||||
*/
|
||||
export async function rateCustomer(data) {
|
||||
console.log('[Rating] Property owner rating customer:', data);
|
||||
return apiFetch('/Ratings/PropertyRateCustomer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ratings for a property
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @returns {Promise} - Array of ratings
|
||||
*/
|
||||
export async function getPropertyRatings(propertyId) {
|
||||
console.log('[Rating] Fetching property ratings for:', propertyId);
|
||||
return apiFetch(`/Ratings/GetPropertyRatings?propertyId=${propertyId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ratings for a customer
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @returns {Promise} - Array of ratings
|
||||
*/
|
||||
export async function getCustomerRatings(customerId) {
|
||||
console.log('[Rating] Fetching customer ratings for:', customerId);
|
||||
return apiFetch(`/Ratings/GetCustomerRatings?customerId=${customerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average rating for a property
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @returns {Promise} - Average rating
|
||||
*/
|
||||
export async function getPropertyAverageRating(propertyId) {
|
||||
console.log('[Rating] Fetching average rating for property:', propertyId);
|
||||
const ratings = await getPropertyRatings(propertyId);
|
||||
if (!Array.isArray(ratings) || ratings.length === 0) return 0;
|
||||
|
||||
const total = ratings.reduce((sum, rating) => sum + rating.rating, 0);
|
||||
return Math.round((total / ratings.length) * 10) / 10; // Round to 1 decimal
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average rating for a customer
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @returns {Promise} - Average rating
|
||||
*/
|
||||
export async function getCustomerAverageRating(customerId) {
|
||||
console.log('[Rating] Fetching average rating for customer:', customerId);
|
||||
const ratings = await getCustomerRatings(customerId);
|
||||
if (!Array.isArray(ratings) || ratings.length === 0) return 0;
|
||||
|
||||
const total = ratings.reduce((sum, rating) => sum + rating.rating, 0);
|
||||
return Math.round((total / ratings.length) * 10) / 10; // Round to 1 decimal
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's rating for a specific property (if any)
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @param {number} userId - ID of the user
|
||||
* @returns {Promise} - User's rating or null
|
||||
*/
|
||||
export async function getUserPropertyRating(propertyId, userId) {
|
||||
console.log('[Rating] Fetching user rating for property:', propertyId, 'user:', userId);
|
||||
const allRatings = await getPropertyRatings(propertyId);
|
||||
if (!Array.isArray(allRatings)) return null;
|
||||
|
||||
return allRatings.find(r => r.userId === userId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's rating for a specific customer (if any)
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @param {number} userId - ID of the user
|
||||
* @returns {Promise} - User's rating or null
|
||||
*/
|
||||
export async function getUserCustomerRating(customerId, userId) {
|
||||
console.log('[Rating] Fetching user rating for customer:', customerId, 'user:', userId);
|
||||
const allRatings = await getCustomerRatings(customerId);
|
||||
if (!Array.isArray(allRatings)) return null;
|
||||
|
||||
return allRatings.find(r => r.userId === userId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can rate a property (after renting)
|
||||
* @param {number} propertyId - ID of the property
|
||||
* @param {number} userId - ID of the user
|
||||
* @returns {Promise} - Boolean indicating if rating is allowed
|
||||
*/
|
||||
export async function canRateProperty(propertyId, userId) {
|
||||
console.log('[Rating] Checking if user can rate property:', propertyId, 'user:', userId);
|
||||
|
||||
// Logic: User can rate if they have completed a rental in the past
|
||||
// This would typically check reservation history
|
||||
// For now, we'll simulate this with a simple check
|
||||
|
||||
// In a real implementation, this would check:
|
||||
// 1. User's reservation history for this property
|
||||
// 2. Whether the rental period has ended
|
||||
// 3. Whether they've already rated
|
||||
|
||||
const userRating = await getUserPropertyRating(propertyId, userId);
|
||||
return !userRating; // Can rate if no existing rating
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can rate a customer (after renting to them)
|
||||
* @param {number} customerId - ID of the customer
|
||||
* @param {number} userId - ID of the user (owner)
|
||||
* @returns {Promise} - Boolean indicating if rating is allowed
|
||||
*/
|
||||
export async function canRateCustomer(customerId, userId) {
|
||||
console.log('[Rating] Checking if user can rate customer:', customerId, 'user:', userId);
|
||||
|
||||
// Logic: Owner can rate if they have rented to this customer
|
||||
// This would typically check reservation history
|
||||
|
||||
const userRating = await getUserCustomerRating(customerId, userId);
|
||||
return !userRating; // Can rate if no existing rating
|
||||
}
|
||||
|
||||
// Helper function for API calls
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = AuthService.getToken();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
console.log('[Rating API] Request:', options.method || 'GET', `${API_BASE}${endpoint}`);
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
console.log('[Rating API] Response:', res.status, endpoint);
|
||||
|
||||
if (!res.ok && res.status !== 206) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[Rating API] Error:', res.status, text);
|
||||
throw new Error(`Rating API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json && typeof json === 'object' && 'data' in json) {
|
||||
return json.data;
|
||||
}
|
||||
return json;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,20 @@
|
||||
const nextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "45.93.137.91.nip.io",
|
||||
pathname: "/api/Pictures/**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "45.93.137.91",
|
||||
pathname: "/api/Pictures/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
// basePath: "/sweetHome",
|
||||
// assetPrefix: "/sweetHome/",
|
||||
};
|
||||
|
||||
1057
package-lock.json
generated
1057
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@pbe/react-yandex-maps": "^1.2.5",
|
||||
"firebase": "^12.11.0",
|
||||
"flowbite": "^4.0.1",
|
||||
"flowbite-react": "^0.12.16",
|
||||
"framer-motion": "^12.29.2",
|
||||
|
||||
38
public/firebase-messaging-sw.js
Normal file
38
public/firebase-messaging-sw.js
Normal file
@ -0,0 +1,38 @@
|
||||
// Firebase Cloud Messaging Service Worker
|
||||
// This file MUST be in the public/ directory (served at /firebase-messaging-sw.js)
|
||||
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js");
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js");
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
|
||||
authDomain: "sweet-home-b2766.firebaseapp.com",
|
||||
projectId: "sweet-home-b2766",
|
||||
storageBucket: "sweet-home-b2766.firebasestorage.app",
|
||||
messagingSenderId: "602865114600",
|
||||
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
|
||||
measurementId: "G-M2V95NBJLX",
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
console.log("[FCM SW] Background message:", payload);
|
||||
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
|
||||
const options = {
|
||||
body: payload.notification?.body || payload.data?.body || "",
|
||||
icon: payload.notification?.icon || "/logo.png",
|
||||
badge: "/logo.png",
|
||||
data: payload.data,
|
||||
tag: "sweethome-notification",
|
||||
};
|
||||
self.registration.showNotification(title, options);
|
||||
});
|
||||
|
||||
// Handle notification click
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/";
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
});
|
||||
Reference in New Issue
Block a user