Compare commits

...

59 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
9e1f8f517b Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 59s
2026-04-01 02:18:08 +03:00
700b446463 Edit register 2026-04-01 02:18:03 +03:00
be14250a08 fix: request permission synchronously from user click gesture
All checks were successful
Build frontend / build (push) Successful in 1m2s
2026-03-31 23:16:05 +00:00
eec7a9a75d fix: show notification permission prompt on user click instead of auto-request
All checks were successful
Build frontend / build (push) Successful in 57s
2026-03-31 23:07:15 +00:00
4ca7106b48 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
Some checks failed
Build frontend / build (push) Failing after 1m0s
2026-04-01 02:05:39 +03:00
7685134a39 Edit register 2026-04-01 02:05:32 +03:00
6ad2457e74 fix: use HTTPS URL in firebase.js
All checks were successful
Build frontend / build (push) Successful in 40s
2026-03-31 22:53:34 +00:00
98c3f51df2 fix: switch API base URL to HTTPS (nip.io)
All checks were successful
Build frontend / build (push) Successful in 42s
2026-03-31 22:48:50 +00:00
5d44fb56ec Edit profits
All checks were successful
Build frontend / build (push) Successful in 49s
2026-04-01 01:46:48 +03:00
ba389042c2 chore: add nip.io domain with SSL for HTTPS notifications
All checks were successful
Build frontend / build (push) Successful in 53s
2026-03-31 22:38:11 +00:00
c546e11ed3 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-01 01:34:54 +03:00
8d7efe82a4 Edit profits for owner 2026-04-01 01:34:51 +03:00
52758eae9d fix: wait for hydration before checking auth in NotificationHandler
All checks were successful
Build frontend / build (push) Successful in 51s
2026-03-31 22:12:55 +00:00
a824fb0c7c fix: send FCM token to User/SetFCMToken endpoint
All checks were successful
Build frontend / build (push) Successful in 43s
2026-03-31 21:24:43 +00:00
9e87aa90e8 feat: send FCM token to backend on permission grant
Some checks failed
Build frontend / build (push) Failing after 25s
2026-03-31 20:20:52 +00:00
199e78d6b1 chore: set VAPID key for FCM
All checks were successful
Build frontend / build (push) Successful in 1m11s
2026-03-31 20:09:07 +00:00
df9711f539 fix: only request notification permissions for signed-in users
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-31 19:52:47 +00:00
2bea2d190c feat: integrate Firebase Cloud Messaging for push notifications
Some checks failed
Build frontend / build (push) Failing after 24s
2026-03-31 19:50:48 +00:00
81674c4aa7 fix: add remote image pattern for next.config.mjs
All checks were successful
Build frontend / build (push) Successful in 55s
2026-03-31 19:45:03 +00:00
cf7f51b514 fix: update GetMyRentListings endpoint (userId removed from URL)
All checks were successful
Build frontend / build (push) Successful in 1m3s
2026-03-31 18:46:12 +00:00
0171c7a2bf fix: prepend /Pictures/ to image paths for nginx static serving
All checks were successful
Build frontend / build (push) Successful in 40s
2026-03-30 18:52:08 +00:00
9f6a730a94 Show login dialog when favoriting without auth
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-30 18:34:19 +00:00
2c04cd751f Fix favorites: optimistic remove + no loading flash
All checks were successful
Build frontend / build (push) Successful in 54s
2026-03-30 18:18:09 +00:00
db184bbace Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 1m13s
2026-03-30 21:02:43 +03:00
230805e02b Edit phone in footer 2026-03-30 21:02:34 +03:00
33 changed files with 5650 additions and 1378 deletions

View File

@ -6,6 +6,7 @@ import Link from "next/link";
import Image from "next/image";
import { NavLink, MobileNavLink } from "./components/NavLinks";
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
import FloatingSidebar from '@/app/components/FloatingSidebar';
import {
Globe,
@ -41,6 +42,7 @@ import { motion, AnimatePresence } from "framer-motion";
import AuthService from "./services/AuthService";
import { UserRole, UserRoleLabels } from "./enums/UserRole";
import "./i18n/config";
import NotificationHandler from "./components/NotificationHandler";
export default function ClientLayout({ children }) {
const { t, i18n } = useTranslation();
@ -247,18 +249,18 @@ export default function ClientLayout({ children }) {
عقاراتي
</span>
</NavLink>
<NavLink href="/owner/bookings">
<NavLink href="/owner/reservations">
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
الحجوزات
</span>
</NavLink>
<NavLink href="/owner/calendar">
{/* <NavLink href="/owner/calendar">
<span className="flex items-center gap-2">
<CalendarDays className="w-4 h-4" />
التقويم
</span>
</NavLink>
</NavLink> */}
<NavLink href="/owner/profits">
<span className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
@ -397,7 +399,7 @@ export default function ClientLayout({ children }) {
</Link>
<Link
href="/owner/bookings"
href="/owner/reservations"
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
onClick={() => setShowUserMenu(false)}
>
@ -521,7 +523,7 @@ export default function ClientLayout({ children }) {
<div className="border-t border-gray-100 my-2"></div>
<Link
href="/tenant/bookings"
href="/reservations"
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
onClick={() => setShowUserMenu(false)}
>
@ -649,7 +651,7 @@ export default function ClientLayout({ children }) {
</span>
</MobileNavLink>
<MobileNavLink
href="/owner/bookings"
href="/owner/reservations"
onClick={closeMobileMenu}
>
<span className="flex items-center gap-2">
@ -707,10 +709,12 @@ export default function ClientLayout({ children }) {
<main
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
>
<FavoritesProvider>
{children}
{!isAdmin && <FloatingSidebar isRTL={currentLanguage === 'ar'} />}
</FavoritesProvider>
<NotificationsProvider>
<FavoritesProvider>
{children}
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
</FavoritesProvider>
</NotificationsProvider>
</main>
{!isAuthPage && !isProfilePage && (
@ -781,7 +785,7 @@ export default function ClientLayout({ children }) {
<ul className="space-y-3 text-gray-400">
<li className="flex items-center gap-2">
<Phone className="w-5 h-5" />
<span dir="ltr" className="text-left">{t("phone")}</span>
<span dir="ltr" className="text-right">{t("phone")}</span>
</li>
<li className="flex items-center gap-2">
<Mail className="w-5 h-5" />
@ -798,6 +802,7 @@ export default function ClientLayout({ children }) {
</div>
</footer>
)}
<NotificationHandler />
</>
);
}

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

View File

@ -0,0 +1,165 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { initializeApp, getApps } from "firebase/app";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
import AuthService from "../services/AuthService";
const firebaseConfig = {
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
authDomain: "sweet-home-b2766.firebaseapp.com",
projectId: "sweet-home-b2766",
storageBucket: "sweet-home-b2766.firebasestorage.app",
messagingSenderId: "602865114600",
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
measurementId: "G-M2V95NBJLX",
};
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
export default function NotificationHandler() {
const [notification, setNotification] = useState(null);
const [showPrompt, setShowPrompt] = useState(false);
const initialized = useRef(false);
useEffect(() => {
function checkAuth() {
if (initialized.current) return;
if (!AuthService.getToken()) return;
initialized.current = true;
if ("Notification" in window) {
if (Notification.permission === "default") {
setShowPrompt(true);
} else if (Notification.permission === "granted") {
setupFCM();
}
}
}
// Check immediately
checkAuth();
// Also check when auth token changes (login via client-side navigation)
const interval = setInterval(() => {
if (!initialized.current && AuthService.getToken()) {
checkAuth();
}
}, 1000);
// Check on route change (visibility)
const onVisibility = () => {
if (document.visibilityState === "visible") checkAuth();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
async function setupFCM() {
try {
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
const messaging = getMessaging(app);
const fcmToken = await getToken(messaging, {
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
serviceWorkerRegistration: registration,
});
if (fcmToken) {
console.log("[FCM] Token:", fcmToken.substring(0, 20) + "...");
const authToken = AuthService.getToken();
if (authToken) {
const apiBase = "https://45.93.137.91.nip.io/api";
await fetch(`${apiBase}/User/SetFCMToken`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ token: fcmToken, deviceType: 2 }),
});
console.log("[FCM] Token sent to backend");
}
}
onMessage(messaging, (payload) => {
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
const body = payload.notification?.body || payload.data?.body || "";
setNotification({ title, body });
setTimeout(() => setNotification(null), 5000);
});
} catch (err) {
console.error("[FCM] Setup error:", err);
}
}
async function handleEnable() {
setShowPrompt(false);
// This MUST be synchronous from a user gesture
const permission = await Notification.requestPermission();
console.log("[FCM] Permission result:", permission);
if (permission === "granted") {
await setupFCM();
}
}
return (
<>
{showPrompt && (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999]">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-xl">🔔</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900 text-sm">تفعيل الإشعارات</p>
<p className="text-gray-600 text-sm mt-0.5">اسمح بالإشعارات للبقاء على اطلاع بحجوزاتك وعروضنا.</p>
<div className="flex gap-2 mt-3">
<button
onClick={handleEnable}
className="px-4 py-1.5 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
>
تفعيل
</button>
<button
onClick={() => setShowPrompt(false)}
className="px-4 py-1.5 text-gray-500 text-sm hover:text-gray-700 transition-colors"
>
لاحقاً
</button>
</div>
</div>
</div>
</div>
)}
{notification && (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999] animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-xl">🏠</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900 text-sm">{notification.title}</p>
<p className="text-gray-600 text-sm mt-0.5">{notification.body}</p>
</div>
<button
onClick={() => setNotification(null)}
className="text-gray-400 hover:text-gray-600 flex-shrink-0"
>
</button>
</div>
</div>
)}
</>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,24 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
export default function HeroSearch({ onSearch }) {
export default function HeroSearch({ onSearch, isAuthenticated }) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('rent');
const [activeTab, setActiveTab] = useState('buy');
const [filters, setFilters] = useState({
city: '',
propertyType: '',
priceRange: '',
identityType: 'syrian'
city: 'all',
propertyType: 'all',
priceRange: 'all',
identityType: 'syrian',
ownerSource: 'all',
rentPeriod: 'all',
availableToday: false
});
const [showLoginDialog, setShowLoginDialog] = useState(false);
const cities = [
{ id: 'all', label: 'جميع المدن' },
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
const propertyTypes = [
{ id: 'all', label: 'الكل' },
{ id: 'apartment', label: 'شقة' },
{ id: 'villa', label: 'فيلا' },
{ id: 'house', label: 'بيت' },
{ id: 'studio', label: 'استوديو' }
{ id: 'apartment', label: 'شقق سكنية' },
{ id: 'studio', label: 'استوديو' },
{ id: 'commercial', label: 'عقار تجاري' },
{ id: 'villa', label: 'فيلا / مزرعة' }
];
const priceRanges = [
@ -46,17 +51,45 @@ export default function HeroSearch({ onSearch }) {
{ id: 'passport', label: 'جواز سفر' }
];
const ownerSources = [
{ id: 'all', label: 'الكل' },
{ id: 'owner', label: 'من المالك' },
{ id: 'agency', label: 'من مكتب عقاري' }
];
const rentPeriods = [
{ id: 'all', label: 'الكل' },
{ id: 'daily', label: 'إيجار يومي' },
{ id: 'monthly', label: 'إيجار شهري' }
];
const handleTabClick = (tab) => {
setActiveTab(tab);
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
setShowLoginDialog(true);
}
};
const handleSearch = () => {
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
setShowLoginDialog(true);
return;
}
onSearch({
...filters,
propertyType: filters.propertyType || 'all',
mode: activeTab,
city: filters.city || 'all',
priceRange: filters.priceRange || 'all'
propertyType: filters.propertyType || 'all',
priceRange: filters.priceRange || 'all',
ownerSource: filters.ownerSource || 'all',
rentPeriod: filters.rentPeriod || 'all'
});
};
return (
<motion.div
<>
<motion.div
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
{['rent', 'buy', 'sell'].map((tab) => (
<motion.button
key={tab}
onClick={() => setActiveTab(tab)}
onClick={() => handleTabClick(tab)}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
activeTab === tab
? 'bg-amber-500 text-white'
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
<select
value={filters.ownerSource}
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left 1rem center',
backgroundSize: '1rem',
paddingLeft: '2.5rem'
}}
>
{ownerSources.map((source) => (
<option key={source.id} value={source.id}>
{source.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
<select
value={filters.rentPeriod}
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left 1rem center',
backgroundSize: '1rem',
paddingLeft: '2.5rem'
}}
>
{rentPeriods.map((period) => (
<option key={period.id} value={period.id}>
{period.label}
</option>
))}
</select>
</div>
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
<label className="mt-4 flex items-center gap-3 text-white text-sm">
<input
type="checkbox"
checked={filters.availableToday}
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
/>
<span className="font-medium">عرض فقط العقارات المتاحة من اليوم</span>
</label>
</div>
</div>
<div className="mt-6">
<motion.button
onClick={handleSearch}
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
</motion.button>
</div>
</motion.div>
{showLoginDialog && !isAuthenticated && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
<div className="flex items-center gap-3 mb-5">
<ShieldCheck className="w-7 h-7 text-amber-500" />
<div>
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl bg-gray-50 p-4">
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setShowLoginDialog(false)}
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
>
إغلاق
</button>
<Link
href="/login"
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
>
تسجيل الدخول
</Link>
</div>
</div>
</div>
</div>
)}
</>
);
}

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

@ -84,7 +84,8 @@ export const FavoritesProvider = ({ children }) => {
if (!AuthService.isAuthenticated()) return false;
try {
await addFavoriteProperty(propId);
await fetchFavorites(); // refresh list
// Refresh to get the full object with faveId
await fetchFavorites();
return true;
} catch (err) {
console.error('[Favorites] Add failed:', err);
@ -94,15 +95,18 @@ export const FavoritesProvider = ({ children }) => {
const removeFavorite = async (propId) => {
if (!AuthService.isAuthenticated()) return false;
// Find the faveId for this property
const fav = favorites.find(f => f.id === propId);
if (!fav) return false;
// Optimistic update — remove immediately from UI
const previous = [...favorites];
setFavorites(prev => prev.filter(f => f.id !== propId));
try {
await removeFavoriteProperty(fav.faveId);
setFavorites(prev => prev.filter(f => f.id !== propId));
return true;
} catch (err) {
console.error('[Favorites] Remove failed:', err);
// Rollback on failure
setFavorites(previous);
return false;
}
};

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

@ -26,7 +26,7 @@ export default function FavoritesPage() {
return amount?.toLocaleString() + ' ل.س';
};
if (favoritesLoading) {
if (favoritesLoading && favorites.length === 0) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">

View File

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

View File

@ -4,71 +4,28 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
import AuthService from '@/app/services/AuthService';
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'
}
];
import { useNotifications } from '@/app/contexts/NotificationsContext';
export default function NotificationsPage() {
const router = useRouter();
const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const { notifications, unreadCount, isLoading } = useNotifications();
const [error, setError] = useState(null);
useEffect(() => {
if (AuthService.isAdmin()) {
router.push('/');
if (!AuthService.isAuthenticated()) {
router.push('/login');
return;
}
setTimeout(() => {
setNotifications(mockNotifications);
setIsLoading(false);
}, 500);
}, [router]);
const markAsRead = (id) => {
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: true } : n))
);
// This will be handled by context if needed
};
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) {
return (
<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 (
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-4xl">
@ -100,30 +69,31 @@ export default function NotificationsPage() {
</div>
) : (
<div className="space-y-4">
{notifications.map((notification) => {
const Icon = notification.icon;
return (
<div
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 ${notification.bgColor} rounded-full flex items-center justify-center flex-shrink-0`}>
<Icon className={`w-6 h-6 ${notification.color}`} />
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-gray-900">{notification.title}</h3>
{notifications.map((notification, index) => (
<div
key={index}
className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
>
<div className="p-5 flex gap-4">
<div className="w-12 h-12 bg-blue-50 rounded-full flex items-center justify-center shrink-0">
<Bell className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-gray-900">{notification.title}</h3>
{notification.message && (
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
)}
{notification.date && (
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>

View File

@ -1,328 +1,459 @@
// 'use client';
// import { useState, useEffect } from 'react';
// import { motion } from 'framer-motion';
// import { useRouter } from 'next/navigation';
// import {
// DollarSign,
// TrendingUp,
// Wallet,
// Star,
// Eye,
// Download,
// CalendarDays
// } from 'lucide-react';
// import toast, { Toaster } from 'react-hot-toast';
// import AuthService from '@/app/services/AuthService';
// const StatCard = ({ title, value, icon: Icon, color }) => {
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
// >
// <div className="flex items-center justify-between mb-4">
// <div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
// <Icon className="w-6 h-6 text-white" />
// </div>
// </div>
// <h3 className="text-sm text-gray-500 mb-1">{title}</h3>
// <div className="text-2xl font-bold text-gray-900">{value}</div>
// </motion.div>
// );
// };
// const PropertyProfitCard = ({ property, onViewDetails }) => {
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
// >
// <div className="p-5">
// <div className="flex justify-between items-start mb-4">
// <div>
// <h3 className="font-bold text-lg text-gray-900">{property.title}</h3>
// {property.isNotSeized && (
// <span className="inline-block mt-1 px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
// غير محجوز
// </span>
// )}
// </div>
// <span className="text-xs text-gray-500">{property.location}</span>
// </div>
// <div className="grid grid-cols-3 gap-4 mb-4">
// <div className="text-center">
// <div className="text-sm text-gray-500">الإيرادات</div>
// <div className="text-lg font-bold text-amber-600">{formatCurrency(property.revenue)}</div>
// </div>
// <div className="text-center">
// <div className="text-sm text-gray-500">العمولة</div>
// <div className="text-lg font-bold text-blue-600">{formatCurrency(property.commission)}</div>
// </div>
// <div className="text-center">
// <div className="text-sm text-gray-500">المتبقي</div>
// <div className="text-lg font-bold text-green-600">{formatCurrency(property.remaining)}</div>
// </div>
// </div>
// <div className="flex justify-between items-center pt-3 border-t border-gray-100">
// <div className="flex items-center gap-2">
// <Star className="w-4 h-4 text-amber-500" />
// <span className="text-sm font-medium text-gray-700">التقييم العام:</span>
// <span className="text-sm font-medium text-gray-900">{property.valuation}</span>
// </div>
// <div className="flex items-center gap-1 text-sm text-gray-500">
// <CalendarDays className="w-4 h-4" />
// <span>مؤجر {property.rentedCount} مرة</span>
// </div>
// </div>
// <button
// onClick={() => onViewDetails(property)}
// className="w-full mt-4 py-2 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
// >
// <Eye className="w-4 h-4" />
// عرض التفاصيل
// </button>
// </div>
// </motion.div>
// );
// };
// const PropertyCalendar = ({ year, month }) => {
// const [currentMonth, setCurrentMonth] = useState(new Date(year, month - 1));
// const monthNames = ['يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'];
// const weekDays = ['إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت', 'أحد'];
// const getDaysInMonth = (date) => {
// return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
// };
// const getFirstDayOfMonth = (date) => {
// const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
// return day === 0 ? 6 : day - 1;
// };
// const daysInMonth = getDaysInMonth(currentMonth);
// const firstDayIndex = getFirstDayOfMonth(currentMonth);
// const cells = [];
// for (let i = 0; i < firstDayIndex; i++) {
// cells.push(<div key={`empty-${i}`} className="p-2 md:p-3 text-center" />);
// }
// for (let d = 1; d <= daysInMonth; d++) {
// cells.push(
// <div
// key={d}
// className="p-2 md:p-3 text-center rounded-xl hover:bg-gray-100 transition-colors"
// >
// {d}
// </div>
// );
// }
// return (
// <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
// <div className="flex justify-between items-center mb-6">
// <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
// <CalendarDays className="w-5 h-5 text-amber-500" />
// {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
// </h3>
// <div className="flex gap-2">
// <button
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
// className="p-2 hover:bg-gray-100 rounded-lg"
// >
// &larr;
// </button>
// <button
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
// className="p-2 hover:bg-gray-100 rounded-lg"
// >
// &rarr;
// </button>
// </div>
// </div>
// <div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
// {weekDays.map(day => (
// <div key={day}>{day}</div>
// ))}
// </div>
// <div className="grid grid-cols-7 gap-1">
// {cells}
// </div>
// </div>
// );
// };
// export default function OwnerProfitsPage() {
// const router = useRouter();
// const [user, setUser] = useState(null);
// const [isLoading, setIsLoading] = useState(true);
// const [summary] = useState({
// totalRevenue: 4290,
// totalCommission: 644,
// remainingBalance: 3647,
// });
// const [properties] = useState([
// {
// id: 1,
// title: 'Damascus Olive Residence',
// location: 'دمشق، المزة',
// isNotSeized: true,
// revenue: 3240,
// commission: 486,
// remaining: 2754,
// valuation: 'جيد جدا',
// rentedCount: 18,
// },
// ]);
// useEffect(() => {
// if (AuthService.isGuest()) {
// router.push('/auth/choose-role');
// return;
// }
// if (!AuthService.isOwner()) {
// router.push('/');
// return;
// }
// const authUser = AuthService.getUser();
// if (authUser) {
// setUser({
// name: authUser.name || authUser.email,
// email: authUser.email,
// });
// }
// setIsLoading(false);
// }, [router]);
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
// const handleViewDetails = (property) => {
// toast.info(`عرض تفاصيل ${property.title}`);
// };
// const handleExportReport = () => {
// toast.success('جاري تصدير التقرير...');
// };
// if (isLoading) {
// return (
// <div className="min-h-screen flex items-center justify-center">
// <div className="text-center">
// <div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
// <p className="text-gray-600">جاري التحميل...</p>
// </div>
// </div>
// );
// }
// return (
// <div className="min-h-screen bg-gray-50 py-8">
// <Toaster position="top-center" reverseOrder={false} />
// <div className="container mx-auto px-4 max-w-6xl">
// <div className="mb-8">
// <h1 className="text-3xl font-bold text-gray-900 mb-2">دفتر الحسابات</h1>
// <p className="text-gray-600">نظرة عامة على أرباح المالك</p>
// </div>
// <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
// <StatCard
// title="الإيرادات"
// value={formatCurrency(summary.totalRevenue)}
// icon={DollarSign}
// color="bg-green-500"
// />
// <StatCard
// title="العمولة"
// value={formatCurrency(summary.totalCommission)}
// icon={TrendingUp}
// color="bg-blue-500"
// />
// <StatCard
// title="المتبقي"
// value={formatCurrency(summary.remainingBalance)}
// icon={Wallet}
// color="bg-amber-500"
// />
// </div>
// <div className="mb-12">
// <h2 className="text-xl font-bold text-gray-900 mb-4">عقاراتي</h2>
// <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
// {properties.map((property) => (
// <PropertyProfitCard
// key={property.id}
// property={property}
// onViewDetails={handleViewDetails}
// />
// ))}
// </div>
// </div>
// <div className="mb-12">
// <h2 className="text-xl font-bold text-gray-900 mb-4">تقويم العقار</h2>
// <PropertyCalendar year={2026} month={3} />
// </div>
// {/* <div className="flex justify-end">
// <button
// onClick={handleExportReport}
// className="px-6 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
// >
// <Download className="w-5 h-5" />
// تصدير التقرير
// </button>
// </div> */}
// </div>
// </div>
// );
// }
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import {
DollarSign,
TrendingUp,
TrendingDown,
Calendar,
Home,
Building,
Download,
Filter,
ChevronLeft,
ChevronRight,
ArrowLeft,
Loader2,
Eye,
PieChart,
BarChart,
LineChart,
Wallet,
CreditCard,
Clock,
CheckCircle,
XCircle
} from 'lucide-react';
import { Download, Loader2 } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
const StatCard = ({ title, value, change, icon: Icon, color, trend }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
>
<div className="flex justify-between items-start mb-4">
<div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
<Icon className="w-6 h-6 text-white" />
</div>
<div className={`flex items-center gap-1 text-sm ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{trend === 'up' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span>{Math.abs(change)}%</span>
</div>
</div>
<h3 className="text-sm text-gray-600 mb-1">{title}</h3>
<div className="text-2xl font-bold text-gray-900">{value}</div>
</motion.div>
);
};
const PropertyProfitCard = ({ property, onViewDetails }) => {
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
>
<div className="p-5">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-bold text-gray-900 mb-1">{property.title}</h3>
<p className="text-sm text-gray-500">{property.location}</p>
</div>
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
property.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}>
{property.status === 'active' ? 'نشط' : 'غير نشط'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-gray-50 p-3 rounded-xl text-center">
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-xs text-gray-500">إجمالي الأرباح</div>
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
</div>
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Calendar className="w-5 h-5 text-blue-500 mx-auto mb-1" />
<div className="text-xs text-gray-500">عدد الحجوزات</div>
<div className="text-lg font-bold text-blue-600">{property.totalBookings}</div>
</div>
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-500">هذا الشهر</span>
<span className="font-medium text-gray-900">{formatCurrency(property.monthlyProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">الأسبوع الماضي</span>
<span className="font-medium text-gray-900">{formatCurrency(property.weeklyProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">متوسط السعر اليومي</span>
<span className="font-medium text-gray-900">{formatCurrency(property.avgDailyPrice)}</span>
</div>
</div>
<button
onClick={() => onViewDetails(property)}
className="w-full bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
>
<Eye className="w-4 h-4" />
عرض التفاصيل
</button>
</div>
</motion.div>
);
};
const ProfitDetailsModal = ({ property, isOpen, onClose }) => {
if (!isOpen || !property) return null;
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
const monthlyData = [
{ month: 'يناير', profit: 1250000 },
{ month: 'فبراير', profit: 1500000 },
{ month: 'مارس', profit: 1800000 },
{ month: 'إبريل', profit: 2100000 },
{ month: 'مايو', profit: 2500000 },
{ month: 'يونيو', profit: 2300000 }
];
const maxProfit = Math.max(...monthlyData.map(d => d.profit));
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold">{property.title}</h2>
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
<XCircle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-amber-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
<div className="text-xs text-gray-600 mt-1">إجمالي الأرباح</div>
</div>
<div className="bg-blue-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-blue-600">{property.totalBookings}</div>
<div className="text-xs text-gray-600 mt-1">عدد الحجوزات</div>
</div>
<div className="bg-green-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-green-600">{property.occupancyRate}%</div>
<div className="text-xs text-gray-600 mt-1">نسبة الإشغال</div>
</div>
<div className="bg-purple-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-purple-600">{formatCurrency(property.avgDailyPrice)}</div>
<div className="text-xs text-gray-600 mt-1">متوسط السعر اليومي</div>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-4">الأرباح الشهرية</h3>
<div className="space-y-3">
{monthlyData.map((data, index) => (
<div key={index}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{data.month}</span>
<span className="font-medium text-gray-900">{formatCurrency(data.profit)}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(data.profit / maxProfit) * 100}%` }}
transition={{ duration: 0.8, delay: index * 0.1 }}
className="bg-amber-500 h-2 rounded-full"
/>
</div>
</div>
))}
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-4">آخر الحجوزات</h3>
<div className="space-y-3">
{property.recentBookings?.map((booking, index) => (
<div key={index} className="bg-white p-3 rounded-lg flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{booking.tenantName}</p>
<p className="text-xs text-gray-500">{booking.startDate} - {booking.endDate}</p>
</div>
<div className="text-right">
<p className="font-bold text-amber-600">{formatCurrency(booking.amount)}</p>
<p className="text-xs text-gray-500">{booking.status === 'completed' ? 'مكتمل' : 'قيد التنفيذ'}</p>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
</motion.div>
);
};
import * as XLSX from 'xlsx';
import AuthService from '@/app/services/AuthService';
export default function OwnerProfitsPage() {
const router = useRouter();
const [user, setUser] = useState(null);
const [properties, setProperties] = useState([]);
const [filteredProperties, setFilteredProperties] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedProperty, setSelectedProperty] = useState(null);
const [dateRange, setDateRange] = useState({ start: '', end: '' });
const [selectedPeriod, setSelectedPeriod] = useState('month');
const [tableData, setTableData] = useState([]);
const sampleData = [
{
id: 1,
property: 'A000000001',
bookingNumber: 'XX-101',
fromDate: '2025-05-01',
toDate: '2025-05-07',
amountReceived: 500,
platformCommission: 0,
transferredToOwner: 0,
transferReceipt: '—',
},
{
id: 2,
property: 'A000000002',
bookingNumber: 'XX-202',
fromDate: '2025-05-10',
toDate: '2025-05-15',
amountReceived: 300,
platformCommission: 0,
transferredToOwner: 0,
transferReceipt: '—',
},
{
id: 3,
property: 'A000000003',
bookingNumber: 'XX-309',
fromDate: '2025-06-01',
toDate: '2025-06-05',
amountReceived: 800,
platformCommission: 150,
transferredToOwner: 0,
transferReceipt: 'قيد الانتظار',
},
];
const computeRows = (data) => {
return data.map((item) => {
const platformProfit = item.amountReceived * 0.05;
const ownerDue = item.amountReceived - platformProfit;
return {
...item,
platformProfit,
ownerDue,
};
});
};
useEffect(() => {
if (AuthService.isGuest()) {
router.push('/auth/choose-role');
return;
}
if (!AuthService.isOwner()) {
router.push('/');
return;
}
useEffect(() => {
const authUser = AuthService.getUser();
if (authUser && AuthService.isOwner()) {
if (authUser) {
setUser({
name: authUser.name || authUser.email,
email: authUser.email,
role: 'owner',
});
loadData();
} else {
router.push('/auth/choose-role');
}
}, [router]); // month, year, all
const loadProfitsData = () => {
const storedProfits = localStorage.getItem('ownerProfits');
if (storedProfits) {
setProperties(JSON.parse(storedProfits));
setFilteredProperties(JSON.parse(storedProfits));
const stored = localStorage.getItem('ownerProfitsTable');
if (stored) {
setTableData(computeRows(JSON.parse(stored)));
} else {
const mockProperties = [
{
id: 1,
title: 'فيلا فاخرة في المزة',
location: 'دمشق، المزة',
status: 'active',
totalProfit: 12500000,
totalBookings: 24,
monthlyProfit: 3200000,
weeklyProfit: 850000,
avgDailyPrice: 500000,
occupancyRate: 78,
recentBookings: [
{ tenantName: 'أحمد محمد', startDate: '2024-03-10', endDate: '2024-03-15', amount: 2500000, status: 'completed' },
{ tenantName: 'سارة أحمد', startDate: '2024-03-05', endDate: '2024-03-08', amount: 1500000, status: 'completed' }
]
},
{
id: 2,
title: 'شقة حديثة في الشهباء',
location: 'حلب، الشهباء',
status: 'active',
totalProfit: 5800000,
totalBookings: 18,
monthlyProfit: 1500000,
weeklyProfit: 400000,
avgDailyPrice: 250000,
occupancyRate: 65,
recentBookings: [
{ tenantName: 'محمد علي', startDate: '2024-03-12', endDate: '2024-03-14', amount: 750000, status: 'completed' }
]
},
{
id: 3,
title: 'بيت عائلي في بابا عمرو',
location: 'حمص، بابا عمرو',
status: 'active',
totalProfit: 8400000,
totalBookings: 12,
monthlyProfit: 2100000,
weeklyProfit: 525000,
avgDailyPrice: 350000,
occupancyRate: 45,
recentBookings: []
}
];
setProperties(mockProperties);
setFilteredProperties(mockProperties);
localStorage.setItem('ownerProfits', JSON.stringify(mockProperties));
setTableData(computeRows(sampleData));
localStorage.setItem('ownerProfitsTable', JSON.stringify(sampleData));
}
setIsLoading(false);
};
}, [router]);
const totalStats = {
totalProfit: properties.reduce((sum, p) => sum + p.totalProfit, 0),
totalBookings: properties.reduce((sum, p) => sum + p.totalBookings, 0),
avgOccupancy: Math.round(properties.reduce((sum, p) => sum + p.occupancyRate, 0) / properties.length),
activeProperties: properties.filter(p => p.status === 'active').length
};
const formatCurrency = (amount) => {
if (amount >= 1000000) {
return (amount / 1000000).toFixed(1) + ' مليون ل.س';
const totals = tableData.reduce(
(acc, row) => {
acc.totalAmountReceived += row.amountReceived;
acc.totalCommission += row.platformCommission;
acc.totalPlatformProfit += row.platformProfit;
acc.totalOwnerDue += row.ownerDue;
acc.totalTransferred += row.transferredToOwner;
return acc;
},
{
totalAmountReceived: 0,
totalCommission: 0,
totalPlatformProfit: 0,
totalOwnerDue: 0,
totalTransferred: 0,
}
);
const handleExportReport = () => {
try {
const exportData = tableData.map((row) => ({
'العقار': row.property,
'رقم الحجز': row.bookingNumber,
'من تاريخ': row.fromDate,
'حتى تاريخ': row.toDate,
'العروض المستلم': row.amountReceived,
'عمولة المنصة': row.platformCommission,
'ربح المنصة (5%)': row.platformProfit,
'المستحق للمالك': row.ownerDue,
'تم التحويل للمالك': row.transferredToOwner,
'رقم وصل التحويل': row.transferReceipt,
}));
exportData.push({
'العقار': 'الإجمالي العام',
'رقم الحجز': '',
'من تاريخ': '',
'حتى تاريخ': '',
'العروض المستلم': totals.totalAmountReceived,
'عمولة المنصة': totals.totalCommission,
'ربح المنصة (5%)': totals.totalPlatformProfit,
'المستحق للمالك': totals.totalOwnerDue,
'تم التحويل للمالك': totals.totalTransferred,
'رقم وصل التحويل': '—',
});
const worksheet = XLSX.utils.json_to_sheet(exportData);
const colWidths = [
{ wch: 15 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
{ wch: 14 },
{ wch: 14 },
{ wch: 16 },
{ wch: 16 },
{ wch: 16 },
{ wch: 18 },
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'أرباح المالك');
XLSX.writeFile(workbook, `تقرير_الأرباح_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
toast.success('تم تصدير التقرير بنجاح!');
} catch (error) {
console.error('خطأ في التصدير:', error);
toast.error('حدث خطأ أثناء تصدير التقرير');
}
return amount?.toLocaleString() + ' ل.س';
};
if (isLoading) {
@ -337,132 +468,125 @@ export default function OwnerProfitsPage() {
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
<Toaster position="top-center" reverseOrder={false} />
<ProfitDetailsModal
property={selectedProperty}
isOpen={!!selectedProperty}
onClose={() => setSelectedProperty(null)}
/>
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 max-w-7xl">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
>
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأرباح والإحصائيات</h1>
<p className="text-gray-600">مرحباً {user?.name}، إليك ملخص أرباحك</p>
</div>
<div className="flex gap-3">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
>
<option value="month">آخر 30 يوم</option>
<option value="year">آخر 12 شهر</option>
<option value="all">جميع الفترات</option>
</select>
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
<Download className="w-5 h-5" />
تصدير التقرير
</button> */}
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
<p className="text-gray-600">
مرحباً {user?.name}
</p>
</div>
<button
onClick={handleExportReport}
className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
>
<Download className="w-5 h-5" />
تصدير التقرير
</button>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="إجمالي الأرباح"
value={formatCurrency(totalStats.totalProfit)}
change={12.5}
icon={Wallet}
color="bg-amber-500"
trend="up"
/>
<StatCard
title="عدد الحجوزات"
value={totalStats.totalBookings}
change={8.2}
icon={Calendar}
color="bg-blue-500"
trend="up"
/>
<StatCard
title="متوسط نسبة الإشغال"
value={`${totalStats.avgOccupancy}%`}
change={5.3}
icon={PieChart}
color="bg-green-500"
trend="up"
/>
<StatCard
title="العقارات النشطة"
value={totalStats.activeProperties}
change={0}
icon={Building}
color="bg-purple-500"
trend="up"
/>
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900">أرباح العقارات</h2>
<div className="flex gap-2">
<button
onClick={() => setFilteredProperties(properties)}
className="px-3 py-1.5 bg-gray-100 rounded-lg text-sm hover:bg-gray-200 transition-colors"
>
عرض الكل
</button>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-800 text-gray-100">
<tr>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العقار</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم الحجز</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">من تاريخ</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">حتى تاريخ</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العروض المستلم</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">عمولة المنصة</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider bg-amber-50 text-amber-800">
ربح المنصة <span className="font-normal text-[11px] block">(5% من العربون)</span>
</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">المستحق للمالك</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">تم التحويل للمالك</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم وصل التحويل</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{tableData.map((row, idx) => (
<tr
key={row.id}
className={`hover:bg-amber-50/40 transition-colors ${
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
}`}
>
<td className="px-4 py-3 whitespace-nowrap text-center font-medium text-gray-800">
{row.property}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
{row.bookingNumber}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
{row.fromDate}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
{row.toDate}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-gray-800">
{row.amountReceived}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
{row.platformCommission}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-bold text-amber-700 bg-amber-50/50">
{row.platformProfit}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-emerald-700">
{row.ownerDue}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
{row.transferredToOwner}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-500 text-xs">
{row.transferReceipt}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-100 border-t-2 border-gray-300">
<tr>
<td colSpan="4" className="px-4 py-4 text-right font-bold text-gray-800">
الإجمالي العام
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
{totals.totalAmountReceived}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
{totals.totalCommission}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-amber-700 bg-amber-100/60">
{totals.totalPlatformProfit}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-emerald-700">
{totals.totalOwnerDue}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
{totals.totalTransferred}
</td>
<td className="px-4 py-4 text-center text-gray-500"></td>
</tr>
</tfoot>
</table>
</div>
{filteredProperties.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<div className="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<DollarSign className="w-12 h-12 text-amber-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد بيانات</h3>
<p className="text-gray-600">لا توجد أرباح مسجلة حتى الآن</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredProperties.map((property) => (
<PropertyProfitCard
key={property.id}
property={property}
onViewDetails={setSelectedProperty}
/>
))}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="bg-gradient-to-r from-amber-500 to-amber-600 rounded-2xl p-6 text-white mt-8"
>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="text-lg font-bold mb-1">احصل على المزيد من الأرباح</h3>
<p className="text-amber-100 text-sm">أضف عقارات جديدة وحسّن أسعارك لزيادة الإشغال</p>
</div>
<Link
href="/owner/properties/add"
className="px-6 py-2 bg-white text-amber-600 rounded-xl font-medium hover:bg-amber-50 transition-colors"
>
إضافة عقار جديد
</Link>
<div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
<span className="inline-flex items-center gap-1"></span> ملاحظة:
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
</div>
</motion.div>
</div>
</div>
);
}
}

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

View File

@ -721,7 +721,7 @@ export default function OwnerPropertiesPage() {
try {
console.log('[OwnerProperties] Fetching listings for user:', userId);
const data = await getMyRentListings(userId);
const data = await getMyRentListings();
const list = Array.isArray(data) ? data : (data ? [data] : []);
console.log('[OwnerProperties] API returned:', list.length, 'properties');
@ -747,9 +747,9 @@ export default function OwnerPropertiesPage() {
livingRooms: details.livingRooms || 0,
status: { 0: 'available', 1: 'booked', 2: 'maintenance' }[info.status] || 'available',
images: (() => {
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const raw = Array.isArray(info.images) ? info.images : [];
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/'}${img}`) : ['/property-placeholder.jpg'];
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`) : ['/property-placeholder.jpg'];
})(),
createdAt: item.createdAt || new Date().toISOString(),
furnished: details.furnished || false,

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) {
const info = item.propertyInformation || {};
const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
const dailyPrice = item.dailyRent ?? 0;
const monthlyPrice = item.monthlyRent ?? 0;
const salePrice = item.price ?? 0;
const isRentListing = Boolean(item.dailyRent != null || item.monthlyRent != null);
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? 'apartment';
const price = isRentListing ? (dailyPrice || monthlyPrice || 0) : salePrice;
const priceUnit = isRentListing ? (monthlyPrice ? 'monthly' : 'daily') : 'sale';
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? (item.type || 'apartment');
const status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
const features = [];
@ -52,20 +57,27 @@ function mapApiProperty(item, index) {
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
// Extract images from API and build full URLs
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const rawImages = Array.isArray(info.images) ? info.images : [];
const images = rawImages.length > 0
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/'}${img}`)
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
: ['/property-placeholder.jpg'];
const ownerSource = info.ownerType == null && item.ownerType == null
? 'all'
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
? 'agency'
: 'owner';
return {
id: item.id ?? index + 1,
title: info.address || `عقار #${item.id || index + 1}`,
description: info.description || '',
type: propType,
price: dailyPrice,
priceUSD: dailyPrice,
priceUnit: 'daily',
price: price,
priceUSD: price,
priceUnit,
listingType: isRentListing ? 'rent' : 'sale',
location: {
city: extractCity(info.address) || 'دمشق',
district: info.address || '',
@ -85,7 +97,9 @@ function mapApiProperty(item, index) {
priceDisplay: {
daily: dailyPrice,
monthly: monthlyPrice,
sale: salePrice,
},
ownerSource,
bookings: [],
_raw: item,
};
@ -175,6 +189,16 @@ export default function HomePage() {
setSearchFilters(filters);
const filtered = allProperties.filter(property => {
if (filters.mode === 'rent' && property.listingType !== 'rent') {
return false;
}
if (filters.mode === 'sell' && property.listingType !== 'sale') {
return false;
}
if (filters.mode === 'buy' && property.listingType !== 'sale') {
return false;
}
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
return false;
}
@ -194,6 +218,20 @@ export default function HomePage() {
}
}
if (filters.ownerSource && filters.ownerSource !== 'all') {
if (filters.ownerSource === 'owner' && property.ownerSource !== 'owner') return false;
if (filters.ownerSource === 'agency' && property.ownerSource !== 'agency') return false;
}
if (filters.rentPeriod && filters.rentPeriod !== 'all' && property.listingType === 'rent') {
if (filters.rentPeriod === 'daily' && !property.priceDisplay.daily) return false;
if (filters.rentPeriod === 'monthly' && !property.priceDisplay.monthly) return false;
}
if (filters.availableToday) {
if (property.status !== 'available') return false;
}
if (filters.identityType && property.allowedIdentities) {
if (!property.allowedIdentities.includes(filters.identityType)) {
return false;
@ -312,7 +350,7 @@ export default function HomePage() {
</motion.p>
</motion.div>
{!isOwner && <HeroSearch onSearch={applyFilters} />}
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
{isOwner && (
<motion.div
@ -477,6 +515,25 @@ export default function HomePage() {
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
</span>
</div>
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
<span className="text-gray-600">مصدر العرض: </span>
<span className="font-bold text-gray-900">
{searchFilters.ownerSource === 'all' ? 'الكل' :
searchFilters.ownerSource === 'owner' ? 'من المالك' : 'من مكتب عقاري'}
</span>
</div>
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
<span className="text-gray-600">نوع الإيجار: </span>
<span className="font-bold text-gray-900">
{searchFilters.rentPeriod === 'all' ? 'الكل' :
searchFilters.rentPeriod === 'daily' ? 'إيجار يومي' : 'إيجار شهري'}
</span>
</div>
{searchFilters.availableToday && (
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
<span className="font-bold text-gray-900">فقط المتاحة من اليوم</span>
</div>
)}
</motion.div>
)}
</div>

View File

@ -57,10 +57,10 @@ function mapApiProperty(item, index) {
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
// Extract images from API and build full URLs
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const rawImages = Array.isArray(info.images) ? info.images : [];
const images = rawImages.length > 0
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/'}${img}`)
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
: ['/property-placeholder.jpg'];
return {
@ -97,7 +97,7 @@ function extractCity(address) {
// API-only — no fallback data
const PropertyCard = ({ property, viewMode = 'grid' }) => {
const PropertyCard = ({ property, viewMode = 'grid', onLoginRequired }) => {
const { isFavorite: checkFavorite, addFavorite, removeFavorite } = useFavorites();
const [favLoading, setFavLoading] = useState(false);
const [currentImage, setCurrentImage] = useState(0);
@ -107,7 +107,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
const toggleFavorite = async (e) => {
e.preventDefault();
e.stopPropagation();
if (!AuthService.isAuthenticated()) { toast.error('سجل الدخول أولاً'); return; }
if (!AuthService.isAuthenticated()) { onLoginRequired?.(); return; }
setFavLoading(true);
if (isFav) {
await removeFavorite(property.id);
@ -493,6 +493,7 @@ export default function PropertiesPage() {
const [sortBy, setSortBy] = useState('newest');
const [properties, setProperties] = useState([]);
const [loading, setLoading] = useState(true);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [filters, setFilters] = useState({
search: '',
propertyType: 'all',
@ -625,7 +626,7 @@ export default function PropertiesPage() {
: 'space-y-4'
}>
{filteredProperties.map((property) => (
<PropertyCard key={property.id} property={property} viewMode={viewMode} />
<PropertyCard key={property.id} property={property} viewMode={viewMode} onLoginRequired={() => setShowLoginDialog(true)} />
))}
</div>
@ -644,6 +645,36 @@ export default function PropertiesPage() {
)}
</div>
<Toaster position="top-center" />
{showLoginDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowLoginDialog(false)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl text-center"
>
<div className="w-14 h-14 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Heart className="w-7 h-7 text-amber-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">تسجيل الدخول مطلوب</h3>
<p className="text-gray-500 mb-6">يجب تسجيل الدخول لإضافة العقارات إلى المفضلة</p>
<div className="flex gap-3">
<button
onClick={() => setShowLoginDialog(false)}
className="flex-1 py-3 border border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
إلغاء
</button>
<Link
href="/login"
className="flex-1 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
>
تسجيل الدخول
</Link>
</div>
</motion.div>
</div>
)}
</div>
);
}

View File

@ -49,6 +49,9 @@ import { getRentProperty, getSaleProperty, bookReservation, checkAvailability, g
import AuthService from '../../services/AuthService';
import { useFavorites } from '@/app/contexts/FavoritesContext';
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums';
import RatingForm from '@/app/components/ratings/RatingForm.js';
import RatingList from '@/app/components/ratings/RatingList.js';
import StarRating from '@/app/components/ratings/StarRating.js';
// Copy to clipboard that works on HTTP too
async function copyToClipboard(text) {
@ -97,10 +100,10 @@ function mapApiDetail(item) {
const typeLabels = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' };
// Extract images from API and build full URLs
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api') : '';
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const rawImages = Array.isArray(info.images) ? info.images : [];
const images = rawImages.length > 0
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/'}${img}`)
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
: ['/property-placeholder.jpg', '/villa1.jpg', '/villa2.jpg'];
return {
@ -177,6 +180,11 @@ export default function PropertyDetailsPage() {
const [favLoading, setFavLoading] = useState(false);
const [selectingEnd, setSelectingEnd] = useState(false);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showRatingForm, setShowRatingForm] = useState(false);
const [currentRating, setCurrentRating] = useState(0);
const [currentComment, setCurrentComment] = useState('');
const [userRating, setUserRating] = useState(null);
const [canRate, setCanRate] = useState(false);
useEffect(() => {
const id = params.id;
@ -218,20 +226,29 @@ export default function PropertyDetailsPage() {
fetchProperty();
}, [params.id]);
// Fetch available date ranges
// Fetch user rating and check if they can rate
useEffect(() => {
if (!property) return;
const propId = property._raw?.id || params.id;
console.log('[Property] Fetching available dates for:', propId);
getAvailableDateRanges(propId)
.then((data) => {
const ranges = Array.isArray(data) ? data : [];
console.log('[Property] Available date ranges:', ranges);
setAvailableRanges(ranges);
})
.catch((err) => {
console.warn('[Property] Failed to fetch available dates:', err);
});
async function fetchUserRatingAndCheck() {
if (!property || !AuthService.isAuthenticated()) return;
try {
// Check if user has already rated
const rating = await getUserPropertyRating(property._raw?.id || parseInt(params.id), AuthService.getUserId());
if (rating) {
setUserRating(rating);
setCurrentRating(rating.rating);
setCurrentComment(rating.comment || '');
}
// Check if user can rate (e.g., after renting)
const canRateProperty = await canRateProperty(property._raw?.id || parseInt(params.id), AuthService.getUserId());
setCanRate(canRateProperty);
} catch (error) {
console.error('[Property] Failed to fetch user rating:', error);
}
}
fetchUserRatingAndCheck();
}, [property, params.id]);
// Set Open Graph meta tags dynamically for Facebook/Twitter sharing
@ -422,7 +439,7 @@ export default function PropertyDetailsPage() {
<div className="flex gap-2">
<button
onClick={async () => {
if (!AuthService.isAuthenticated()) { toast.error('سجل الدخول أولاً'); return; }
if (!AuthService.isAuthenticated()) { setShowLoginDialog(true); return; }
const propId = property?._raw?.id || parseInt(params.id);
setFavLoading(true);
if (isFavorite(propId)) {
@ -754,12 +771,17 @@ export default function PropertyDetailsPage() {
{property.reviewList.map((review, idx) => (
<div key={idx} className="border-b border-gray-100 last:border-0 pb-4 last:pb-0">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-bold text-gray-900">{review.user}</span>
<div className="flex items-center gap-1 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'}`} />
))}
<div className="flex items-start gap-2">
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
<User className="w-6 h-6 text-gray-600" />
</div>
<div>
<span className="font-bold text-gray-900">{review.user}</span>
<div className="flex items-center gap-1 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'}`} />
))}
</div>
</div>
</div>
<span className="text-sm text-gray-500">{review.date}</span>
@ -771,24 +793,32 @@ export default function PropertyDetailsPage() {
</motion.div>
)}
{property.rules && property.rules.length > 0 && (
{/* New Rating Components */}
{AuthService.isAuthenticated() && canRate && !userRating && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
transition={{ delay: 0.65 }}
className="bg-amber-50 border-2 border-amber-200 rounded-xl p-4 text-center cursor-pointer hover:border-amber-300 transition-all"
onClick={() => setShowRatingForm(true)}
>
<h2 className="text-xl font-bold mb-4 text-gray-900">قوانين المنزل</h2>
<ul className="space-y-2">
{property.rules.map((rule, idx) => (
<li key={idx} className="flex items-center gap-2 text-gray-600">
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
{rule}
</li>
))}
</ul>
<Star className="w-8 h-8 text-amber-500 mx-auto mb-2" />
<h3 className="font-bold text-amber-700 mb-2">قيّم هذا العقار</h3>
<p className="text-sm text-amber-600">شارك تجربتك مع المستأجرين الآخرين</p>
</motion.div>
)}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-200"
>
<RatingList
propertyId={property._raw?.id || parseInt(params.id)}
userId={AuthService.getUserId()}
/>
</motion.div>
</div>
<div className="space-y-6">

View File

@ -1,6 +1,624 @@
// 'use client';
// import { useState, useRef, useMemo } from 'react';
// import { motion, AnimatePresence } from 'framer-motion';
// import { useRouter } from 'next/navigation';
// import Link from 'next/link';
// import Image from 'next/image';
// import {
// User, Mail, Phone, Lock, Eye, EyeOff, MessageCircle,
// Camera, X, CheckCircle, XCircle, ArrowLeft, Building,
// Loader2, Shield, KeyRound
// } from 'lucide-react';
// import toast, { Toaster } from 'react-hot-toast';
// import { addOwner, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
// import AuthService from '../../services/AuthService';
// import { OwnerType, OwnerTypeLabels } from '../../enums';
// export default function OwnerRegisterPage() {
// const router = useRouter();
// const [step, setStep] = useState(1); // 1=form, 2=id images
// const [showOtpModal, setShowOtpModal] = useState(false);
// const [showPassword, setShowPassword] = useState(false);
// const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// const [isLoading, setIsLoading] = useState(false);
// const [formData, setFormData] = useState({
// firstName: '',
// lastName: '',
// email: '',
// phone: '',
// whatsapp: '',
// phone2: '',
// nationalNumber: '',
// password: '',
// confirmPassword: '',
// ownerType: OwnerType.PERSON,
// agreeTerms: false
// });
// const [idImages, setIdImages] = useState({ front: null, back: null });
// const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
// const [otpCode, setOtpCode] = useState('');
// const [errors, setErrors] = useState({});
// const fileInputFrontRef = useRef(null);
// const fileInputBackRef = useRef(null);
// const handleImageUpload = (side, file) => {
// if (!file) return;
// if (!file.type.startsWith('image/')) {
// toast.error('الرجاء اختيار صورة صالحة');
// return;
// }
// if (file.size > 5 * 1024 * 1024) {
// toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
// return;
// }
// const reader = new FileReader();
// reader.onloadend = () => {
// setIdImagePreviews(prev => ({ ...prev, [side]: reader.result }));
// };
// reader.readAsDataURL(file);
// setIdImages(prev => ({ ...prev, [side]: file }));
// console.log('[OwnerRegister] Image uploaded:', side);
// toast.success('تم رفع الصورة بنجاح', { style: { background: '#dcfce7', color: '#166534' } });
// };
// const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
// const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
// const validateStep1 = () => {
// const newErrors = {};
// if (!formData.firstName) newErrors.firstName = 'الاسم الأول مطلوب';
// if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
// if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
// else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
// if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
// else if (!validatePhone(formData.whatsapp)) newErrors.whatsapp = 'رقم الواتساب غير صالح (يجب أن يبدأ 09 أو 05)';
// if (formData.phone && !validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح';
// if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
// else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
// if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
// setErrors(newErrors);
// return Object.keys(newErrors).length === 0;
// };
// const validateStep2 = () => {
// const newErrors = {};
// if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
// if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
// setErrors(newErrors);
// return Object.keys(newErrors).length === 0;
// };
// const handleNextStep = () => {
// if (validateStep1()) {
// console.log('[OwnerRegister] Step 1 valid, moving to step 2');
// setStep(2);
// window.scrollTo({ top: 0, behavior: 'smooth' });
// } else {
// toast.error('يرجى تصحيح الأخطاء في النموذج');
// }
// };
// // ─── Main signup handler ───
// const handleSubmit = async (e) => {
// e.preventDefault();
// if (!validateStep2()) {
// toast.error('يرجى إكمال جميع الصور المطلوبة');
// return;
// }
// if (!formData.agreeTerms) {
// toast.error('يجب الموافقة على الشروط والأحكام');
// return;
// }
// setIsLoading(true);
// console.log('[OwnerRegister] Submitting owner registration...');
// const payload = {
// firstName: formData.firstName,
// lastName: formData.lastName,
// email: formData.email,
// phoneNumber: formData.phone || '',
// whatsAppNumber: formData.whatsapp,
// phone: formData.phone2,
// nationalNumber: formData.nationalNumber,
// password: formData.password,
// ownerType: formData.ownerType,
// };
// try {
// const res = await addOwner(payload, idImages.front, idImages.back);
// console.log('[OwnerRegister] addOwner response:', res);
// if (res.status === 200 || res.ok) {
// const tempToken = res.data;
// if (tempToken) {
// AuthService.addToken(tempToken);
// console.log('[OwnerRegister] Temp token stored for OTP');
// }
// const apiMessage = res.message || res.data?.message;
// toast.success(apiMessage || 'تم إنشاء الحساب! يرجى التحقق من بريدك الإلكتروني', { duration: 4000 });
// // Auto-login to trigger OTP
// console.log('[OwnerRegister] Auto-login to send OTP...');
// const loginRes = await loginWithEmail(formData.email, formData.password);
// console.log('[OwnerRegister] login response:', loginRes);
// if (loginRes.status === 206) {
// const otpToken = loginRes.data;
// if (otpToken) AuthService.addToken(otpToken);
// const loginMsg = loginRes.message || loginRes.data?.message;
// toast(loginMsg || 'تم إرسال رمز التحقق إلى بريدك الإلكتروني', { icon: '📧' });
// setShowOtpModal(true);
// } else if (loginRes.status === 200) {
// const loginToken = loginRes.data;
// if (loginToken) AuthService.addToken(loginToken);
// toast.success(loginRes.message || 'تم تسجيل الدخول بنجاح!');
// router.push('/');
// }
// } else {
// const errMsg = res.message || res.data?.message || 'فشل في إنشاء الحساب';
// console.error('[OwnerRegister] Registration failed:', errMsg);
// toast.error(errMsg);
// }
// } catch (err) {
// console.error('[OwnerRegister] Error:', err);
// toast.error(err.message || 'حدث خطأ أثناء التسجيل');
// } finally {
// setIsLoading(false);
// }
// };
// // ─── OTP verification handler ───
// const handleVerifyOTP = async () => {
// if (!otpCode || otpCode.length < 4) {
// toast.error('يرجى إدخال رمز التحقق');
// return;
// }
// setIsLoading(true);
// console.log('[OwnerRegister] Verifying OTP:', otpCode);
// try {
// const res = await verifyEmail(otpCode);
// console.log('[OwnerRegister] VerifyEmail response:', res);
// if (res.status === 200) {
// AuthService.deleteToken();
// console.log('[OwnerRegister] Temp token removed after verification');
// toast.success(res.message || 'تم التحقق من البريد الإلكتروني بنجاح!', { duration: 3000 });
// setShowOtpModal(false);
// setTimeout(() => router.push('/login'), 1500);
// } else {
// const errMsg = res.message || res.data?.message || 'رمز التحقق غير صحيح';
// console.error('[OwnerRegister] Verification failed:', errMsg);
// toast.error(errMsg);
// }
// } catch (err) {
// console.error('[OwnerRegister] Verify error:', err);
// toast.error(err.message || 'حدث خطأ أثناء التحقق');
// } finally {
// setIsLoading(false);
// }
// };
// const handleResendOTP = async () => {
// setIsLoading(true);
// console.log('[OwnerRegister] Resending email OTP...');
// try {
// await sendEmailOTP();
// toast.success('تم إرسال رمز تحقق جديد');
// } catch (err) {
// console.error('[OwnerRegister] Resend OTP error:', err);
// toast.error('فشل في إرسال الرمز');
// } finally {
// setIsLoading(false);
// }
// };
// const fadeInUp = {
// initial: { opacity: 0, y: 20 },
// animate: { opacity: 1, y: 0 },
// transition: { duration: 0.5 }
// };
// const staggerContainer = {
// animate: { transition: { staggerChildren: 0.1 } }
// };
// const backgroundElements = useMemo(() => {
// const circles = [
// { style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-amber-500/5' },
// { style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/5' },
// { style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-purple-500/5' },
// ];
// const dots = [
// { left: '5%', top: '10%', size: '120px' },
// { left: '15%', top: '70%', size: '80px' },
// { left: '25%', top: '30%', size: '150px' },
// { left: '35%', top: '85%', size: '100px' },
// { left: '45%', top: '15%', size: '90px' },
// { left: '55%', top: '60%', size: '130px' },
// { left: '65%', top: '40%', size: '70px' },
// { left: '75%', top: '80%', size: '110px' },
// { left: '85%', top: '20%', size: '140px' },
// { left: '95%', top: '50%', size: '85px' },
// ];
// return (
// <>
// {circles.map((circle, i) => (
// <div
// key={`circle-${i}`}
// className={`absolute rounded-full ${circle.className}`}
// style={circle.style}
// />
// ))}
// {dots.map((dot, i) => (
// <div
// key={`dot-${i}`}
// className="absolute rounded-full bg-amber-500/10"
// style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
// />
// ))}
// </>
// );
// }, []);
// return (
// <div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
// <Toaster position="top-center" reverseOrder={false} />
// {/* <div className="absolute inset-0 overflow-hidden">
// {[...Array(20)].map((_, i) => (
// <motion.div key={i} className="absolute rounded-full bg-amber-500/10"
// style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
// animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
// transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
// ))}
// </div> */}
// <div className="absolute inset-0 overflow-hidden">
// {backgroundElements}
// </div>
// <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
// className="relative z-10 w-full max-w-2xl">
// {/* Progress */}
// <div className="mb-8">
// <div className="flex items-center justify-between mb-4">
// <Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
// <motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
// <span>العودة</span>
// </Link>
// <span className="text-sm text-gray-400">خطوة {step} من 2</span>
// </div>
// <div className="flex gap-2">
// {[1, 2].map((s) => (
// <motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? 'bg-amber-500' : 'bg-gray-700'}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
// ))}
// </div>
// </div>
// <motion.div key={step} initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} transition={{ duration: 0.3 }}
// className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden">
// <div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden">
// <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2, type: "spring" }}
// className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
// <motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="relative z-10">
// <motion.div animate={{ rotate: [0, 10, -10, 0] }} transition={{ duration: 2, repeat: Infinity }}
// className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
// <Building className="w-10 h-10 text-white" />
// </motion.div>
// <h1 className="text-3xl font-bold text-white mb-2">
// {step === 1 ? 'معلومات المالك' : 'الوثائق الرسمية'}
// </h1>
// <p className="text-amber-100">
// {step === 1 ? 'أدخل معلوماتك الأساسية' : 'يرجى رفع صور الهوية للتحقق'}
// </p>
// </motion.div>
// </div>
// <div className="p-8">
// <motion.form variants={staggerContainer} initial="initial" animate="animate"
// onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
// className="space-y-6">
// {/* ─── STEP 1: Form ─── */}
// {step === 1 && (
// <>
// <motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
// <div>
// <label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <User className={`w-5 h-5 ${errors.firstName ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type="text" value={formData.firstName}
// onChange={(e) => { setFormData({...formData, firstName: e.target.value}); setErrors({...errors, firstName: null}); }}
// className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.firstName ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="الاسم الأول" />
// </div>
// {errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
// </div>
// <div>
// <label className="block text-sm font-medium text-gray-300 mb-2">اسم العائلة <span className="text-red-500">*</span></label>
// <input type="text" value={formData.lastName}
// onChange={(e) => { setFormData({...formData, lastName: e.target.value}); setErrors({...errors, lastName: null}); }}
// className={`w-full px-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.lastName ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="اسم العائلة" />
// {errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
// </div>
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">البريد الإلكتروني <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <Mail className={`w-5 h-5 ${errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type="email" value={formData.email}
// onChange={(e) => { setFormData({...formData, email: e.target.value}); setErrors({...errors, email: null}); }}
// className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.email ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="أدخل بريدك الإلكتروني" />
// </div>
// {errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف <span className="text-gray-500">(اختياري)</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <Phone className="w-5 h-5 text-gray-400 group-focus-within:text-amber-500" />
// </div>
// <input type="tel" value={formData.phone}
// onChange={(e) => { setFormData({...formData, phone: e.target.value}); setErrors({...errors, phone: null}); }}
// className="w-full pr-12 pl-4 py-3 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all"
// placeholder="أدخل رقم هاتفك (اختياري)" />
// </div>
// {errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">رقم الواتساب <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <MessageCircle className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type="tel" value={formData.whatsapp}
// onChange={(e) => { setFormData({...formData, whatsapp: e.target.value}); setErrors({...errors, whatsapp: null}); }}
// className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.whatsapp ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="أدخل رقم الواتساب" />
// </div>
// {errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type="tel" value={formData.phone2}
// onChange={(e) => { setFormData({...formData, phone2: e.target.value}); setErrors({...errors, phone2: null}); }}
// className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone2 ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="أدخل رقم الهاتف" maxLength={7} />
// </div>
// {errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">الرقم الوطني <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <User className={`w-5 h-5 ${errors.nationalNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type="text" value={formData.nationalNumber}
// onChange={(e) => { setFormData({...formData, nationalNumber: e.target.value}); setErrors({...errors, nationalNumber: null}); }}
// className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.nationalNumber ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="أدخل الرقم الوطني" />
// </div>
// {errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">نوع المالك <span className="text-red-500">*</span></label>
// <select value={formData.ownerType}
// onChange={(e) => setFormData({...formData, ownerType: e.target.value})}
// className="w-full py-3 px-4 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white appearance-none cursor-pointer">
// {Object.entries(OwnerTypeLabels).map(([value, label]) => (
// <option key={value} value={value} className="bg-gray-900 text-white">{label}</option>
// ))}
// </select>
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <Lock className={`w-5 h-5 ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type={showPassword ? "text" : "password"} value={formData.password}
// onChange={(e) => { setFormData({...formData, password: e.target.value}); setErrors({...errors, password: null}); }}
// className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.password ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="أدخل كلمة المرور" />
// <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
// {showPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
// </button>
// </div>
// {errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">تأكيد كلمة المرور <span className="text-red-500">*</span></label>
// <div className="relative group">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <Lock className={`w-5 h-5 ${errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
// </div>
// <input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword}
// onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: null}); }}
// className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.confirmPassword ? 'border-red-500' : 'border-gray-700'}`}
// placeholder="أعد إدخال كلمة المرور" />
// <button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
// {showConfirmPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
// </button>
// {formData.confirmPassword && (
// <div className="absolute inset-y-0 left-12 flex items-center">
// {formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
// </div>
// )}
// </div>
// {errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>}
// </motion.div>
// </>
// )}
// {/* ─── STEP 2: ID Images ─── */}
// {step === 2 && (
// <>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الأمامي <span className="text-red-500">*</span></label>
// <div onClick={() => fileInputFrontRef.current?.click()}
// className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.front ? 'border-green-500 bg-green-500/10' : errors.front ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-amber-500 hover:bg-white/5'}`}>
// <input ref={fileInputFrontRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('front', e.target.files?.[0])} className="hidden" />
// {idImagePreviews.front ? (
// <div className="relative">
// <Image src={idImagePreviews.front} alt="Front ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
// <button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, front: null})); setIdImagePreviews(prev => ({...prev, front: ''})); }}
// className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
// <X className="w-4 h-4 text-white" />
// </button>
// </div>
// ) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
// </div>
// {errors.front && <p className="text-red-500 text-sm mt-1">{errors.front}</p>}
// </motion.div>
// <motion.div variants={fadeInUp}>
// <label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الخلفي <span className="text-red-500">*</span></label>
// <div onClick={() => fileInputBackRef.current?.click()}
// className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.back ? 'border-green-500 bg-green-500/10' : errors.back ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-amber-500 hover:bg-white/5'}`}>
// <input ref={fileInputBackRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('back', e.target.files?.[0])} className="hidden" />
// {idImagePreviews.back ? (
// <div className="relative">
// <Image src={idImagePreviews.back} alt="Back ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
// <button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, back: null})); setIdImagePreviews(prev => ({...prev, back: ''})); }}
// className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
// <X className="w-4 h-4 text-white" />
// </button>
// </div>
// ) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG • حتى 5MB</p></>)}
// </div>
// {errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
// </motion.div>
// <motion.div variants={fadeInUp} className="flex items-center gap-2">
// <input type="checkbox" id="terms" checked={formData.agreeTerms}
// onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
// className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500" required />
// <label htmlFor="terms" className="text-sm text-gray-300">
// أوافق على <Link href="/terms" className="text-amber-400 hover:text-amber-300">شروط الاستخدام</Link> و <Link href="/privacy" className="text-amber-400 hover:text-amber-300">سياسة الخصوصية</Link>
// </label>
// </motion.div>
// </>
// )}
// {/* ─── Buttons ─── */}
// <motion.div variants={fadeInUp} className="flex gap-3 pt-4">
// {step === 1 ? (
// <>
// <button type="button" onClick={() => router.push('/auth/choose-role')}
// className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">إلغاء</button>
// <button type="submit"
// className="flex-1 bg-gradient-to-r from-amber-500 to-amber-600 text-white py-3 px-4 rounded-xl font-medium hover:from-amber-600 hover:to-amber-700 transition-all">التالي</button>
// </>
// ) : (
// <>
// <button type="button" onClick={() => setStep(1)}
// className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">السابق</button>
// <button type="submit" disabled={isLoading || !formData.agreeTerms}
// className="flex-1 bg-gradient-to-r from-amber-500 to-amber-600 text-white py-3 px-4 rounded-xl font-medium hover:from-amber-600 hover:to-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
// {isLoading ? (<div className="flex items-center justify-center gap-2"><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التسجيل...</span></div>) : 'إنشاء حساب'}
// </button>
// </>
// )}
// </motion.div>
// </motion.form>
// </div>
// </motion.div>
// </motion.div>
// {/* ─── OTP Modal ─── */}
// <AnimatePresence>
// {showOtpModal && (
// <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
// className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
// <motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
// className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
// <div className="text-center mb-6">
// <div className="w-16 h-16 bg-amber-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
// <Shield className="w-8 h-8 text-amber-500" />
// </div>
// <h2 className="text-xl font-bold text-white">التحقق من البريد</h2>
// <p className="text-gray-400 text-sm mt-1">تم إرسال رمز التحقق إلى</p>
// <p className="text-amber-400 font-medium text-sm">{formData.email}</p>
// </div>
// <div className="mb-6">
// <label className="block text-sm font-medium text-gray-300 mb-2">رمز التحقق</label>
// <div className="relative">
// <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
// <KeyRound className="w-5 h-5 text-gray-400" />
// </div>
// <input type="text" value={otpCode} maxLength={6}
// onChange={(e) => setOtpCode(e.target.value)}
// className="w-full pr-12 pl-4 py-3 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 text-white text-center tracking-[0.5em] text-xl"
// placeholder="------" />
// </div>
// </div>
// <div className="flex gap-3">
// <button onClick={handleVerifyOTP} disabled={isLoading || !otpCode}
// className="flex-1 bg-gradient-to-r from-amber-500 to-amber-600 text-white py-3 rounded-xl font-medium hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
// {isLoading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التحقق...</span></> : 'تحقق'}
// </button>
// </div>
// <button onClick={handleResendOTP} disabled={isLoading}
// className="w-full text-center text-amber-400 hover:text-amber-300 text-sm mt-3 disabled:opacity-50">
// إعادة إرسال الرمز
// </button>
// </motion.div>
// </motion.div>
// )}
// </AnimatePresence>
// </div>
// );
// }
'use client';
import { useState, useRef, useMemo } from 'react';
import { useState, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
@ -8,7 +626,7 @@ import Image from 'next/image';
import {
User, Mail, Phone, Lock, Eye, EyeOff, MessageCircle,
Camera, X, CheckCircle, XCircle, ArrowLeft, Building,
Loader2, Shield, KeyRound
Loader2, Shield, KeyRound, Briefcase, FileText, MapPin
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import { addOwner, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
@ -17,7 +635,7 @@ import { OwnerType, OwnerTypeLabels } from '../../enums';
export default function OwnerRegisterPage() {
const router = useRouter();
const [step, setStep] = useState(1); // 1=form, 2=id images
const [step, setStep] = useState(1);
const [showOtpModal, setShowOtpModal] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
@ -34,16 +652,21 @@ export default function OwnerRegisterPage() {
password: '',
confirmPassword: '',
ownerType: OwnerType.PERSON,
licenseNumber: '',
companyAddress: '',
agreeTerms: false
});
const [idImages, setIdImages] = useState({ front: null, back: null });
const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
const [idImages, setIdImages] = useState({ front: null, back: null, license: null });
const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '', license: '' });
const [otpCode, setOtpCode] = useState('');
const [errors, setErrors] = useState({});
const fileInputFrontRef = useRef(null);
const fileInputBackRef = useRef(null);
const fileInputLicenseRef = useRef(null);
const isCompany = formData.ownerType === OwnerType.REAL_ESTATE_AGENCY;
const handleImageUpload = (side, file) => {
if (!file) return;
@ -73,7 +696,6 @@ export default function OwnerRegisterPage() {
if (!formData.firstName) newErrors.firstName = 'الاسم الأول مطلوب';
if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
@ -87,6 +709,11 @@ export default function OwnerRegisterPage() {
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
if (isCompany) {
if (!formData.licenseNumber) newErrors.licenseNumber = 'رقم الرخصة/السجل التجاري مطلوب';
if (!formData.companyAddress) newErrors.companyAddress = 'عنوان المكتب مطلوب';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@ -95,6 +722,7 @@ export default function OwnerRegisterPage() {
const newErrors = {};
if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
if (isCompany && !idImages.license) newErrors.license = 'صورة الرخصة/السجل التجاري مطلوبة';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@ -109,7 +737,6 @@ export default function OwnerRegisterPage() {
}
};
// ─── Main signup handler ───
const handleSubmit = async (e) => {
e.preventDefault();
@ -137,8 +764,13 @@ export default function OwnerRegisterPage() {
ownerType: formData.ownerType,
};
if (isCompany) {
payload.licenseNumber = formData.licenseNumber;
payload.companyAddress = formData.companyAddress;
}
try {
const res = await addOwner(payload, idImages.front, idImages.back);
const res = await addOwner(payload, idImages.front, idImages.back, isCompany ? idImages.license : null);
console.log('[OwnerRegister] addOwner response:', res);
if (res.status === 200 || res.ok) {
@ -181,7 +813,7 @@ export default function OwnerRegisterPage() {
}
};
// ─── OTP verification handler ───
// OTP verification handler
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
toast.error('يرجى إدخال رمز التحقق');
@ -238,64 +870,53 @@ export default function OwnerRegisterPage() {
animate: { transition: { staggerChildren: 0.1 } }
};
const backgroundElements = useMemo(() => {
const circles = [
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-amber-500/5' },
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/5' },
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-purple-500/5' },
];
const backgroundElements = useMemo(() => {
const circles = [
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-amber-500/5' },
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/5' },
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-purple-500/5' },
];
const dots = [
{ left: '5%', top: '10%', size: '120px' },
{ left: '15%', top: '70%', size: '80px' },
{ left: '25%', top: '30%', size: '150px' },
{ left: '35%', top: '85%', size: '100px' },
{ left: '45%', top: '15%', size: '90px' },
{ left: '55%', top: '60%', size: '130px' },
{ left: '65%', top: '40%', size: '70px' },
{ left: '75%', top: '80%', size: '110px' },
{ left: '85%', top: '20%', size: '140px' },
{ left: '95%', top: '50%', size: '85px' },
];
return (
<>
{circles.map((circle, i) => (
<div
key={`circle-${i}`}
className={`absolute rounded-full ${circle.className}`}
style={circle.style}
/>
))}
{dots.map((dot, i) => (
<div
key={`dot-${i}`}
className="absolute rounded-full bg-amber-500/10"
style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
/>
))}
</>
);
}, []);
const dots = [
{ left: '5%', top: '10%', size: '120px' },
{ left: '15%', top: '70%', size: '80px' },
{ left: '25%', top: '30%', size: '150px' },
{ left: '35%', top: '85%', size: '100px' },
{ left: '45%', top: '15%', size: '90px' },
{ left: '55%', top: '60%', size: '130px' },
{ left: '65%', top: '40%', size: '70px' },
{ left: '75%', top: '80%', size: '110px' },
{ left: '85%', top: '20%', size: '140px' },
{ left: '95%', top: '50%', size: '85px' },
];
return (
<>
{circles.map((circle, i) => (
<div
key={`circle-${i}`}
className={`absolute rounded-full ${circle.className}`}
style={circle.style}
/>
))}
{dots.map((dot, i) => (
<div
key={`dot-${i}`}
className="absolute rounded-full bg-amber-500/10"
style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
/>
))}
</>
);
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
<Toaster position="top-center" reverseOrder={false} />
{/* <div className="absolute inset-0 overflow-hidden">
{[...Array(20)].map((_, i) => (
<motion.div key={i} className="absolute rounded-full bg-amber-500/10"
style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
))}
</div> */}
<div className="absolute inset-0 overflow-hidden">
{backgroundElements}
</div>
<div className="absolute inset-0 overflow-hidden">
{backgroundElements}
</div>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
className="relative z-10 w-full max-w-2xl">
@ -339,7 +960,6 @@ const backgroundElements = useMemo(() => {
onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
className="space-y-6">
{/* ─── STEP 1: Form ─── */}
{step === 1 && (
<>
<motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
@ -447,6 +1067,45 @@ const backgroundElements = useMemo(() => {
</select>
</motion.div>
<AnimatePresence>
{isCompany && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 overflow-hidden"
>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">رقم الرخصة/السجل التجاري <span className="text-red-500">*</span></label>
<div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<FileText className={`w-5 h-5 ${errors.licenseNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
</div>
<input type="text" value={formData.licenseNumber}
onChange={(e) => { setFormData({...formData, licenseNumber: e.target.value}); setErrors({...errors, licenseNumber: null}); }}
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.licenseNumber ? 'border-red-500' : 'border-gray-700'}`}
placeholder="أدخل رقم الرخصة أو السجل التجاري" />
</div>
{errors.licenseNumber && <p className="text-red-500 text-sm mt-1">{errors.licenseNumber}</p>}
</motion.div>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">عنوان المكتب <span className="text-red-500">*</span></label>
<div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<MapPin className={`w-5 h-5 ${errors.companyAddress ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
</div>
<input type="text" value={formData.companyAddress}
onChange={(e) => { setFormData({...formData, companyAddress: e.target.value}); setErrors({...errors, companyAddress: null}); }}
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.companyAddress ? 'border-red-500' : 'border-gray-700'}`}
placeholder="أدخل عنوان المكتب" />
</div>
{errors.companyAddress && <p className="text-red-500 text-sm mt-1">{errors.companyAddress}</p>}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
<div className="relative group">
@ -488,7 +1147,6 @@ const backgroundElements = useMemo(() => {
</>
)}
{/* ─── STEP 2: ID Images ─── */}
{step === 2 && (
<>
<motion.div variants={fadeInUp}>
@ -527,6 +1185,35 @@ const backgroundElements = useMemo(() => {
{errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
</motion.div>
<AnimatePresence>
{isCompany && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">صورة الرخصة/السجل التجاري <span className="text-red-500">*</span></label>
<div onClick={() => fileInputLicenseRef.current?.click()}
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.license ? 'border-green-500 bg-green-500/10' : errors.license ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-amber-500 hover:bg-white/5'}`}>
<input ref={fileInputLicenseRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('license', e.target.files?.[0])} className="hidden" />
{idImagePreviews.license ? (
<div className="relative">
<Image src={idImagePreviews.license} alt="License" width={200} height={120} className="mx-auto rounded-lg object-cover" />
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, license: null})); setIdImagePreviews(prev => ({...prev, license: ''})); }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
<X className="w-4 h-4 text-white" />
</button>
</div>
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG حتى 5MB</p></>)}
</div>
{errors.license && <p className="text-red-500 text-sm mt-1">{errors.license}</p>}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<motion.div variants={fadeInUp} className="flex items-center gap-2">
<input type="checkbox" id="terms" checked={formData.agreeTerms}
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
@ -538,7 +1225,7 @@ const backgroundElements = useMemo(() => {
</>
)}
{/* ─── Buttons ─── */}
{/* Buttons */}
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
{step === 1 ? (
<>
@ -563,7 +1250,7 @@ const backgroundElements = useMemo(() => {
</motion.div>
</motion.div>
{/* ─── OTP Modal ─── */}
{/* OTP Modal */}
<AnimatePresence>
{showOtpModal && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
@ -609,4 +1296,4 @@ const backgroundElements = useMemo(() => {
</AnimatePresence>
</div>
);
}
}

226
app/reservations/page.js Normal file
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
* @returns {string[]}

View File

@ -1,6 +1,6 @@
import AuthService from '../services/AuthService';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://45.93.137.91/api';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
/**
* Generic API fetch — attaches auth token, unwraps { data } envelope
@ -134,7 +134,7 @@ export async function getAvailableDateRanges(propertyId) {
}
export async function getReservations() {
return apiFetch('/Reservations/GetReservations');
return apiFetch('/Reservations/GetAllReservations');
}
export async function getReservation(id) {
@ -177,9 +177,9 @@ export async function getOwnerByUserId(userId) {
// ─── Properties ───
export async function getMyRentListings(userId) {
console.log('[API] Fetching my rent listings for user:', userId);
return apiFetch(`/RentProperties/GetMyRentListings/${userId}`);
export async function getMyRentListings() {
console.log('[API] Fetching my rent listings');
return apiFetch(`/RentProperties/GetMyRentListings`);
}
export async function addRentProperty(data) {
@ -366,3 +366,85 @@ export async function addFavoriteProperty(propId) {
export async function removeFavoriteProperty(favePropId) {
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
}
export async function getUserNotifications() {
return apiFetch('/Notifications/GetUserNotifications');
}
// ─── Booking/Reservation Management ───
export async function confirmDepositPayment(bookingId) {
return apiFetch('/Reservations/ConfirmDepositPayment', {
method: 'POST',
body: JSON.stringify({ bookingId }),
});
}
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
const token = AuthService.getToken();
const endpoint = `${API_BASE}/Reservations/AdminConfirmDeposit/admin-confirm-deposit`;
const normalizedComment =
typeof comment === 'string' && comment.trim()
? comment.trim()
: null;
const payload = {
reservationId,
adminId,
comment: normalizedComment,
};
console.log('[API] AdminConfirmDeposit request', {
method: 'PUT',
endpoint,
payload,
adminIdSource: 'jwt-user-id',
hasToken: Boolean(token),
tokenPreview: token ? `${token.slice(0, 18)}...${token.slice(-8)}` : null,
});
const res = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify(payload),
});
const text = await res.text();
let data = null;
console.log('[API] AdminConfirmDeposit raw response', {
status: res.status,
ok: res.ok,
endpoint,
rawText: text,
});
try {
data = text ? JSON.parse(text) : null;
if (data && typeof data === 'object' && 'data' in data) {
data = data.data;
}
} catch {
data = text;
}
const message = typeof data === 'object' && data?.message ? data.message : null;
console.log('[API] AdminConfirmDeposit parsed response', {
status: res.status,
ok: res.ok,
message,
data,
});
return { status: res.status, data, ok: res.ok, message };
}
export async function updateBookingStatus(bookingId, status) {
return apiFetch('/Reservations/UpdateStatus', {
method: 'PUT',
body: JSON.stringify({ bookingId, status }),
});
}

85
app/utils/firebase.js Normal file
View File

@ -0,0 +1,85 @@
import { initializeApp, getApps } from "firebase/app";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
const firebaseConfig = {
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
authDomain: "sweet-home-b2766.firebaseapp.com",
projectId: "sweet-home-b2766",
storageBucket: "sweet-home-b2766.firebasestorage.app",
messagingSenderId: "602865114600",
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
measurementId: "G-M2V95NBJLX",
};
// Initialize Firebase (avoid duplicate init in SSR)
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
// Get messaging instance (only works in browser)
let messaging = null;
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
try {
messaging = getMessaging(app);
} catch (e) {
console.warn("[Firebase] Messaging init failed:", e.message);
}
}
// Request notification permission and get FCM token
export async function requestNotificationPermission() {
if (typeof window === "undefined") return null;
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
console.log("[FCM] Notification permission denied");
return null;
}
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
const token = await getToken(messaging, {
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
serviceWorkerRegistration: registration,
});
console.log("[FCM] Token:", token);
// Send token to backend
if (token) {
try {
const authToken = localStorage.getItem("auth_token");
if (authToken) {
const apiBase = process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
await fetch(`${apiBase}/User/SetFCMToken`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ token, deviceType: 2 }), // 2 = Web
});
console.log("[FCM] Token sent to backend");
}
} catch (err) {
console.error("[FCM] Failed to send token to backend:", err);
}
}
return token;
} catch (err) {
console.error("[FCM] Error getting token:", err);
return null;
}
}
// Listen for foreground messages
export function onForegroundMessage(callback) {
if (!messaging) return () => {};
return onMessage(messaging, (payload) => {
console.log("[FCM] Foreground message:", payload);
callback(payload);
});
}
export { app, messaging };

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

View File

@ -2,6 +2,20 @@
const nextConfig = {
/* config options here */
reactCompiler: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "45.93.137.91.nip.io",
pathname: "/api/Pictures/**",
},
{
protocol: "http",
hostname: "45.93.137.91",
pathname: "/api/Pictures/**",
},
],
},
// basePath: "/sweetHome",
// assetPrefix: "/sweetHome/",
};

1057
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
},
"dependencies": {
"@pbe/react-yandex-maps": "^1.2.5",
"firebase": "^12.11.0",
"flowbite": "^4.0.1",
"flowbite-react": "^0.12.16",
"framer-motion": "^12.29.2",

View File

@ -0,0 +1,38 @@
// Firebase Cloud Messaging Service Worker
// This file MUST be in the public/ directory (served at /firebase-messaging-sw.js)
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js");
firebase.initializeApp({
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
authDomain: "sweet-home-b2766.firebaseapp.com",
projectId: "sweet-home-b2766",
storageBucket: "sweet-home-b2766.firebasestorage.app",
messagingSenderId: "602865114600",
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
measurementId: "G-M2V95NBJLX",
});
const messaging = firebase.messaging();
// Handle background messages
messaging.onBackgroundMessage((payload) => {
console.log("[FCM SW] Background message:", payload);
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
const options = {
body: payload.notification?.body || payload.data?.body || "",
icon: payload.notification?.icon || "/logo.png",
badge: "/logo.png",
data: payload.data,
tag: "sweethome-notification",
};
self.registration.showNotification(title, options);
});
// Handle notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(clients.openWindow(url));
});