Compare commits

...

34 Commits

Author SHA1 Message Date
97126c5776 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 54s
2026-04-22 10:52:19 +03:00
1e167c447a Edit profits for owner 2026-04-22 10:52:08 +03:00
dd0a9c401d readdded the getuserId function
All checks were successful
Build frontend / build (push) Successful in 58s
2026-04-17 14:40:47 +03:00
32f6c7af5a fixed the api request
All checks were successful
Build frontend / build (push) Successful in 1m7s
2026-04-16 22:49:15 +03:00
7e0d5eaf8d edited the api request
All checks were successful
Build frontend / build (push) Successful in 40s
2026-04-16 22:40:59 +03:00
beccd8b24f added debugging on the admin confirm
All checks were successful
Build frontend / build (push) Successful in 41s
2026-04-16 22:33:19 +03:00
7e9a9d79f2 there is no endpoint in name /Reservations/GetReservations
All checks were successful
Build frontend / build (push) Successful in 41s
2026-04-16 22:13:14 +03:00
39f494aecb fixed some things
All checks were successful
Build frontend / build (push) Successful in 42s
2026-04-16 22:06:57 +03:00
485baffdc2 fixed some things
All checks were successful
Build frontend / build (push) Successful in 55s
2026-04-16 21:30:22 +03:00
c46173d7c6 fixed some things
All checks were successful
Build frontend / build (push) Successful in 45s
2026-04-16 21:18:31 +03:00
04fa34107b linked the admin confirm deposte
All checks were successful
Build frontend / build (push) Successful in 1m9s
2026-04-16 21:15:21 +03:00
5a7d0ef265 Added confirm button for admin
All checks were successful
Build frontend / build (push) Successful in 46s
2026-04-15 12:28:01 +03:00
0ba435fd7e Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 1m56s
2026-04-15 12:10:45 +03:00
9c2a748ae9 Added API for notifications and edit style 2026-04-15 12:07:39 +03:00
db949aaeba Fix build errors: corrected import paths, added missing RatingList component, fixed syntax errors in rating components
All checks were successful
Build frontend / build (push) Successful in 54s
2026-04-14 14:23:17 +00:00
ae600ad41b Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
Some checks failed
Build frontend / build (push) Failing after 43s
2026-04-13 00:38:59 +03:00
16b1c7c6f6 Edit home 2026-04-13 00:25:29 +03:00
f761ab6f48 Removed double <<
Some checks failed
Build frontend / build (push) Failing after 46s
2026-04-12 20:50:02 +00:00
78138e6445 Commited by hamza on openclaw's belalf
Some checks failed
Build frontend / build (push) Failing after 54s
2026-04-12 20:44:04 +00:00
0891974440 fix: properly enrich reservations with property data using status integer codes
All checks were successful
Build frontend / build (push) Successful in 1m21s
2026-04-05 19:11:59 +00:00
f925af0272 fix: access property via propertyInformation nested object
All checks were successful
Build frontend / build (push) Successful in 48s
2026-04-05 18:46:47 +00:00
2346f518ce feat: enrich reservation pages with property details via GetRentPropertyById
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-05 18:40:09 +00:00
149058ddfc fix: route حجزات nav links to new reservations pages
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-05 18:09:11 +00:00
e6249e845e fix: match backend typos GetOwnerResevationRequests (missing r)
All checks were successful
Build frontend / build (push) Successful in 42s
2026-04-05 17:52:08 +00:00
3bdb99f2e5 feat: add user reservations page and owner reservation requests page
All checks were successful
Build frontend / build (push) Successful in 49s
2026-04-05 14:42:11 +00:00
2a1f00740f disable export report in owner
All checks were successful
Build frontend / build (push) Successful in 1m8s
2026-04-04 22:47:05 +03:00
50836d3ec7 Edit AddPropertyForm
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-04 21:50:31 +03:00
d850f921b5 Edit add admin and privacy
All checks were successful
Build frontend / build (push) Successful in 49s
2026-04-04 21:01:02 +03:00
77dd052951 Added add admin and privacy in sidebar for admin
All checks were successful
Build frontend / build (push) Successful in 1m20s
2026-04-04 20:56:49 +03:00
1207dbe20d removed the validation from the email
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-02 16:05:20 +03:00
c9f52f64cb removed the validation from the email
All checks were successful
Build frontend / build (push) Successful in 1m23s
2026-04-02 16:00:05 +03:00
5fd22f0e01 disabled the validation on email
All checks were successful
Build frontend / build (push) Successful in 1m9s
2026-04-02 14:44:23 +03:00
2998a6bd75 fix: import useMapEvents as hook instead of dynamic component
All checks were successful
Build frontend / build (push) Successful in 45s
2026-04-01 19:07:22 +00:00
571c85f14f fix: detect auth changes via polling and visibility change
All checks were successful
Build frontend / build (push) Successful in 52s
2026-04-01 15:29:41 +00:00
23 changed files with 3382 additions and 1467 deletions

View File

@ -6,6 +6,7 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { NavLink, MobileNavLink } from "./components/NavLinks"; import { NavLink, MobileNavLink } from "./components/NavLinks";
import { FavoritesProvider } from '@/app/contexts/FavoritesContext'; import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
import FloatingSidebar from '@/app/components/FloatingSidebar'; import FloatingSidebar from '@/app/components/FloatingSidebar';
import { import {
Globe, Globe,
@ -248,7 +249,7 @@ export default function ClientLayout({ children }) {
عقاراتي عقاراتي
</span> </span>
</NavLink> </NavLink>
<NavLink href="/owner/bookings"> <NavLink href="/owner/reservations">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
الحجوزات الحجوزات
@ -398,7 +399,7 @@ export default function ClientLayout({ children }) {
</Link> </Link>
<Link <Link
href="/owner/bookings" href="/owner/reservations"
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
onClick={() => setShowUserMenu(false)} onClick={() => setShowUserMenu(false)}
> >
@ -522,7 +523,7 @@ export default function ClientLayout({ children }) {
<div className="border-t border-gray-100 my-2"></div> <div className="border-t border-gray-100 my-2"></div>
<Link <Link
href="/tenant/bookings" href="/reservations"
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
onClick={() => setShowUserMenu(false)} onClick={() => setShowUserMenu(false)}
> >
@ -650,7 +651,7 @@ export default function ClientLayout({ children }) {
</span> </span>
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/owner/bookings" href="/owner/reservations"
onClick={closeMobileMenu} onClick={closeMobileMenu}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -708,10 +709,12 @@ export default function ClientLayout({ children }) {
<main <main
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`} className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
> >
<FavoritesProvider> <NotificationsProvider>
{children} <FavoritesProvider>
{!isAdmin && <FloatingSidebar isRTL={currentLanguage === 'ar'} />} {children}
</FavoritesProvider> <FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
</FavoritesProvider>
</NotificationsProvider>
</main> </main>
{!isAuthPage && !isProfilePage && ( {!isAuthPage && !isProfilePage && (

113
app/admin/add-admin/page.js Normal file
View 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
View 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>
);
}

View File

@ -3,11 +3,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import { Heart, Bell, CreditCard } from 'lucide-react'; import { Heart, Bell, CreditCard, Shield, UserPlus } from 'lucide-react';
import { useFavorites } from '@/app/contexts/FavoritesContext'; import { useFavorites } from '@/app/contexts/FavoritesContext';
import { useNotifications } from '@/app/contexts/NotificationsContext';
export default function FloatingSidebar({ isRTL }) { export default function FloatingSidebar({ isRTL, isAdmin }) {
const { favorites } = useFavorites(); const { favorites } = useFavorites();
const { unreadCount } = useNotifications();
const [tooltip, setTooltip] = useState(null); const [tooltip, setTooltip] = useState(null);
let timeoutId = null; let timeoutId = null;
@ -40,6 +42,24 @@ export default function FloatingSidebar({ isRTL }) {
tap: { scale: 0.95 }, 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 ( return (
<motion.div <motion.div
className="fixed z-50" className="fixed z-50"
@ -48,117 +68,122 @@ export default function FloatingSidebar({ isRTL }) {
initial="initial" initial="initial"
animate="animate" animate="animate"
> >
<div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/50 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/90"> <div className="bg-white/90 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/60 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/95">
<motion.div {isAdmin ? (
className="relative group" <>
variants={buttonVariants} <motion.div
initial="rest" className="relative group"
whileHover="hover" variants={buttonVariants}
whileTap="tap" initial="rest"
onMouseEnter={() => showTooltip('favorites')} whileHover="hover"
onMouseLeave={hideTooltip} whileTap="tap"
> onMouseEnter={() => showTooltip('addAdmin')}
<Link onMouseLeave={hideTooltip}
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-gradient-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
>
{favorites.length}
</motion.span>
)}
</div>
</Link>
{tooltip === 'favorites' && (
<div
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
> >
<span className="relative"> <Link
المفضلة href="/admin/add-admin"
<span 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"
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>
)}
</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" />
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -right-1 -top-1 w-5 h-5 bg-gradient-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
> >
3 <UserPlus className="w-6 h-6" />
</motion.span> </Link>
</div> {renderTooltip('addAdmin', 'إضافة أدمن')}
</Link> </motion.div>
{tooltip === 'notifications' && (
<div <motion.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`} className="relative group"
variants={buttonVariants}
initial="rest"
whileHover="hover"
whileTap="tap"
onMouseEnter={() => showTooltip('editPrivacy')}
onMouseLeave={hideTooltip}
> >
<span className="relative"> <Link
الإشعارات href="/admin/privacy"
<span 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"
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' <Shield className="w-6 h-6" />
}`} </Link>
></span> {renderTooltip('editPrivacy', 'تعديل سياسة الخصوصية')}
</span> </motion.div>
</div> </>
)} ) : (
</motion.div> <>
<motion.div <motion.div
className="relative group" className="relative group"
variants={buttonVariants} variants={buttonVariants}
initial="rest" initial="rest"
whileHover="hover" whileHover="hover"
whileTap="tap" whileTap="tap"
onMouseEnter={() => showTooltip('payments')} onMouseEnter={() => showTooltip('favorites')}
onMouseLeave={hideTooltip} 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>
{tooltip === 'payments' && (
<div
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
> >
<span className="relative"> <Link
المدفوعات href="/favorites"
<span className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
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' <div className="relative">
}`} <Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
></span> {favorites.length > 0 && (
</span> <motion.span
</div> initial={{ scale: 0 }}
)} animate={{ scale: 1 }}
</motion.div> 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> </div>
</motion.div> </motion.div>
); );

View File

@ -23,11 +23,11 @@ export default function NotificationHandler() {
const initialized = useRef(false); const initialized = useRef(false);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { function checkAuth() {
if (initialized.current) return; if (initialized.current) return;
initialized.current = true;
if (!AuthService.getToken()) return; if (!AuthService.getToken()) return;
initialized.current = true;
if ("Notification" in window) { if ("Notification" in window) {
if (Notification.permission === "default") { if (Notification.permission === "default") {
@ -36,9 +36,28 @@ export default function NotificationHandler() {
setupFCM(); 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); }, 1000);
return () => clearTimeout(timer); // Check on route change (visibility)
const onVisibility = () => {
if (document.visibilityState === "visible") checkAuth();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []); }, []);
async function setupFCM() { async function setupFCM() {

View File

@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useProperties } from '@/app/contexts/PropertyContext'; 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'; import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
export default function AddPropertyForm({ onClose, onSuccess }) { export default function AddPropertyForm({ onClose, onSuccess }) {
@ -131,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
required required
> >
<option value="">اختر المدينة</option> <option value="">اختر المدينة</option>
{Object.values(CITIES).map(city => ( {CitiesList.map(city => (
<option key={city} value={city}>{city}</option> <option key={city} value={city}>{city}</option>
))} ))}
</select> </select>

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,24 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search, MapPin, Home, DollarSign } from 'lucide-react'; import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
export default function HeroSearch({ onSearch }) { export default function HeroSearch({ onSearch, isAuthenticated }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('rent'); const [activeTab, setActiveTab] = useState('buy');
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
city: '', city: 'all',
propertyType: '', propertyType: 'all',
priceRange: '', priceRange: 'all',
identityType: 'syrian' identityType: 'syrian',
ownerSource: 'all',
rentPeriod: 'all',
availableToday: false
}); });
const [showLoginDialog, setShowLoginDialog] = useState(false);
const cities = [ const cities = [
{ id: 'all', label: 'جميع المدن' }, { id: 'all', label: 'جميع المدن' },
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
const propertyTypes = [ const propertyTypes = [
{ id: 'all', label: 'الكل' }, { id: 'all', label: 'الكل' },
{ id: 'apartment', label: 'شقة' }, { id: 'apartment', label: 'شقق سكنية' },
{ id: 'villa', label: 'فيلا' }, { id: 'studio', label: 'استوديو' },
{ id: 'house', label: 'بيت' }, { id: 'commercial', label: 'عقار تجاري' },
{ id: 'studio', label: 'استوديو' } { id: 'villa', label: 'فيلا / مزرعة' }
]; ];
const priceRanges = [ const priceRanges = [
@ -46,17 +51,45 @@ export default function HeroSearch({ onSearch }) {
{ id: 'passport', label: 'جواز سفر' } { id: 'passport', label: 'جواز سفر' }
]; ];
const ownerSources = [
{ id: 'all', label: 'الكل' },
{ id: 'owner', label: 'من المالك' },
{ id: 'agency', label: 'من مكتب عقاري' }
];
const rentPeriods = [
{ id: 'all', label: 'الكل' },
{ id: 'daily', label: 'إيجار يومي' },
{ id: 'monthly', label: 'إيجار شهري' }
];
const handleTabClick = (tab) => {
setActiveTab(tab);
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
setShowLoginDialog(true);
}
};
const handleSearch = () => { const handleSearch = () => {
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
setShowLoginDialog(true);
return;
}
onSearch({ onSearch({
...filters, ...filters,
propertyType: filters.propertyType || 'all', mode: activeTab,
city: filters.city || 'all', city: filters.city || 'all',
priceRange: filters.priceRange || 'all' propertyType: filters.propertyType || 'all',
priceRange: filters.priceRange || 'all',
ownerSource: filters.ownerSource || 'all',
rentPeriod: filters.rentPeriod || 'all'
}); });
}; };
return ( return (
<motion.div <>
<motion.div
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl" 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 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
{['rent', 'buy', 'sell'].map((tab) => ( {['rent', 'buy', 'sell'].map((tab) => (
<motion.button <motion.button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => handleTabClick(tab)}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${ className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
activeTab === tab activeTab === tab
? 'bg-amber-500 text-white' ? 'bg-amber-500 text-white'
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
</select> </select>
</div> </div>
</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"> <div className="mt-6">
<motion.button <motion.button
onClick={handleSearch} onClick={handleSearch}
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
</motion.button> </motion.button>
</div> </div>
</motion.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>
)}
</>
); );
} }

View 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;

View 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;

View 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 'ضعيف';
}

View 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>
);
}

View File

@ -1,10 +1,10 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from "framer-motion";
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from "react-hot-toast";
import Link from 'next/link'; import Link from "next/link";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { import {
Mail, Mail,
Lock, Lock,
@ -18,7 +18,7 @@ import {
Shield, Shield,
Phone, Phone,
KeyRound, KeyRound,
} from 'lucide-react'; } from "lucide-react";
import { import {
loginWithEmail, loginWithEmail,
loginWithPhone, loginWithPhone,
@ -30,44 +30,45 @@ import {
isPhoneNumber, isPhoneNumber,
getOwnerByUserId, getOwnerByUserId,
getCustomerByUserId, getCustomerByUserId,
} from '../utils/api'; } from "../utils/api";
import AuthService from '../services/AuthService'; import AuthService from "../services/AuthService";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
// Step: 'login' | 'otp' // Step: 'login' | 'otp'
const [step, setStep] = useState('login'); const [step, setStep] = useState("login");
const [loginMethod, setLoginMethod] = useState('email'); // 'email' | 'phone' const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false); const [isSuccess, setIsSuccess] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
credential: '', credential: "",
password: '', password: "",
rememberMe: false, rememberMe: false,
}); });
const [otpCode, setOtpCode] = useState(''); const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState(''); const [otpError, setOtpError] = useState("");
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const validateForm = () => { const validateForm = () => {
const newErrors = {}; const newErrors = {};
if (!formData.credential) { if (!formData.credential) {
newErrors.credential = loginMethod === 'email' newErrors.credential =
? 'البريد الإلكتروني مطلوب' loginMethod === "email"
: 'رقم الهاتف مطلوب'; ? "البريد الإلكتروني مطلوب"
} else if (loginMethod === 'email' && !isEmail(formData.credential)) { : "رقم الهاتف مطلوب";
newErrors.credential = 'البريد الإلكتروني غير صالح'; // } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
} else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) { // newErrors.credential = 'البريد الإلكتروني غير صالح';
newErrors.credential = 'رقم الهاتف غير صالح'; // } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
newErrors.credential = "رقم الهاتف غير صالح";
} }
if (!formData.password) { if (!formData.password) {
newErrors.password = 'كلمة المرور مطلوبة'; newErrors.password = "كلمة المرور مطلوبة";
} }
setErrors(newErrors); setErrors(newErrors);
@ -82,17 +83,25 @@ export default function LoginPage() {
setErrors({}); setErrors({});
try { try {
const loginFn = loginMethod === 'email' ? loginWithEmail : loginWithPhone; const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
console.log('[Login] Attempting login via', loginMethod, ':', formData.credential); console.log(
"[Login] Attempting login via",
loginMethod,
":",
formData.credential,
);
const result = await loginFn(formData.credential, formData.password); 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) { 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); AuthService.addToken(token);
console.log('[Login] Token stored'); console.log("[Login] Token stored");
// Fetch user profile to get full name // Fetch user profile to get full name
const authUser = AuthService.getUser(); const authUser = AuthService.getUser();
@ -103,71 +112,81 @@ export default function LoginPage() {
const profile = await fetchFn(authUser.id); const profile = await fetchFn(authUser.id);
if (profile) { if (profile) {
AuthService.cacheUser({ 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, email: profile.email || authUser.email,
phone: profile.phone || profile.phoneNumber || authUser.phone, phone: profile.phone || profile.phoneNumber || authUser.phone,
}); });
console.log('[Login] User profile cached'); console.log("[Login] User profile cached");
} }
} catch (err) { } catch (err) {
console.warn('[Login] Failed to fetch profile:', err); console.warn("[Login] Failed to fetch profile:", err);
} }
} }
const userRole = AuthService.isAdmin() ? 'admin' const userRole = AuthService.isAdmin()
: AuthService.isOwner() ? 'owner' ? "admin"
: 'customer'; : AuthService.isOwner()
console.log('[Login] User role:', userRole); ? "owner"
: "customer";
console.log("[Login] User role:", userRole);
setIsSuccess(true); setIsSuccess(true);
toast.success('تم تسجيل الدخول بنجاح!', { toast.success("تم تسجيل الدخول بنجاح!", {
style: { background: '#dcfce7', color: '#166534' }, style: { background: "#dcfce7", color: "#166534" },
}); });
setTimeout(() => { setTimeout(() => {
if (userRole === 'admin') { if (userRole === "admin") {
router.push('/admin'); router.push("/admin");
} else { } else {
router.push('/'); router.push("/");
} }
}, 1500); }, 1500);
} else if (result.status === 206) { } else if (result.status === 206) {
console.log('[Login] 206 — OTP required'); console.log("[Login] 206 — OTP required");
const tempToken = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken; const tempToken =
typeof result.data === "string"
? result.data
: result.data?.token || result.data?.accessToken;
if (tempToken) { if (tempToken) {
AuthService.addToken(tempToken); AuthService.addToken(tempToken);
console.log('[Login] Temp token stored for OTP'); console.log("[Login] Temp token stored for OTP");
} }
toast('يرجى إدخال رمز التحقق', { toast("يرجى إدخال رمز التحقق", {
icon: '🔐', icon: "🔐",
style: { background: '#fef3c7', color: '#92400e' }, style: { background: "#fef3c7", color: "#92400e" },
}); });
// Send OTP // Send OTP
try { try {
if (loginMethod === 'email') { if (loginMethod === "email") {
await sendEmailOTP(); await sendEmailOTP();
} else { } else {
await sendPhoneOTP(); await sendPhoneOTP();
} }
console.log('[Login] OTP sent successfully'); console.log("[Login] OTP sent successfully");
} catch (otpErr) { } 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 { } else {
// Other error // Other error
console.error('[Login] Unexpected status:', result.status, result.data); console.error("[Login] Unexpected status:", result.status, result.data);
toast.error(result.data?.message || result.data || 'بيانات الدخول غير صحيحة', { toast.error(
style: { background: '#fee2e2', color: '#991b1b' }, result.data?.message || result.data || "بيانات الدخول غير صحيحة",
}); {
style: { background: "#fee2e2", color: "#991b1b" },
},
);
} }
} catch (err) { } catch (err) {
console.error('[Login] Error:', err); console.error("[Login] Error:", err);
toast.error(err.message || 'حدث خطأ في الاتصال', { toast.error(err.message || "حدث خطأ في الاتصال", {
style: { background: '#fee2e2', color: '#991b1b' }, style: { background: "#fee2e2", color: "#991b1b" },
}); });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -177,43 +196,46 @@ export default function LoginPage() {
const handleVerifyOTP = async (e) => { const handleVerifyOTP = async (e) => {
e.preventDefault(); e.preventDefault();
if (!otpCode || otpCode.length < 4) { if (!otpCode || otpCode.length < 4) {
setOtpError('يرجى إدخال رمز التحقق'); setOtpError("يرجى إدخال رمز التحقق");
return; return;
} }
setIsLoading(true); setIsLoading(true);
setOtpError(''); setOtpError("");
try { try {
const verifyFn = loginMethod === 'email' ? verifyEmail : verifyPhone; const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
console.log('[OTP] Verifying code:', otpCode); console.log("[OTP] Verifying code:", otpCode);
const result = await verifyFn(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) { if (result.ok) {
const finalToken = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken; const finalToken =
if (finalToken && typeof finalToken === 'string') { typeof result.data === "string"
? result.data
: result.data?.token || result.data?.accessToken;
if (finalToken && typeof finalToken === "string") {
AuthService.addToken(finalToken); AuthService.addToken(finalToken);
console.log('[OTP] Final token stored'); console.log("[OTP] Final token stored");
} }
setIsSuccess(true); setIsSuccess(true);
toast.success('تم التحقق بنجاح!', { toast.success("تم التحقق بنجاح!", {
style: { background: '#dcfce7', color: '#166534' }, style: { background: "#dcfce7", color: "#166534" },
}); });
setTimeout(() => { setTimeout(() => {
console.log('[OTP] Redirecting to home'); console.log("[OTP] Redirecting to home");
router.push('/'); router.push("/");
}, 1500); }, 1500);
} else { } else {
console.error('[OTP] Verification failed:', result.data); console.error("[OTP] Verification failed:", result.data);
setOtpError(result.data?.message || 'رمز التحقق غير صحيح'); setOtpError(result.data?.message || "رمز التحقق غير صحيح");
} }
} catch (err) { } catch (err) {
console.error('[OTP] Error:', err); console.error("[OTP] Error:", err);
setOtpError(err.message || 'حدث خطأ في التحقق'); setOtpError(err.message || "حدث خطأ في التحقق");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -221,18 +243,18 @@ export default function LoginPage() {
const resendOTP = async () => { const resendOTP = async () => {
try { try {
console.log('[OTP] Resending OTP via', loginMethod); console.log("[OTP] Resending OTP via", loginMethod);
if (loginMethod === 'email') { if (loginMethod === "email") {
await sendEmailOTP(); await sendEmailOTP();
} else { } else {
await sendPhoneOTP(); await sendPhoneOTP();
} }
toast.success('تم إرسال رمز التحقق مجدداً', { toast.success("تم إرسال رمز التحقق مجدداً", {
style: { background: '#dcfce7', color: '#166534' }, style: { background: "#dcfce7", color: "#166534" },
}); });
} catch (err) { } catch (err) {
console.error('[OTP] Resend failed:', err); console.error("[OTP] Resend failed:", err);
toast.error('فشل إرسال الرمز'); toast.error("فشل إرسال الرمز");
} }
}; };
@ -242,11 +264,11 @@ export default function LoginPage() {
if (errors.credential) setErrors({ ...errors, credential: null }); if (errors.credential) setErrors({ ...errors, credential: null });
// Auto-switch method // Auto-switch method
if (isEmail(value)) { // if (isEmail(value)) {
setLoginMethod('email'); // setLoginMethod('email');
} else if (isPhoneNumber(value)) { // } else if (isPhoneNumber(value)) {
setLoginMethod('phone'); // setLoginMethod('phone');
} // }
}; };
const particles = Array.from({ length: 20 }, (_, i) => ({ const particles = Array.from({ length: 20 }, (_, i) => ({
@ -271,7 +293,7 @@ export default function LoginPage() {
visible: { visible: {
y: 0, y: 0,
opacity: 1, opacity: 1,
transition: { type: 'spring', stiffness: 100 }, transition: { type: "spring", stiffness: 100 },
}, },
}; };
@ -285,9 +307,23 @@ export default function LoginPage() {
<motion.div <motion.div
key={p.id} key={p.id}
className="absolute rounded-full bg-amber-500/20" className="absolute rounded-full bg-amber-500/20"
style={{ left: `${p.x}%`, top: `${p.y}%`, width: p.size, height: p.size }} style={{
animate={{ y: [0, -20, 0], x: [0, 10, -10, 0], opacity: [0.2, 0.4, 0.2] }} left: `${p.x}%`,
transition={{ duration: p.duration, repeat: Infinity, delay: p.delay, ease: 'linear' }} 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> </div>
@ -312,8 +348,14 @@ export default function LoginPage() {
> >
{/* Back link */} {/* Back link */}
<motion.div variants={itemVariants} className="absolute -top-16 left-0"> <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"> <Link
<motion.div whileHover={{ x: -5 }} transition={{ type: 'spring', stiffness: 400 }}> 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" /> <ArrowLeft className="w-4 h-4" />
</motion.div> </motion.div>
<span>العودة للرئيسية</span> <span>العودة للرئيسية</span>
@ -329,23 +371,28 @@ export default function LoginPage() {
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
animate={{ scale: 1 }} 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" className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full"
/> />
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
animate={{ scale: 1 }} 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" 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 <motion.div
animate={{ rotate: [0, 10, -10, 0] }} animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 2, repeat: Infinity }} 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" 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" /> <KeyRound className="w-10 h-10 text-white" />
) : ( ) : (
<Home 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> </motion.div>
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1> <h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
<p className="text-amber-100"> <p className="text-amber-100">
{step === 'otp' ? 'أدخل رمز التحقق' : 'مرحباً بعودتك!'} {step === "otp" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
</p> </p>
</motion.div> </motion.div>
</div> </div>
<div className="p-8"> <div className="p-8">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{step === 'login' ? ( {step === "login" ? (
<motion.form <motion.form
key="login" key="login"
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
@ -374,13 +421,13 @@ export default function LoginPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setLoginMethod('email'); setLoginMethod("email");
setFormData({ ...formData, credential: '' }); setFormData({ ...formData, credential: "" });
}} }}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${ className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
loginMethod === 'email' loginMethod === "email"
? 'bg-amber-500 text-white shadow-lg' ? "bg-amber-500 text-white shadow-lg"
: 'text-gray-400 hover:text-white' : "text-gray-400 hover:text-white"
}`} }`}
> >
<Mail className="w-4 h-4" /> <Mail className="w-4 h-4" />
@ -389,13 +436,13 @@ export default function LoginPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setLoginMethod('phone'); setLoginMethod("phone");
setFormData({ ...formData, credential: '' }); setFormData({ ...formData, credential: "" });
}} }}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${ className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
loginMethod === 'phone' loginMethod === "phone"
? 'bg-amber-500 text-white shadow-lg' ? "bg-amber-500 text-white shadow-lg"
: 'text-gray-400 hover:text-white' : "text-gray-400 hover:text-white"
}`} }`}
> >
<Phone className="w-4 h-4" /> <Phone className="w-4 h-4" />
@ -406,29 +453,46 @@ export default function LoginPage() {
{/* Credential input */} {/* Credential input */}
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
{loginMethod === 'email' ? 'البريد الإلكتروني' : 'رقم الهاتف'} {loginMethod === "email"
? "البريد الإلكتروني"
: "رقم الهاتف"}
</label> </label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
{loginMethod === 'email' ? ( {loginMethod === "email" ? (
<Mail className={`w-5 h-5 transition-colors ${errors.credential ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} /> <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> </div>
<input <input
type={loginMethod === 'email' ? 'email' : 'tel'} type="text"
// type={loginMethod === 'email' ? 'email' : 'tel'}
value={formData.credential} value={formData.credential}
onChange={(e) => handleCredentialChange(e.target.value)} 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 ${ 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" dir="ltr"
/> />
</div> </div>
{errors.credential && ( {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} {errors.credential}
</motion.p> </motion.p>
)} )}
@ -436,24 +500,36 @@ export default function LoginPage() {
{/* Password */} {/* Password */}
<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>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 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> </div>
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
value={formData.password} value={formData.password}
onChange={(e) => { onChange={(e) => {
setFormData({ ...formData, password: e.target.value }); setFormData({
if (errors.password) setErrors({ ...errors, password: null }); ...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 ${ 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="أدخل كلمة المرور" 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 ? ( {showPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" /> <EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
) : ( ) : (
@ -462,7 +538,11 @@ export default function LoginPage() {
</button> </button>
</div> </div>
{errors.password && ( {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} {errors.password}
</motion.p> </motion.p>
)} )}
@ -474,12 +554,22 @@ export default function LoginPage() {
<input <input
type="checkbox" type="checkbox"
checked={formData.rememberMe} 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" 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> </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> </Link>
</div> </div>
@ -525,7 +615,7 @@ export default function LoginPage() {
<div className="text-center mb-4"> <div className="text-center mb-4">
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" /> <Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
<p className="text-gray-300 text-sm"> <p className="text-gray-300 text-sm">
تم إرسال رمز التحقق إلى{' '} تم إرسال رمز التحقق إلى{" "}
<span className="text-white font-medium" dir="ltr"> <span className="text-white font-medium" dir="ltr">
{formData.credential} {formData.credential}
</span> </span>
@ -533,23 +623,29 @@ export default function LoginPage() {
</div> </div>
<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 <input
type="text" type="text"
value={otpCode} value={otpCode}
onChange={(e) => { onChange={(e) => {
setOtpCode(e.target.value); 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 ${ 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="______" placeholder="______"
maxLength={6} maxLength={6}
dir="ltr" dir="ltr"
/> />
{otpError && ( {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} {otpError}
</motion.p> </motion.p>
)} )}
@ -586,10 +682,10 @@ export default function LoginPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setStep('login'); setStep("login");
setOtpCode(''); setOtpCode("");
setOtpError(''); setOtpError("");
console.log('[OTP] Going back to login'); console.log("[OTP] Going back to login");
}} }}
className="text-gray-400 hover:text-white transition-colors" className="text-gray-400 hover:text-white transition-colors"
> >
@ -607,20 +703,39 @@ export default function LoginPage() {
)} )}
</AnimatePresence> </AnimatePresence>
<motion.p variants={itemVariants} className="text-center text-gray-400 mt-6"> <motion.p
ليس لديك حساب؟{' '} variants={itemVariants}
<Link href="/auth/choose-role" className="text-amber-400 hover:text-amber-300 font-medium transition-colors"> 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> </Link>
</motion.p> </motion.p>
</div> </div>
</motion.div> </motion.div>
<motion.p variants={itemVariants} className="text-center text-gray-500 text-xs mt-4"> <motion.p
بتسجيل الدخول، أنت توافق على{' '} variants={itemVariants}
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors">شروط الاستخدام</Link> className="text-center text-gray-500 text-xs mt-4"
{' '}و{' '} >
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">سياسة الخصوصية</Link> بتسجيل الدخول، أنت توافق على{" "}
<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.p>
</motion.div> </motion.div>
</div> </div>

View File

@ -4,71 +4,28 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react'; import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
import AuthService from '@/app/services/AuthService'; import AuthService from '@/app/services/AuthService';
import { useNotifications } from '@/app/contexts/NotificationsContext';
const mockNotifications = [
{
id: 1,
type: 'booking',
title: 'تأكيد الحجز',
message: 'تم تأكيد حجزك في فيلا المزة للفترة 10-15 مارس',
date: '2024-03-01',
read: false,
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-50'
},
{
id: 2,
type: 'payment',
title: 'دفعة مستلمة',
message: 'تم استلام دفعة الإيجار بقيمة 500,000 ل.س',
date: '2024-02-28',
read: false,
icon: MessageCircle,
color: 'text-blue-600',
bgColor: 'bg-blue-50'
},
{
id: 3,
type: 'reminder',
title: 'تذكير بالإيجار',
message: 'ينتهي عقد الإيجار خلال 3 أيام',
date: '2024-02-25',
read: true,
icon: Calendar,
color: 'text-amber-600',
bgColor: 'bg-amber-50'
}
];
export default function NotificationsPage() { export default function NotificationsPage() {
const router = useRouter(); const router = useRouter();
const [notifications, setNotifications] = useState([]); const { notifications, unreadCount, isLoading } = useNotifications();
const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
if (AuthService.isAdmin()) { if (!AuthService.isAuthenticated()) {
router.push('/'); router.push('/login');
return; return;
} }
setTimeout(() => {
setNotifications(mockNotifications);
setIsLoading(false);
}, 500);
}, [router]); }, [router]);
const markAsRead = (id) => { const markAsRead = (id) => {
setNotifications(prev => // This will be handled by context if needed
prev.map(n => (n.id === id ? { ...n, read: true } : n))
);
}; };
const markAllAsRead = () => { const markAllAsRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, read: true }))); // This will be handled by context if needed
}; };
const unreadCount = notifications.filter(n => !n.read).length;
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@ -80,6 +37,18 @@ export default function NotificationsPage() {
); );
} }
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 ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-4xl"> <div className="container mx-auto px-4 max-w-4xl">
@ -100,30 +69,31 @@ export default function NotificationsPage() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{notifications.map((notification) => { {notifications.map((notification, index) => (
const Icon = notification.icon; <div
return ( key={index}
<div className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
key={notification.id} >
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">
<div className="p-5 flex gap-4"> <Bell className="w-6 h-6 text-blue-600" />
<div className={`w-12 h-12 ${notification.bgColor} rounded-full flex items-center justify-center flex-shrink-0`}> </div>
<Icon className={`w-6 h-6 ${notification.color}`} /> <div className="flex-1">
</div> <div className="flex justify-between items-start">
<div className="flex-1"> <div>
<div className="flex justify-between items-start"> <h3 className="font-bold text-gray-900">{notification.title}</h3>
<div> {notification.message && (
<h3 className="font-bold text-gray-900">{notification.title}</h3>
<p className="text-gray-600 text-sm mt-1">{notification.message}</p> <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> <p className="text-xs text-gray-400 mt-2">{notification.date}</p>
</div> )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); </div>
})} ))}
</div> </div>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -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 TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { 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 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 }) { function MapClickHandler({ onMapClick }) {
const map = useMapEvents({ const map = useMapEvents({

View 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>
);
}

View File

@ -38,10 +38,15 @@ import AuthService from './services/AuthService';
function mapApiProperty(item, index) { function mapApiProperty(item, index) {
const info = item.propertyInformation || {}; const info = item.propertyInformation || {};
const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0; const dailyPrice = item.dailyRent ?? 0;
const monthlyPrice = item.monthlyRent ?? 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 status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
const features = []; const features = [];
@ -58,14 +63,21 @@ function mapApiProperty(item, index) {
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`) ? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
: ['/property-placeholder.jpg']; : ['/property-placeholder.jpg'];
const ownerSource = info.ownerType == null && item.ownerType == null
? 'all'
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
? 'agency'
: 'owner';
return { return {
id: item.id ?? index + 1, id: item.id ?? index + 1,
title: info.address || `عقار #${item.id || index + 1}`, title: info.address || `عقار #${item.id || index + 1}`,
description: info.description || '', description: info.description || '',
type: propType, type: propType,
price: dailyPrice, price: price,
priceUSD: dailyPrice, priceUSD: price,
priceUnit: 'daily', priceUnit,
listingType: isRentListing ? 'rent' : 'sale',
location: { location: {
city: extractCity(info.address) || 'دمشق', city: extractCity(info.address) || 'دمشق',
district: info.address || '', district: info.address || '',
@ -85,7 +97,9 @@ function mapApiProperty(item, index) {
priceDisplay: { priceDisplay: {
daily: dailyPrice, daily: dailyPrice,
monthly: monthlyPrice, monthly: monthlyPrice,
sale: salePrice,
}, },
ownerSource,
bookings: [], bookings: [],
_raw: item, _raw: item,
}; };
@ -175,6 +189,16 @@ export default function HomePage() {
setSearchFilters(filters); setSearchFilters(filters);
const filtered = allProperties.filter(property => { 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) { if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
return false; 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 (filters.identityType && property.allowedIdentities) {
if (!property.allowedIdentities.includes(filters.identityType)) { if (!property.allowedIdentities.includes(filters.identityType)) {
return false; return false;
@ -312,7 +350,7 @@ export default function HomePage() {
</motion.p> </motion.p>
</motion.div> </motion.div>
{!isOwner && <HeroSearch onSearch={applyFilters} />} {!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
{isOwner && ( {isOwner && (
<motion.div <motion.div
@ -477,6 +515,25 @@ export default function HomePage() {
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'} searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
</span> </span>
</div> </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> </motion.div>
)} )}
</div> </div>

View File

@ -49,6 +49,9 @@ import { getRentProperty, getSaleProperty, bookReservation, checkAvailability, g
import AuthService from '../../services/AuthService'; import AuthService from '../../services/AuthService';
import { useFavorites } from '@/app/contexts/FavoritesContext'; import { useFavorites } from '@/app/contexts/FavoritesContext';
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums'; 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 // Copy to clipboard that works on HTTP too
async function copyToClipboard(text) { async function copyToClipboard(text) {
@ -177,6 +180,11 @@ export default function PropertyDetailsPage() {
const [favLoading, setFavLoading] = useState(false); const [favLoading, setFavLoading] = useState(false);
const [selectingEnd, setSelectingEnd] = useState(false); const [selectingEnd, setSelectingEnd] = useState(false);
const [showLoginDialog, setShowLoginDialog] = 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(() => { useEffect(() => {
const id = params.id; const id = params.id;
@ -218,20 +226,29 @@ export default function PropertyDetailsPage() {
fetchProperty(); fetchProperty();
}, [params.id]); }, [params.id]);
// Fetch available date ranges // Fetch user rating and check if they can rate
useEffect(() => { useEffect(() => {
if (!property) return; async function fetchUserRatingAndCheck() {
const propId = property._raw?.id || params.id; if (!property || !AuthService.isAuthenticated()) return;
console.log('[Property] Fetching available dates for:', propId);
getAvailableDateRanges(propId) try {
.then((data) => { // Check if user has already rated
const ranges = Array.isArray(data) ? data : []; const rating = await getUserPropertyRating(property._raw?.id || parseInt(params.id), AuthService.getUserId());
console.log('[Property] Available date ranges:', ranges); if (rating) {
setAvailableRanges(ranges); setUserRating(rating);
}) setCurrentRating(rating.rating);
.catch((err) => { setCurrentComment(rating.comment || '');
console.warn('[Property] Failed to fetch available dates:', err); }
});
// 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]); }, [property, params.id]);
// Set Open Graph meta tags dynamically for Facebook/Twitter sharing // Set Open Graph meta tags dynamically for Facebook/Twitter sharing
@ -754,12 +771,17 @@ export default function PropertyDetailsPage() {
{property.reviewList.map((review, idx) => ( {property.reviewList.map((review, idx) => (
<div key={idx} className="border-b border-gray-100 last:border-0 pb-4 last:pb-0"> <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 className="flex justify-between items-start mb-2">
<div> <div className="flex items-start gap-2">
<span className="font-bold text-gray-900">{review.user}</span> <div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
<div className="flex items-center gap-1 mt-1"> <User className="w-6 h-6 text-gray-600" />
{[...Array(5)].map((_, i) => ( </div>
<Star key={i} className={`w-4 h-4 ${i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'}`} /> <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>
</div> </div>
<span className="text-sm text-gray-500">{review.date}</span> <span className="text-sm text-gray-500">{review.date}</span>
@ -771,24 +793,32 @@ export default function PropertyDetailsPage() {
</motion.div> </motion.div>
)} )}
{property.rules && property.rules.length > 0 && ( {/* New Rating Components */}
{AuthService.isAuthenticated() && canRate && !userRating && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }} transition={{ delay: 0.65 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100" 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> <Star className="w-8 h-8 text-amber-500 mx-auto mb-2" />
<ul className="space-y-2"> <h3 className="font-bold text-amber-700 mb-2">قيّم هذا العقار</h3>
{property.rules.map((rule, idx) => ( <p className="text-sm text-amber-600">شارك تجربتك مع المستأجرين الآخرين</p>
<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>
</motion.div> </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>
<div className="space-y-6"> <div className="space-y-6">

226
app/reservations/page.js Normal file
View 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>
);
}

View File

@ -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 * Get roles array from JWT
* @returns {string[]} * @returns {string[]}

View File

@ -134,7 +134,7 @@ export async function getAvailableDateRanges(propertyId) {
} }
export async function getReservations() { export async function getReservations() {
return apiFetch('/Reservations/GetReservations'); return apiFetch('/Reservations/GetAllReservations');
} }
export async function getReservation(id) { export async function getReservation(id) {
@ -366,3 +366,85 @@ export async function addFavoriteProperty(propId) {
export async function removeFavoriteProperty(favePropId) { export async function removeFavoriteProperty(favePropId) {
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' }); 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 }),
});
}

193
app/utils/ratings.js Normal file
View 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;
}
}