Compare commits
37 Commits
be14250a08
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d3ead55ca | |||
| 97126c5776 | |||
| 1e167c447a | |||
| dd0a9c401d | |||
| 32f6c7af5a | |||
| 7e0d5eaf8d | |||
| beccd8b24f | |||
| 7e9a9d79f2 | |||
| 39f494aecb | |||
| 485baffdc2 | |||
| c46173d7c6 | |||
| 04fa34107b | |||
| 5a7d0ef265 | |||
| 0ba435fd7e | |||
| 9c2a748ae9 | |||
| db949aaeba | |||
| ae600ad41b | |||
| 16b1c7c6f6 | |||
| f761ab6f48 | |||
| 78138e6445 | |||
| 0891974440 | |||
| f925af0272 | |||
| 2346f518ce | |||
| 149058ddfc | |||
| e6249e845e | |||
| 3bdb99f2e5 | |||
| 2a1f00740f | |||
| 50836d3ec7 | |||
| d850f921b5 | |||
| 77dd052951 | |||
| 1207dbe20d | |||
| c9f52f64cb | |||
| 5fd22f0e01 | |||
| 2998a6bd75 | |||
| 571c85f14f | |||
| 9e1f8f517b | |||
| 700b446463 |
@ -6,6 +6,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
import { NavLink, MobileNavLink } from "./components/NavLinks";
|
||||||
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
|
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
|
||||||
|
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
|
||||||
import FloatingSidebar from '@/app/components/FloatingSidebar';
|
import FloatingSidebar from '@/app/components/FloatingSidebar';
|
||||||
import {
|
import {
|
||||||
Globe,
|
Globe,
|
||||||
@ -248,7 +249,7 @@ export default function ClientLayout({ children }) {
|
|||||||
عقاراتي
|
عقاراتي
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/owner/bookings">
|
<NavLink href="/owner/reservations">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
الحجوزات
|
الحجوزات
|
||||||
@ -398,7 +399,7 @@ export default function ClientLayout({ children }) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/owner/bookings"
|
href="/owner/reservations"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
@ -522,7 +523,7 @@ export default function ClientLayout({ children }) {
|
|||||||
<div className="border-t border-gray-100 my-2"></div>
|
<div className="border-t border-gray-100 my-2"></div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/tenant/bookings"
|
href="/reservations"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
|
||||||
onClick={() => setShowUserMenu(false)}
|
onClick={() => setShowUserMenu(false)}
|
||||||
>
|
>
|
||||||
@ -650,7 +651,7 @@ export default function ClientLayout({ children }) {
|
|||||||
</span>
|
</span>
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
<MobileNavLink
|
<MobileNavLink
|
||||||
href="/owner/bookings"
|
href="/owner/reservations"
|
||||||
onClick={closeMobileMenu}
|
onClick={closeMobileMenu}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@ -708,10 +709,12 @@ export default function ClientLayout({ children }) {
|
|||||||
<main
|
<main
|
||||||
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
|
||||||
>
|
>
|
||||||
<FavoritesProvider>
|
<NotificationsProvider>
|
||||||
{children}
|
<FavoritesProvider>
|
||||||
{!isAdmin && <FloatingSidebar isRTL={currentLanguage === 'ar'} />}
|
{children}
|
||||||
</FavoritesProvider>
|
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
|
||||||
|
</FavoritesProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isAuthPage && !isProfilePage && (
|
{!isAuthPage && !isProfilePage && (
|
||||||
|
|||||||
113
app/admin/add-admin/page.js
Normal file
113
app/admin/add-admin/page.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import AuthService from '@/app/services/AuthService';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function AddAdminPage() {
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
const [formState, setFormState] = useState({ fullName: '', email: '', password: '' });
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||||
|
setChecked(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (field) => (event) => {
|
||||||
|
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaved(true);
|
||||||
|
console.log('Add admin payload', formState);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!checked) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||||
|
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||||
|
العودة للرئيسية
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mt-3">إضافة مدير جديد</h1>
|
||||||
|
<p className="text-slate-500 mt-2">انشئ حساب مسؤول جديد مع صلاحيات الإدارة.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-[1.5fr_0.8fr]">
|
||||||
|
<section className="rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||||
|
<h2 className="text-xl font-semibold mb-6">بيانات المدير</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium text-slate-700">الاسم الكامل</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formState.fullName}
|
||||||
|
onChange={handleChange('fullName')}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||||
|
placeholder="مثال: محمد الأحمد"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium text-slate-700">البريد الإلكتروني</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formState.email}
|
||||||
|
onChange={handleChange('email')}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium text-slate-700">كلمة المرور</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formState.password}
|
||||||
|
onChange={handleChange('password')}
|
||||||
|
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="inline-flex items-center justify-center rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||||
|
حفظ المدير الجديد
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{saved && (
|
||||||
|
<div className="mt-6 rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||||
|
تم حفظ بيانات المدير بنجاح
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/admin/privacy/page.js
Normal file
85
app/admin/privacy/page.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import AuthService from '@/app/services/AuthService';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const initialPolicy = `1. نحترم خصوصيتك ونلتزم بحماية بياناتك الشخصية.
|
||||||
|
2. يتم استخدام المعلومات لتحسين تجربة المستخدم وتأمين الخدمة.
|
||||||
|
3. لا نشارك البيانات مع أطراف خارجية بدون موافقتك.
|
||||||
|
4. يمكنك طلب حذف بياناتك من النظام في أي وقت.`;
|
||||||
|
|
||||||
|
export default function PrivacyPolicyAdminPage() {
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
const [policyText, setPolicyText] = useState(initialPolicy);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
|
||||||
|
setChecked(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaved(true);
|
||||||
|
console.log('Privacy policy updated:', policyText);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!checked) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
|
||||||
|
<p className="text-gray-600 mb-6">هذه الصفحة لتحرير سياسة الخصوصية ولا يمكن الوصول إليها إلا للمدير.</p>
|
||||||
|
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||||
|
العودة للرئيسية
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
|
||||||
|
<p className="text-slate-500 mt-2">قم بتحديث نص سياسة الخصوصية</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-6 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">نص سياسة الخصوصية</label>
|
||||||
|
<textarea
|
||||||
|
value={policyText}
|
||||||
|
onChange={(e) => setPolicyText(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
className="w-full rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4 text-slate-700 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<button type="submit" className="rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
|
||||||
|
حفظ السياسة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{saved && (
|
||||||
|
<div className="rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
|
||||||
|
تمت حفظ سياسة الخصوصية بنجاح
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,11 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Heart, Bell, CreditCard } from 'lucide-react';
|
import { Heart, Bell, CreditCard, Shield, UserPlus } from 'lucide-react';
|
||||||
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||||
|
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||||
|
|
||||||
export default function FloatingSidebar({ isRTL }) {
|
export default function FloatingSidebar({ isRTL, isAdmin }) {
|
||||||
const { favorites } = useFavorites();
|
const { favorites } = useFavorites();
|
||||||
|
const { unreadCount } = useNotifications();
|
||||||
const [tooltip, setTooltip] = useState(null);
|
const [tooltip, setTooltip] = useState(null);
|
||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
|
|
||||||
@ -40,6 +42,24 @@ export default function FloatingSidebar({ isRTL }) {
|
|||||||
tap: { scale: 0.95 },
|
tap: { scale: 0.95 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTooltip = (id, label) => {
|
||||||
|
if (tooltip !== id) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
{label}
|
||||||
|
<span
|
||||||
|
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
||||||
|
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
||||||
|
}`}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed z-50"
|
className="fixed z-50"
|
||||||
@ -48,117 +68,122 @@ export default function FloatingSidebar({ isRTL }) {
|
|||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
>
|
>
|
||||||
<div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/50 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/90">
|
<div className="bg-white/90 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/60 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/95">
|
||||||
<motion.div
|
{isAdmin ? (
|
||||||
className="relative group"
|
<>
|
||||||
variants={buttonVariants}
|
<motion.div
|
||||||
initial="rest"
|
className="relative group"
|
||||||
whileHover="hover"
|
variants={buttonVariants}
|
||||||
whileTap="tap"
|
initial="rest"
|
||||||
onMouseEnter={() => showTooltip('favorites')}
|
whileHover="hover"
|
||||||
onMouseLeave={hideTooltip}
|
whileTap="tap"
|
||||||
>
|
onMouseEnter={() => showTooltip('addAdmin')}
|
||||||
<Link
|
onMouseLeave={hideTooltip}
|
||||||
href="/favorites"
|
|
||||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
|
||||||
{favorites.length > 0 && (
|
|
||||||
<motion.span
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
className="absolute -right-1 -top-1 w-5 h-5 bg-gradient-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
|
||||||
>
|
|
||||||
{favorites.length}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
{tooltip === 'favorites' && (
|
|
||||||
<div
|
|
||||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
|
||||||
>
|
>
|
||||||
<span className="relative">
|
<Link
|
||||||
المفضلة
|
href="/admin/add-admin"
|
||||||
<span
|
className="flex items-center justify-center w-12 h-12 rounded-xl bg-amber-50 border border-amber-200 text-amber-600 hover:bg-amber-100 transition-colors"
|
||||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
|
||||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
|
||||||
}`}
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="relative group"
|
|
||||||
variants={buttonVariants}
|
|
||||||
initial="rest"
|
|
||||||
whileHover="hover"
|
|
||||||
whileTap="tap"
|
|
||||||
onMouseEnter={() => showTooltip('notifications')}
|
|
||||||
onMouseLeave={hideTooltip}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/notifications"
|
|
||||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
|
||||||
<motion.span
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
className="absolute -right-1 -top-1 w-5 h-5 bg-gradient-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
|
||||||
>
|
>
|
||||||
3
|
<UserPlus className="w-6 h-6" />
|
||||||
</motion.span>
|
</Link>
|
||||||
</div>
|
{renderTooltip('addAdmin', 'إضافة أدمن')}
|
||||||
</Link>
|
</motion.div>
|
||||||
{tooltip === 'notifications' && (
|
|
||||||
<div
|
<motion.div
|
||||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
className="relative group"
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="rest"
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
|
onMouseEnter={() => showTooltip('editPrivacy')}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
<span className="relative">
|
<Link
|
||||||
الإشعارات
|
href="/admin/privacy"
|
||||||
<span
|
className="flex items-center justify-center w-12 h-12 rounded-xl bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 transition-colors"
|
||||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
>
|
||||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
<Shield className="w-6 h-6" />
|
||||||
}`}
|
</Link>
|
||||||
></span>
|
{renderTooltip('editPrivacy', 'تعديل سياسة الخصوصية')}
|
||||||
</span>
|
</motion.div>
|
||||||
</div>
|
</>
|
||||||
)}
|
) : (
|
||||||
</motion.div>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative group"
|
className="relative group"
|
||||||
variants={buttonVariants}
|
variants={buttonVariants}
|
||||||
initial="rest"
|
initial="rest"
|
||||||
whileHover="hover"
|
whileHover="hover"
|
||||||
whileTap="tap"
|
whileTap="tap"
|
||||||
onMouseEnter={() => showTooltip('payments')}
|
onMouseEnter={() => showTooltip('favorites')}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/payments"
|
|
||||||
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
|
||||||
</Link>
|
|
||||||
{tooltip === 'payments' && (
|
|
||||||
<div
|
|
||||||
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
|
|
||||||
>
|
>
|
||||||
<span className="relative">
|
<Link
|
||||||
المدفوعات
|
href="/favorites"
|
||||||
<span
|
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||||
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
|
>
|
||||||
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
|
<div className="relative">
|
||||||
}`}
|
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||||
></span>
|
{favorites.length > 0 && (
|
||||||
</span>
|
<motion.span
|
||||||
</div>
|
initial={{ scale: 0 }}
|
||||||
)}
|
animate={{ scale: 1 }}
|
||||||
</motion.div>
|
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||||
|
>
|
||||||
|
{favorites.length}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{renderTooltip('favorites', 'المفضلة')}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="relative group"
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="rest"
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
|
onMouseEnter={() => showTooltip('notifications')}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
|
||||||
|
>
|
||||||
|
{unreadCount}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{renderTooltip('notifications', 'الإشعارات')}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="relative group"
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="rest"
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
|
onMouseEnter={() => showTooltip('payments')}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/payments"
|
||||||
|
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
|
||||||
|
</Link>
|
||||||
|
{renderTooltip('payments', 'المدفوعات')}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -23,11 +23,11 @@ export default function NotificationHandler() {
|
|||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
function checkAuth() {
|
||||||
if (initialized.current) return;
|
if (initialized.current) return;
|
||||||
initialized.current = true;
|
|
||||||
|
|
||||||
if (!AuthService.getToken()) return;
|
if (!AuthService.getToken()) return;
|
||||||
|
initialized.current = true;
|
||||||
|
|
||||||
if ("Notification" in window) {
|
if ("Notification" in window) {
|
||||||
if (Notification.permission === "default") {
|
if (Notification.permission === "default") {
|
||||||
@ -36,9 +36,28 @@ export default function NotificationHandler() {
|
|||||||
setupFCM();
|
setupFCM();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// Also check when auth token changes (login via client-side navigation)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (!initialized.current && AuthService.getToken()) {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
// Check on route change (visibility)
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") checkAuth();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function setupFCM() {
|
async function setupFCM() {
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useProperties } from '@/app/contexts/PropertyContext';
|
import { useProperties } from '@/app/contexts/PropertyContext';
|
||||||
import { CommissionType, City, CitiesList } from '@/app/enums';
|
import { CommissionType, CitiesList } from '@/app/enums';
|
||||||
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
|
||||||
|
|
||||||
export default function AddPropertyForm({ onClose, onSuccess }) {
|
export default function AddPropertyForm({ onClose, onSuccess }) {
|
||||||
@ -131,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">اختر المدينة</option>
|
<option value="">اختر المدينة</option>
|
||||||
{Object.values(CITIES).map(city => (
|
{CitiesList.map(city => (
|
||||||
<option key={city} value={city}>{city}</option>
|
<option key={city} value={city}>{city}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Search, MapPin, Home, DollarSign } from 'lucide-react';
|
import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
export default function HeroSearch({ onSearch }) {
|
export default function HeroSearch({ onSearch, isAuthenticated }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState('rent');
|
const [activeTab, setActiveTab] = useState('buy');
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
city: '',
|
city: 'all',
|
||||||
propertyType: '',
|
propertyType: 'all',
|
||||||
priceRange: '',
|
priceRange: 'all',
|
||||||
identityType: 'syrian'
|
identityType: 'syrian',
|
||||||
|
ownerSource: 'all',
|
||||||
|
rentPeriod: 'all',
|
||||||
|
availableToday: false
|
||||||
});
|
});
|
||||||
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
|
|
||||||
const cities = [
|
const cities = [
|
||||||
{ id: 'all', label: 'جميع المدن' },
|
{ id: 'all', label: 'جميع المدن' },
|
||||||
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
|
|
||||||
const propertyTypes = [
|
const propertyTypes = [
|
||||||
{ id: 'all', label: 'الكل' },
|
{ id: 'all', label: 'الكل' },
|
||||||
{ id: 'apartment', label: 'شقة' },
|
{ id: 'apartment', label: 'شقق سكنية' },
|
||||||
{ id: 'villa', label: 'فيلا' },
|
{ id: 'studio', label: 'استوديو' },
|
||||||
{ id: 'house', label: 'بيت' },
|
{ id: 'commercial', label: 'عقار تجاري' },
|
||||||
{ id: 'studio', label: 'استوديو' }
|
{ id: 'villa', label: 'فيلا / مزرعة' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const priceRanges = [
|
const priceRanges = [
|
||||||
@ -46,17 +51,45 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
{ id: 'passport', label: 'جواز سفر' }
|
{ id: 'passport', label: 'جواز سفر' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ownerSources = [
|
||||||
|
{ id: 'all', label: 'الكل' },
|
||||||
|
{ id: 'owner', label: 'من المالك' },
|
||||||
|
{ id: 'agency', label: 'من مكتب عقاري' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const rentPeriods = [
|
||||||
|
{ id: 'all', label: 'الكل' },
|
||||||
|
{ id: 'daily', label: 'إيجار يومي' },
|
||||||
|
{ id: 'monthly', label: 'إيجار شهري' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
|
||||||
|
setShowLoginDialog(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
|
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
|
||||||
|
setShowLoginDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onSearch({
|
onSearch({
|
||||||
...filters,
|
...filters,
|
||||||
propertyType: filters.propertyType || 'all',
|
mode: activeTab,
|
||||||
city: filters.city || 'all',
|
city: filters.city || 'all',
|
||||||
priceRange: filters.priceRange || 'all'
|
propertyType: filters.propertyType || 'all',
|
||||||
|
priceRange: filters.priceRange || 'all',
|
||||||
|
ownerSource: filters.ownerSource || 'all',
|
||||||
|
rentPeriod: filters.rentPeriod || 'all'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<>
|
||||||
|
<motion.div
|
||||||
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
{['rent', 'buy', 'sell'].map((tab) => (
|
{['rent', 'buy', 'sell'].map((tab) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => handleTabClick(tab)}
|
||||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-amber-500 text-white'
|
? 'bg-amber-500 text-white'
|
||||||
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
|
||||||
|
<select
|
||||||
|
value={filters.ownerSource}
|
||||||
|
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'left 1rem center',
|
||||||
|
backgroundSize: '1rem',
|
||||||
|
paddingLeft: '2.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ownerSources.map((source) => (
|
||||||
|
<option key={source.id} value={source.id}>
|
||||||
|
{source.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
|
||||||
|
<select
|
||||||
|
value={filters.rentPeriod}
|
||||||
|
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'left 1rem center',
|
||||||
|
backgroundSize: '1rem',
|
||||||
|
paddingLeft: '2.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rentPeriods.map((period) => (
|
||||||
|
<option key={period.id} value={period.id}>
|
||||||
|
{period.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
|
||||||
|
<label className="mt-4 flex items-center gap-3 text-white text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.availableToday}
|
||||||
|
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
|
||||||
|
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">عرض فقط العقارات المتاحة من اليوم</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{showLoginDialog && !isAuthenticated && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
|
||||||
|
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<ShieldCheck className="w-7 h-7 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
|
||||||
|
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl bg-gray-50 p-4">
|
||||||
|
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLoginDialog(false)}
|
||||||
|
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
إغلاق
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
تسجيل الدخول
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
317
app/components/ratings/PropertyRatingForm.js
Normal file
317
app/components/ratings/PropertyRatingForm.js
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import StarRating from './StarRating';
|
||||||
|
import { addPropertyRating } from '../../utils/ratings';
|
||||||
|
|
||||||
|
const RatingField = ({ label, value, onChange }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">{label} <span className="text-red-500">*</span></label>
|
||||||
|
<StarRating rating={value} onRatingChange={onChange} size={28} />
|
||||||
|
{value === 0 && <p className="text-xs text-red-500">مطلوب</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PropertyRatingForm({ reservationId, onSuccess, onCancel }) {
|
||||||
|
const [cleanRating, setCleanRating] = useState(0);
|
||||||
|
const [servicesRating, setServicesRating] = useState(0);
|
||||||
|
const [ownerBehaviorRating, setOwnerBehaviorRating] = useState(0);
|
||||||
|
const [experienceRating, setExperienceRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (cleanRating === 0) return 'نظافة العقار';
|
||||||
|
if (servicesRating === 0) return 'جودة الخدمات';
|
||||||
|
if (ownerBehaviorRating === 0) return 'سلوك المالك';
|
||||||
|
if (experienceRating === 0) return 'التجربة العامة';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const missing = validate();
|
||||||
|
if (missing) {
|
||||||
|
toast.error(`يرجى تقييم: ${missing}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await addPropertyRating({
|
||||||
|
reservationId,
|
||||||
|
cleanRating,
|
||||||
|
servicesRating,
|
||||||
|
ownerBehaviorRating,
|
||||||
|
experienceRating,
|
||||||
|
comment: comment.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success('تم إرسال التقييم بنجاح!');
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('حدث خطأ، حاول مرة أخرى');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">تقييم العقار</h3>
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<RatingField label="نظافة العقار" value={cleanRating} onChange={setCleanRating} />
|
||||||
|
<RatingField label="جودة الخدمات" value={servicesRating} onChange={setServicesRating} />
|
||||||
|
<RatingField label="سلوك المالك" value={ownerBehaviorRating} onChange={setOwnerBehaviorRating} />
|
||||||
|
<RatingField label="التجربة العامة" value={experienceRating} onChange={setExperienceRating} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">تعليق (اختياري)</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="w-full mt-1 px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
|
||||||
|
placeholder="شارك تجربتك..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 rounded-xl transition flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||||
|
{loading ? 'جاري الإرسال...' : 'إرسال التقييم'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
app/components/ratings/PropertyRatingList.js
Normal file
286
app/components/ratings/PropertyRatingList.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
// '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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Star, User, Calendar, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
import { getPropertyRatings, getPropertyAverageRating } from '../../utils/ratings';
|
||||||
|
|
||||||
|
const RatingItem = ({ rating }) => {
|
||||||
|
const overall = (
|
||||||
|
rating.cleanRating + rating.servicesRating +
|
||||||
|
rating.ownerBehaviorRating + rating.experienceRating
|
||||||
|
) / 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-100 py-4 last:border-0">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-800">{rating.customerName || 'مستأجر'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-amber-50 px-2 py-1 rounded-full">
|
||||||
|
<Star className="w-3 h-3 text-amber-500 fill-amber-500" />
|
||||||
|
<span className="text-sm font-bold">{overall.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-2">
|
||||||
|
<div>النظافة: {rating.cleanRating}/5</div>
|
||||||
|
<div>الخدمات: {rating.servicesRating}/5</div>
|
||||||
|
<div>سلوك المالك: {rating.ownerBehaviorRating}/5</div>
|
||||||
|
<div>التجربة: {rating.experienceRating}/5</div>
|
||||||
|
</div>
|
||||||
|
{rating.comment && (
|
||||||
|
<p className="text-gray-700 text-sm mt-2 pr-4 border-r-2 border-amber-200">"{rating.comment}"</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400 mt-2">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{new Date(rating.createdAt).toLocaleDateString('ar-SA')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PropertyRatingList({ propertyId }) {
|
||||||
|
const [ratings, setRatings] = useState([]);
|
||||||
|
const [average, setAverage] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const fetchRatings = async (reset = false) => {
|
||||||
|
const currentPage = reset ? 1 : page;
|
||||||
|
try {
|
||||||
|
if (reset) setLoading(true);
|
||||||
|
else setLoadingMore(true);
|
||||||
|
const result = await getPropertyRatings(propertyId, currentPage, 10);
|
||||||
|
const items = result?.items || result?.data?.items || result || [];
|
||||||
|
const totalPages = result?.totalPages || result?.data?.totalPages || 1;
|
||||||
|
setRatings(prev => reset ? items : [...prev, ...items]);
|
||||||
|
setHasMore(currentPage < totalPages);
|
||||||
|
if (reset) setPage(1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAverage = async () => {
|
||||||
|
try {
|
||||||
|
const avg = await getPropertyAverageRating(propertyId);
|
||||||
|
setAverage(avg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (propertyId) {
|
||||||
|
fetchRatings(true);
|
||||||
|
fetchAverage();
|
||||||
|
}
|
||||||
|
}, [propertyId]);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
fetchRatings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && ratings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-amber-500 mx-auto" />
|
||||||
|
<p className="text-gray-500 mt-2">جاري تحميل التقييمات...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4 pb-3 border-b border-gray-100">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">تقييمات المستأجرين</h3>
|
||||||
|
{average !== null && (
|
||||||
|
<div className="flex items-center gap-1 bg-amber-50 px-3 py-1 rounded-full">
|
||||||
|
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
|
||||||
|
<span className="font-bold text-lg">{average.toFixed(1)}</span>
|
||||||
|
<span className="text-gray-500 text-sm">/5</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ratings.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-6">لا توجد تقييمات حتى الآن. كن أول من يقيم هذا العقار.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ratings.map((r, idx) => <RatingItem key={idx} rating={r} />)}
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="w-full mt-4 py-2 text-amber-600 hover:text-amber-700 font-medium text-sm flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{loadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
{loadingMore ? 'جاري التحميل...' : 'عرض المزيد'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
app/components/ratings/StarRating.js
Normal file
134
app/components/ratings/StarRating.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// 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 'ضعيف';
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
|
||||||
|
const StarRating = ({ rating = 0, onRatingChange, size = 28, readOnly = false }) => {
|
||||||
|
const [hoverRating, setHoverRating] = useState(0);
|
||||||
|
|
||||||
|
const handleClick = (value) => {
|
||||||
|
if (!readOnly && onRatingChange) onRatingChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex gap-1"
|
||||||
|
onMouseLeave={() => !readOnly && setHoverRating(0)}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const filled = (hoverRating || rating) >= star;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClick(star)}
|
||||||
|
onMouseEnter={() => !readOnly && setHoverRating(star)}
|
||||||
|
className="focus:outline-none transition-transform hover:scale-110"
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={size}
|
||||||
|
className={`${filled ? 'fill-amber-500 text-amber-500' : 'text-gray-300 fill-transparent'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StarRating;
|
||||||
75
app/contexts/NotificationsContext.js
Normal file
75
app/contexts/NotificationsContext.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getUserNotifications } from '../utils/api';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
|
||||||
|
const NotificationsContext = createContext();
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const context = useContext(NotificationsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotifications must be used within NotificationsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationsProvider({ children }) {
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
if (!AuthService.isAuthenticated()) {
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getUserNotifications();
|
||||||
|
const notificationsArray = Array.isArray(data) ? data : [];
|
||||||
|
setNotifications(notificationsArray);
|
||||||
|
// Assuming all are unread for now, or add logic to check 'read' field if exists
|
||||||
|
setUnreadCount(notificationsArray.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notifications:', error);
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
const markAsRead = useCallback((id) => {
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n => (n.id === id ? { ...n, read: true } : n))
|
||||||
|
);
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markAllAsRead = useCallback(() => {
|
||||||
|
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||||
|
setUnreadCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
isLoading,
|
||||||
|
fetchNotifications,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from "react-hot-toast";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Lock,
|
Lock,
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Phone,
|
Phone,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
} from 'lucide-react';
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
loginWithEmail,
|
loginWithEmail,
|
||||||
loginWithPhone,
|
loginWithPhone,
|
||||||
@ -30,44 +30,45 @@ import {
|
|||||||
isPhoneNumber,
|
isPhoneNumber,
|
||||||
getOwnerByUserId,
|
getOwnerByUserId,
|
||||||
getCustomerByUserId,
|
getCustomerByUserId,
|
||||||
} from '../utils/api';
|
} from "../utils/api";
|
||||||
import AuthService from '../services/AuthService';
|
import AuthService from "../services/AuthService";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Step: 'login' | 'otp'
|
// Step: 'login' | 'otp'
|
||||||
const [step, setStep] = useState('login');
|
const [step, setStep] = useState("login");
|
||||||
const [loginMethod, setLoginMethod] = useState('email'); // 'email' | 'phone'
|
const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
credential: '',
|
credential: "",
|
||||||
password: '',
|
password: "",
|
||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [otpCode, setOtpCode] = useState('');
|
const [otpCode, setOtpCode] = useState("");
|
||||||
const [otpError, setOtpError] = useState('');
|
const [otpError, setOtpError] = useState("");
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors = {};
|
const newErrors = {};
|
||||||
|
|
||||||
if (!formData.credential) {
|
if (!formData.credential) {
|
||||||
newErrors.credential = loginMethod === 'email'
|
newErrors.credential =
|
||||||
? 'البريد الإلكتروني مطلوب'
|
loginMethod === "email"
|
||||||
: 'رقم الهاتف مطلوب';
|
? "البريد الإلكتروني مطلوب"
|
||||||
} else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
: "رقم الهاتف مطلوب";
|
||||||
newErrors.credential = 'البريد الإلكتروني غير صالح';
|
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
|
||||||
} else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
// newErrors.credential = 'البريد الإلكتروني غير صالح';
|
||||||
newErrors.credential = 'رقم الهاتف غير صالح';
|
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
|
||||||
|
newErrors.credential = "رقم الهاتف غير صالح";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
newErrors.password = 'كلمة المرور مطلوبة';
|
newErrors.password = "كلمة المرور مطلوبة";
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@ -82,17 +83,25 @@ export default function LoginPage() {
|
|||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loginFn = loginMethod === 'email' ? loginWithEmail : loginWithPhone;
|
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
|
||||||
console.log('[Login] Attempting login via', loginMethod, ':', formData.credential);
|
console.log(
|
||||||
|
"[Login] Attempting login via",
|
||||||
|
loginMethod,
|
||||||
|
":",
|
||||||
|
formData.credential,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await loginFn(formData.credential, formData.password);
|
const result = await loginFn(formData.credential, formData.password);
|
||||||
|
|
||||||
console.log('[Login] Response status:', result.status);
|
console.log("[Login] Response status:", result.status);
|
||||||
|
|
||||||
if (result.status === 200) {
|
if (result.status === 200) {
|
||||||
const token = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken;
|
const token =
|
||||||
|
typeof result.data === "string"
|
||||||
|
? result.data
|
||||||
|
: result.data?.token || result.data?.accessToken;
|
||||||
AuthService.addToken(token);
|
AuthService.addToken(token);
|
||||||
console.log('[Login] Token stored');
|
console.log("[Login] Token stored");
|
||||||
|
|
||||||
// Fetch user profile to get full name
|
// Fetch user profile to get full name
|
||||||
const authUser = AuthService.getUser();
|
const authUser = AuthService.getUser();
|
||||||
@ -103,71 +112,81 @@ export default function LoginPage() {
|
|||||||
const profile = await fetchFn(authUser.id);
|
const profile = await fetchFn(authUser.id);
|
||||||
if (profile) {
|
if (profile) {
|
||||||
AuthService.cacheUser({
|
AuthService.cacheUser({
|
||||||
name: profile.fullName || profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
|
name:
|
||||||
|
profile.fullName ||
|
||||||
|
profile.name ||
|
||||||
|
`${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||||
email: profile.email || authUser.email,
|
email: profile.email || authUser.email,
|
||||||
phone: profile.phone || profile.phoneNumber || authUser.phone,
|
phone: profile.phone || profile.phoneNumber || authUser.phone,
|
||||||
});
|
});
|
||||||
console.log('[Login] User profile cached');
|
console.log("[Login] User profile cached");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Login] Failed to fetch profile:', err);
|
console.warn("[Login] Failed to fetch profile:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRole = AuthService.isAdmin() ? 'admin'
|
const userRole = AuthService.isAdmin()
|
||||||
: AuthService.isOwner() ? 'owner'
|
? "admin"
|
||||||
: 'customer';
|
: AuthService.isOwner()
|
||||||
console.log('[Login] User role:', userRole);
|
? "owner"
|
||||||
|
: "customer";
|
||||||
|
console.log("[Login] User role:", userRole);
|
||||||
|
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
toast.success('تم تسجيل الدخول بنجاح!', {
|
toast.success("تم تسجيل الدخول بنجاح!", {
|
||||||
style: { background: '#dcfce7', color: '#166534' },
|
style: { background: "#dcfce7", color: "#166534" },
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (userRole === 'admin') {
|
if (userRole === "admin") {
|
||||||
router.push('/admin');
|
router.push("/admin");
|
||||||
} else {
|
} else {
|
||||||
router.push('/');
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
} else if (result.status === 206) {
|
} else if (result.status === 206) {
|
||||||
console.log('[Login] 206 — OTP required');
|
console.log("[Login] 206 — OTP required");
|
||||||
const tempToken = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken;
|
const tempToken =
|
||||||
|
typeof result.data === "string"
|
||||||
|
? result.data
|
||||||
|
: result.data?.token || result.data?.accessToken;
|
||||||
if (tempToken) {
|
if (tempToken) {
|
||||||
AuthService.addToken(tempToken);
|
AuthService.addToken(tempToken);
|
||||||
console.log('[Login] Temp token stored for OTP');
|
console.log("[Login] Temp token stored for OTP");
|
||||||
}
|
}
|
||||||
toast('يرجى إدخال رمز التحقق', {
|
toast("يرجى إدخال رمز التحقق", {
|
||||||
icon: '🔐',
|
icon: "🔐",
|
||||||
style: { background: '#fef3c7', color: '#92400e' },
|
style: { background: "#fef3c7", color: "#92400e" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send OTP
|
// Send OTP
|
||||||
try {
|
try {
|
||||||
if (loginMethod === 'email') {
|
if (loginMethod === "email") {
|
||||||
await sendEmailOTP();
|
await sendEmailOTP();
|
||||||
} else {
|
} else {
|
||||||
await sendPhoneOTP();
|
await sendPhoneOTP();
|
||||||
}
|
}
|
||||||
console.log('[Login] OTP sent successfully');
|
console.log("[Login] OTP sent successfully");
|
||||||
} catch (otpErr) {
|
} catch (otpErr) {
|
||||||
console.warn('[Login] OTP send failed, proceeding anyway:', otpErr);
|
console.warn("[Login] OTP send failed, proceeding anyway:", otpErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep('otp');
|
setStep("otp");
|
||||||
} else {
|
} else {
|
||||||
// Other error
|
// Other error
|
||||||
console.error('[Login] Unexpected status:', result.status, result.data);
|
console.error("[Login] Unexpected status:", result.status, result.data);
|
||||||
toast.error(result.data?.message || result.data || 'بيانات الدخول غير صحيحة', {
|
toast.error(
|
||||||
style: { background: '#fee2e2', color: '#991b1b' },
|
result.data?.message || result.data || "بيانات الدخول غير صحيحة",
|
||||||
});
|
{
|
||||||
|
style: { background: "#fee2e2", color: "#991b1b" },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Login] Error:', err);
|
console.error("[Login] Error:", err);
|
||||||
toast.error(err.message || 'حدث خطأ في الاتصال', {
|
toast.error(err.message || "حدث خطأ في الاتصال", {
|
||||||
style: { background: '#fee2e2', color: '#991b1b' },
|
style: { background: "#fee2e2", color: "#991b1b" },
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -177,43 +196,46 @@ export default function LoginPage() {
|
|||||||
const handleVerifyOTP = async (e) => {
|
const handleVerifyOTP = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!otpCode || otpCode.length < 4) {
|
if (!otpCode || otpCode.length < 4) {
|
||||||
setOtpError('يرجى إدخال رمز التحقق');
|
setOtpError("يرجى إدخال رمز التحقق");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setOtpError('');
|
setOtpError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verifyFn = loginMethod === 'email' ? verifyEmail : verifyPhone;
|
const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
|
||||||
console.log('[OTP] Verifying code:', otpCode);
|
console.log("[OTP] Verifying code:", otpCode);
|
||||||
|
|
||||||
const result = await verifyFn(otpCode);
|
const result = await verifyFn(otpCode);
|
||||||
console.log('[OTP] Verify response status:', result.status);
|
console.log("[OTP] Verify response status:", result.status);
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const finalToken = typeof result.data === 'string' ? result.data : result.data?.token || result.data?.accessToken;
|
const finalToken =
|
||||||
if (finalToken && typeof finalToken === 'string') {
|
typeof result.data === "string"
|
||||||
|
? result.data
|
||||||
|
: result.data?.token || result.data?.accessToken;
|
||||||
|
if (finalToken && typeof finalToken === "string") {
|
||||||
AuthService.addToken(finalToken);
|
AuthService.addToken(finalToken);
|
||||||
console.log('[OTP] Final token stored');
|
console.log("[OTP] Final token stored");
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
toast.success('تم التحقق بنجاح!', {
|
toast.success("تم التحقق بنجاح!", {
|
||||||
style: { background: '#dcfce7', color: '#166534' },
|
style: { background: "#dcfce7", color: "#166534" },
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('[OTP] Redirecting to home');
|
console.log("[OTP] Redirecting to home");
|
||||||
router.push('/');
|
router.push("/");
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
console.error('[OTP] Verification failed:', result.data);
|
console.error("[OTP] Verification failed:", result.data);
|
||||||
setOtpError(result.data?.message || 'رمز التحقق غير صحيح');
|
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OTP] Error:', err);
|
console.error("[OTP] Error:", err);
|
||||||
setOtpError(err.message || 'حدث خطأ في التحقق');
|
setOtpError(err.message || "حدث خطأ في التحقق");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -221,18 +243,18 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const resendOTP = async () => {
|
const resendOTP = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[OTP] Resending OTP via', loginMethod);
|
console.log("[OTP] Resending OTP via", loginMethod);
|
||||||
if (loginMethod === 'email') {
|
if (loginMethod === "email") {
|
||||||
await sendEmailOTP();
|
await sendEmailOTP();
|
||||||
} else {
|
} else {
|
||||||
await sendPhoneOTP();
|
await sendPhoneOTP();
|
||||||
}
|
}
|
||||||
toast.success('تم إرسال رمز التحقق مجدداً', {
|
toast.success("تم إرسال رمز التحقق مجدداً", {
|
||||||
style: { background: '#dcfce7', color: '#166534' },
|
style: { background: "#dcfce7", color: "#166534" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OTP] Resend failed:', err);
|
console.error("[OTP] Resend failed:", err);
|
||||||
toast.error('فشل إرسال الرمز');
|
toast.error("فشل إرسال الرمز");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -242,11 +264,11 @@ export default function LoginPage() {
|
|||||||
if (errors.credential) setErrors({ ...errors, credential: null });
|
if (errors.credential) setErrors({ ...errors, credential: null });
|
||||||
|
|
||||||
// Auto-switch method
|
// Auto-switch method
|
||||||
if (isEmail(value)) {
|
// if (isEmail(value)) {
|
||||||
setLoginMethod('email');
|
// setLoginMethod('email');
|
||||||
} else if (isPhoneNumber(value)) {
|
// } else if (isPhoneNumber(value)) {
|
||||||
setLoginMethod('phone');
|
// setLoginMethod('phone');
|
||||||
}
|
// }
|
||||||
};
|
};
|
||||||
|
|
||||||
const particles = Array.from({ length: 20 }, (_, i) => ({
|
const particles = Array.from({ length: 20 }, (_, i) => ({
|
||||||
@ -271,7 +293,7 @@ export default function LoginPage() {
|
|||||||
visible: {
|
visible: {
|
||||||
y: 0,
|
y: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: { type: 'spring', stiffness: 100 },
|
transition: { type: "spring", stiffness: 100 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -285,9 +307,23 @@ export default function LoginPage() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="absolute rounded-full bg-amber-500/20"
|
className="absolute rounded-full bg-amber-500/20"
|
||||||
style={{ left: `${p.x}%`, top: `${p.y}%`, width: p.size, height: p.size }}
|
style={{
|
||||||
animate={{ y: [0, -20, 0], x: [0, 10, -10, 0], opacity: [0.2, 0.4, 0.2] }}
|
left: `${p.x}%`,
|
||||||
transition={{ duration: p.duration, repeat: Infinity, delay: p.delay, ease: 'linear' }}
|
top: `${p.y}%`,
|
||||||
|
width: p.size,
|
||||||
|
height: p.size,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
y: [0, -20, 0],
|
||||||
|
x: [0, 10, -10, 0],
|
||||||
|
opacity: [0.2, 0.4, 0.2],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: p.duration,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: p.delay,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -312,8 +348,14 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<motion.div variants={itemVariants} className="absolute -top-16 left-0">
|
<motion.div variants={itemVariants} className="absolute -top-16 left-0">
|
||||||
<Link href="/" className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
|
<Link
|
||||||
<motion.div whileHover={{ x: -5 }} transition={{ type: 'spring', stiffness: 400 }}>
|
href="/"
|
||||||
|
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ x: -5 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400 }}
|
||||||
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<span>العودة للرئيسية</span>
|
<span>العودة للرئيسية</span>
|
||||||
@ -329,23 +371,28 @@ export default function LoginPage() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ delay: 0.2, type: 'spring' }}
|
transition={{ delay: 0.2, type: "spring" }}
|
||||||
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full"
|
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ delay: 0.3, type: 'spring' }}
|
transition={{ delay: 0.3, type: "spring" }}
|
||||||
className="absolute -bottom-10 -left-10 w-40 h-40 bg-white/10 rounded-full"
|
className="absolute -bottom-10 -left-10 w-40 h-40 bg-white/10 rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="relative z-10">
|
<motion.div
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: [0, 10, -10, 0] }}
|
animate={{ rotate: [0, 10, -10, 0] }}
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
{step === 'otp' ? (
|
{step === "otp" ? (
|
||||||
<KeyRound className="w-10 h-10 text-white" />
|
<KeyRound className="w-10 h-10 text-white" />
|
||||||
) : (
|
) : (
|
||||||
<Home className="w-10 h-10 text-white" />
|
<Home className="w-10 h-10 text-white" />
|
||||||
@ -353,14 +400,14 @@ export default function LoginPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
|
||||||
<p className="text-amber-100">
|
<p className="text-amber-100">
|
||||||
{step === 'otp' ? 'أدخل رمز التحقق' : 'مرحباً بعودتك!'}
|
{step === "otp" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{step === 'login' ? (
|
{step === "login" ? (
|
||||||
<motion.form
|
<motion.form
|
||||||
key="login"
|
key="login"
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
@ -374,13 +421,13 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLoginMethod('email');
|
setLoginMethod("email");
|
||||||
setFormData({ ...formData, credential: '' });
|
setFormData({ ...formData, credential: "" });
|
||||||
}}
|
}}
|
||||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||||
loginMethod === 'email'
|
loginMethod === "email"
|
||||||
? 'bg-amber-500 text-white shadow-lg'
|
? "bg-amber-500 text-white shadow-lg"
|
||||||
: 'text-gray-400 hover:text-white'
|
: "text-gray-400 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4" />
|
||||||
@ -389,13 +436,13 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLoginMethod('phone');
|
setLoginMethod("phone");
|
||||||
setFormData({ ...formData, credential: '' });
|
setFormData({ ...formData, credential: "" });
|
||||||
}}
|
}}
|
||||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||||
loginMethod === 'phone'
|
loginMethod === "phone"
|
||||||
? 'bg-amber-500 text-white shadow-lg'
|
? "bg-amber-500 text-white shadow-lg"
|
||||||
: 'text-gray-400 hover:text-white'
|
: "text-gray-400 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Phone className="w-4 h-4" />
|
<Phone className="w-4 h-4" />
|
||||||
@ -406,29 +453,46 @@ export default function LoginPage() {
|
|||||||
{/* Credential input */}
|
{/* Credential input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
{loginMethod === 'email' ? 'البريد الإلكتروني' : 'رقم الهاتف'}
|
{loginMethod === "email"
|
||||||
|
? "البريد الإلكتروني"
|
||||||
|
: "رقم الهاتف"}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
{loginMethod === 'email' ? (
|
{loginMethod === "email" ? (
|
||||||
<Mail className={`w-5 h-5 transition-colors ${errors.credential ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
<Mail
|
||||||
|
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Phone className={`w-5 h-5 transition-colors ${errors.credential ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
<Phone
|
||||||
|
className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={loginMethod === 'email' ? 'email' : 'tel'}
|
type="text"
|
||||||
|
// type={loginMethod === 'email' ? 'email' : 'tel'}
|
||||||
value={formData.credential}
|
value={formData.credential}
|
||||||
onChange={(e) => handleCredentialChange(e.target.value)}
|
onChange={(e) => handleCredentialChange(e.target.value)}
|
||||||
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||||
errors.credential ? 'border-red-500' : 'border-gray-700'
|
errors.credential
|
||||||
|
? "border-red-500"
|
||||||
|
: "border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
placeholder={loginMethod === 'email' ? 'example@email.com' : '+963XXXXXXXXX'}
|
placeholder={
|
||||||
|
loginMethod === "email"
|
||||||
|
? "example@email.com"
|
||||||
|
: "+963XXXXXXXXX"
|
||||||
|
}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.credential && (
|
{errors.credential && (
|
||||||
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1">
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-red-500 text-sm mt-1"
|
||||||
|
>
|
||||||
{errors.credential}
|
{errors.credential}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
@ -436,24 +500,36 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور</label>
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
كلمة المرور
|
||||||
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'}`} />
|
<Lock
|
||||||
|
className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFormData({ ...formData, password: e.target.value });
|
setFormData({
|
||||||
if (errors.password) setErrors({ ...errors, password: null });
|
...formData,
|
||||||
|
password: e.target.value,
|
||||||
|
});
|
||||||
|
if (errors.password)
|
||||||
|
setErrors({ ...errors, password: null });
|
||||||
}}
|
}}
|
||||||
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
|
||||||
errors.password ? 'border-red-500' : 'border-gray-700'
|
errors.password ? "border-red-500" : "border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
placeholder="أدخل كلمة المرور"
|
placeholder="أدخل كلمة المرور"
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||||
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
|
||||||
) : (
|
) : (
|
||||||
@ -462,7 +538,11 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1">
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-red-500 text-sm mt-1"
|
||||||
|
>
|
||||||
{errors.password}
|
{errors.password}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
@ -474,12 +554,22 @@ export default function LoginPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.rememberMe}
|
checked={formData.rememberMe}
|
||||||
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
rememberMe: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">تذكرني</span>
|
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
|
||||||
|
تذكرني
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<Link href="/forgot-password" className="text-sm text-amber-400 hover:text-amber-300 transition-colors">
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
نسيت كلمة المرور؟
|
نسيت كلمة المرور؟
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -525,7 +615,7 @@ export default function LoginPage() {
|
|||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
|
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
|
||||||
<p className="text-gray-300 text-sm">
|
<p className="text-gray-300 text-sm">
|
||||||
تم إرسال رمز التحقق إلى{' '}
|
تم إرسال رمز التحقق إلى{" "}
|
||||||
<span className="text-white font-medium" dir="ltr">
|
<span className="text-white font-medium" dir="ltr">
|
||||||
{formData.credential}
|
{formData.credential}
|
||||||
</span>
|
</span>
|
||||||
@ -533,23 +623,29 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">رمز التحقق</label>
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
رمز التحقق
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setOtpCode(e.target.value);
|
setOtpCode(e.target.value);
|
||||||
if (otpError) setOtpError('');
|
if (otpError) setOtpError("");
|
||||||
}}
|
}}
|
||||||
className={`w-full px-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white text-center text-2xl tracking-[0.5em] placeholder-gray-500 transition-all ${
|
className={`w-full px-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white text-center text-2xl tracking-[0.5em] placeholder-gray-500 transition-all ${
|
||||||
otpError ? 'border-red-500' : 'border-gray-700'
|
otpError ? "border-red-500" : "border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
placeholder="______"
|
placeholder="______"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
{otpError && (
|
{otpError && (
|
||||||
<motion.p initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 text-sm mt-1 text-center">
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-red-500 text-sm mt-1 text-center"
|
||||||
|
>
|
||||||
{otpError}
|
{otpError}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
@ -586,10 +682,10 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep('login');
|
setStep("login");
|
||||||
setOtpCode('');
|
setOtpCode("");
|
||||||
setOtpError('');
|
setOtpError("");
|
||||||
console.log('[OTP] Going back to login');
|
console.log("[OTP] Going back to login");
|
||||||
}}
|
}}
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
@ -607,20 +703,39 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<motion.p variants={itemVariants} className="text-center text-gray-400 mt-6">
|
<motion.p
|
||||||
ليس لديك حساب؟{' '}
|
variants={itemVariants}
|
||||||
<Link href="/auth/choose-role" className="text-amber-400 hover:text-amber-300 font-medium transition-colors">
|
className="text-center text-gray-400 mt-6"
|
||||||
|
>
|
||||||
|
ليس لديك حساب؟{" "}
|
||||||
|
<Link
|
||||||
|
href="/auth/choose-role"
|
||||||
|
className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
إنشاء حساب جديد
|
إنشاء حساب جديد
|
||||||
</Link>
|
</Link>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.p variants={itemVariants} className="text-center text-gray-500 text-xs mt-4">
|
<motion.p
|
||||||
بتسجيل الدخول، أنت توافق على{' '}
|
variants={itemVariants}
|
||||||
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors">شروط الاستخدام</Link>
|
className="text-center text-gray-500 text-xs mt-4"
|
||||||
{' '}و{' '}
|
>
|
||||||
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors">سياسة الخصوصية</Link>
|
بتسجيل الدخول، أنت توافق على{" "}
|
||||||
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
شروط الاستخدام
|
||||||
|
</Link>{" "}
|
||||||
|
و{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
سياسة الخصوصية
|
||||||
|
</Link>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,71 +4,28 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
|
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
|
||||||
import AuthService from '@/app/services/AuthService';
|
import AuthService from '@/app/services/AuthService';
|
||||||
|
import { useNotifications } from '@/app/contexts/NotificationsContext';
|
||||||
const mockNotifications = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: 'booking',
|
|
||||||
title: 'تأكيد الحجز',
|
|
||||||
message: 'تم تأكيد حجزك في فيلا المزة للفترة 10-15 مارس',
|
|
||||||
date: '2024-03-01',
|
|
||||||
read: false,
|
|
||||||
icon: CheckCircle,
|
|
||||||
color: 'text-green-600',
|
|
||||||
bgColor: 'bg-green-50'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: 'payment',
|
|
||||||
title: 'دفعة مستلمة',
|
|
||||||
message: 'تم استلام دفعة الإيجار بقيمة 500,000 ل.س',
|
|
||||||
date: '2024-02-28',
|
|
||||||
read: false,
|
|
||||||
icon: MessageCircle,
|
|
||||||
color: 'text-blue-600',
|
|
||||||
bgColor: 'bg-blue-50'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
type: 'reminder',
|
|
||||||
title: 'تذكير بالإيجار',
|
|
||||||
message: 'ينتهي عقد الإيجار خلال 3 أيام',
|
|
||||||
date: '2024-02-25',
|
|
||||||
read: true,
|
|
||||||
icon: Calendar,
|
|
||||||
color: 'text-amber-600',
|
|
||||||
bgColor: 'bg-amber-50'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [notifications, setNotifications] = useState([]);
|
const { notifications, unreadCount, isLoading } = useNotifications();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (AuthService.isAdmin()) {
|
if (!AuthService.isAuthenticated()) {
|
||||||
router.push('/');
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
|
||||||
setNotifications(mockNotifications);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const markAsRead = (id) => {
|
const markAsRead = (id) => {
|
||||||
setNotifications(prev =>
|
// This will be handled by context if needed
|
||||||
prev.map(n => (n.id === id ? { ...n, read: true } : n))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
const markAllAsRead = () => {
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
// This will be handled by context if needed
|
||||||
};
|
};
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => !n.read).length;
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@ -80,6 +37,18 @@ export default function NotificationsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-gray-700 mb-2">خطأ في التحميل</h3>
|
||||||
|
<p className="text-gray-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
<div className="container mx-auto px-4 max-w-4xl">
|
<div className="container mx-auto px-4 max-w-4xl">
|
||||||
@ -100,30 +69,31 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{notifications.map((notification) => {
|
{notifications.map((notification, index) => (
|
||||||
const Icon = notification.icon;
|
<div
|
||||||
return (
|
key={index}
|
||||||
<div
|
className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
|
||||||
key={notification.id}
|
>
|
||||||
className={`bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md 'border-gray-200'}`}
|
<div className="p-5 flex gap-4">
|
||||||
>
|
<div className="w-12 h-12 bg-blue-50 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="p-5 flex gap-4">
|
<Bell className="w-6 h-6 text-blue-600" />
|
||||||
<div className={`w-12 h-12 ${notification.bgColor} rounded-full flex items-center justify-center flex-shrink-0`}>
|
</div>
|
||||||
<Icon className={`w-6 h-6 ${notification.color}`} />
|
<div className="flex-1">
|
||||||
</div>
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<div className="flex justify-between items-start">
|
<h3 className="font-bold text-gray-900">{notification.title}</h3>
|
||||||
<div>
|
{notification.message && (
|
||||||
<h3 className="font-bold text-gray-900">{notification.title}</h3>
|
|
||||||
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
|
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
|
||||||
|
)}
|
||||||
|
{notification.date && (
|
||||||
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
|
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -71,7 +71,7 @@ const MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapCo
|
|||||||
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
|
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
|
||||||
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
|
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
|
||||||
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false });
|
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false });
|
||||||
const useMapEvents = dynamic(() => import('react-leaflet').then(mod => mod.useMapEvents), { ssr: false });
|
import { useMapEvents } from 'react-leaflet';
|
||||||
|
|
||||||
function MapClickHandler({ onMapClick }) {
|
function MapClickHandler({ onMapClick }) {
|
||||||
const map = useMapEvents({
|
const map = useMapEvents({
|
||||||
|
|||||||
262
app/owner/reservations/page.js
Normal file
262
app/owner/reservations/page.js
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
|
||||||
|
MapPin, DollarSign, Home, ArrowLeft, User, RefreshCw, Mail, Phone,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import AuthService from '../../services/AuthService';
|
||||||
|
import { getRentProperty } from '../../utils/api';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||||
|
|
||||||
|
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
|
||||||
|
const STATUS_UI = {
|
||||||
|
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||||
|
ownerConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||||
|
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
|
||||||
|
depositConfirmed: { label: 'مؤكد نهائياً', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||||
|
completed: { label: 'منتهي', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
||||||
|
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sLabel = c => STATUS_UI[STATUS_MAP[c]]?.label ?? String(c);
|
||||||
|
const sColor = c => STATUS_UI[STATUS_MAP[c]]?.color ?? 'bg-gray-100 text-gray-700';
|
||||||
|
const sIcon = c => STATUS_UI[STATUS_MAP[c]]?.icon ?? Clock;
|
||||||
|
|
||||||
|
function StatusBadge({ code }) {
|
||||||
|
const Icon = sIcon(code);
|
||||||
|
return <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${sColor(code)}`}><Icon className="w-3 h-3"/> {sLabel(code)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrich(r) {
|
||||||
|
if (!r.propertyId) return r;
|
||||||
|
try {
|
||||||
|
const prop = await getRentProperty(r.propertyId);
|
||||||
|
r._prop = prop?.propertyInformation ?? prop ?? null;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pAddr = p => p?.address ?? '';
|
||||||
|
const pImgs = p => Array.isArray(p?.images) ? p.images : [];
|
||||||
|
const pBeds = p => p?.numberOfBedRooms ?? 0;
|
||||||
|
const pBaths = p => p?.numberOfBathRooms ?? 0;
|
||||||
|
const API = (token, method, path, body) => fetch(`${API_BASE}${path}`, {
|
||||||
|
method: method || 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
|
||||||
|
...(body && { body: JSON.stringify(body) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function OwnerCard({ r, onViewDetails, onConfirm, onReject }) {
|
||||||
|
const p = r._prop;
|
||||||
|
const imgs = pImgs(p);
|
||||||
|
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
||||||
|
const addr = pAddr(p);
|
||||||
|
const isPending = r.status === 0; // Pending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||||
|
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
|
||||||
|
<div className="p-5">
|
||||||
|
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover"/></div>}
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<StatusBadge code={r.status}/>
|
||||||
|
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString()??'—'}</div>
|
||||||
|
<div className="text-xs text-gray-500">السعر الإجمالي</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{pBeds(p)>0&&<span>{pBeds(p)} غرف</span>}{pBaths(p)>0&&<span>{pBaths(p)} حمامات</span>}</div>}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
||||||
|
<div className="bg-gray-50 p-2 rounded-lg">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-2 rounded-lg">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-3 pt-3 border-t border-gray-100 ${!isPending?'justify-center':''}`}>
|
||||||
|
<button onClick={()=>onViewDetails(r)}
|
||||||
|
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<Eye className="w-4 h-4"/> التفاصيل
|
||||||
|
</button>
|
||||||
|
{isPending && <>
|
||||||
|
<button onClick={()=>onConfirm(r)}
|
||||||
|
className="flex-1 bg-green-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-green-600 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4"/> قبول
|
||||||
|
</button>
|
||||||
|
<button onClick={()=>onReject(r)}
|
||||||
|
className="flex-1 bg-red-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-red-600 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<XCircle className="w-4 h-4"/> رفض
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsModal({ r, isOpen, onClose }) {
|
||||||
|
if (!isOpen || !r) return null;
|
||||||
|
const p = r._prop;
|
||||||
|
return (
|
||||||
|
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
|
||||||
|
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
|
||||||
|
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
|
||||||
|
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold">طلب حجز #{r.id}</h2>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{p && <div className="bg-gray-50 p-4 rounded-xl">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
|
||||||
|
<p><span className="text-gray-500">العنوان:</span> {pAddr(p)||'—'}</p>
|
||||||
|
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mt-2">
|
||||||
|
{pBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBeds(p)} غرف</span>}
|
||||||
|
{pBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBaths(p)} حمامات</span>}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-xl">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
|
||||||
|
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
|
||||||
|
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
|
||||||
|
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 p-4 rounded-xl">
|
||||||
|
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/>المعلومات المالية</h3>
|
||||||
|
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??'—'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OwnerReservationRequestsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [reservations, setReservations] = useState([]);
|
||||||
|
const [filtered, setFiltered] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!AuthService.getUser() || !AuthService.isOwner()) { router.push('/auth/choose-role'); return; }
|
||||||
|
loadReservations();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const loadReservations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = AuthService.getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/Reservations/GetOwnerResevationRequests`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
let list = json.data || json || [];
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
const enriched = await Promise.all(list.map(enrich));
|
||||||
|
setReservations(enriched);
|
||||||
|
setFiltered(enriched);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('فشل تحميل طلبات الحجز');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let r = reservations;
|
||||||
|
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
|
||||||
|
if (searchTerm) {
|
||||||
|
const q = searchTerm.toLowerCase();
|
||||||
|
r = r.filter(x => pAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q));
|
||||||
|
}
|
||||||
|
setFiltered(r);
|
||||||
|
}, [reservations, filterStatus, searchTerm]);
|
||||||
|
|
||||||
|
const handleConfirm = async (r) => {
|
||||||
|
try {
|
||||||
|
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/owner-confirm/${r.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
toast.success('تم قبول الحجز بنجاح');
|
||||||
|
await loadReservations();
|
||||||
|
} catch (err) { console.error(err); toast.error('فشل قبول الحجز'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (r) => {
|
||||||
|
if (!confirm('هل أنت متأكد من رفض هذا الحجز؟')) return;
|
||||||
|
try {
|
||||||
|
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/reject/${r.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
toast.success('تم رفض الحجز');
|
||||||
|
await loadReservations();
|
||||||
|
} catch (err) { console.error(err); toast.error('فشل رفض الحجز'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
|
||||||
|
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
|
||||||
|
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">طلبات الحجز</h1>
|
||||||
|
<p className="text-gray-600">لديك {reservations.length} طلب</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadReservations} className="p-2 bg-white shadow rounded-xl hover:shadow-md transition-all"><RefreshCw className="w-5 h-5 text-gray-600"/></button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
{Object.entries(counts).map(([s, c]) => (
|
||||||
|
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||||
|
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
|
||||||
|
onClick={() => setFilterStatus(s)}>
|
||||||
|
<div className="text-2xl font-bold text-amber-600">{c}</div>
|
||||||
|
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
|
||||||
|
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||||
|
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد طلبات</h3>
|
||||||
|
<p className="text-gray-600">لم يتم استلام أي طلبات حجز حتى الآن</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{filtered.map(r => <OwnerCard key={r.id} r={r} onViewDetails={setSelected} onConfirm={handleConfirm} onReject={handleReject} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
app/page.js
69
app/page.js
@ -38,10 +38,15 @@ import AuthService from './services/AuthService';
|
|||||||
function mapApiProperty(item, index) {
|
function mapApiProperty(item, index) {
|
||||||
const info = item.propertyInformation || {};
|
const info = item.propertyInformation || {};
|
||||||
|
|
||||||
const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
|
const dailyPrice = item.dailyRent ?? 0;
|
||||||
const monthlyPrice = item.monthlyRent ?? 0;
|
const monthlyPrice = item.monthlyRent ?? 0;
|
||||||
|
const salePrice = item.price ?? 0;
|
||||||
|
const isRentListing = Boolean(item.dailyRent != null || item.monthlyRent != null);
|
||||||
|
|
||||||
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? 'apartment';
|
const price = isRentListing ? (dailyPrice || monthlyPrice || 0) : salePrice;
|
||||||
|
const priceUnit = isRentListing ? (monthlyPrice ? 'monthly' : 'daily') : 'sale';
|
||||||
|
|
||||||
|
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? (item.type || 'apartment');
|
||||||
const status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
|
const status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
|
||||||
|
|
||||||
const features = [];
|
const features = [];
|
||||||
@ -58,14 +63,21 @@ function mapApiProperty(item, index) {
|
|||||||
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
|
||||||
: ['/property-placeholder.jpg'];
|
: ['/property-placeholder.jpg'];
|
||||||
|
|
||||||
|
const ownerSource = info.ownerType == null && item.ownerType == null
|
||||||
|
? 'all'
|
||||||
|
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
|
||||||
|
? 'agency'
|
||||||
|
: 'owner';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id ?? index + 1,
|
id: item.id ?? index + 1,
|
||||||
title: info.address || `عقار #${item.id || index + 1}`,
|
title: info.address || `عقار #${item.id || index + 1}`,
|
||||||
description: info.description || '',
|
description: info.description || '',
|
||||||
type: propType,
|
type: propType,
|
||||||
price: dailyPrice,
|
price: price,
|
||||||
priceUSD: dailyPrice,
|
priceUSD: price,
|
||||||
priceUnit: 'daily',
|
priceUnit,
|
||||||
|
listingType: isRentListing ? 'rent' : 'sale',
|
||||||
location: {
|
location: {
|
||||||
city: extractCity(info.address) || 'دمشق',
|
city: extractCity(info.address) || 'دمشق',
|
||||||
district: info.address || '',
|
district: info.address || '',
|
||||||
@ -85,7 +97,9 @@ function mapApiProperty(item, index) {
|
|||||||
priceDisplay: {
|
priceDisplay: {
|
||||||
daily: dailyPrice,
|
daily: dailyPrice,
|
||||||
monthly: monthlyPrice,
|
monthly: monthlyPrice,
|
||||||
|
sale: salePrice,
|
||||||
},
|
},
|
||||||
|
ownerSource,
|
||||||
bookings: [],
|
bookings: [],
|
||||||
_raw: item,
|
_raw: item,
|
||||||
};
|
};
|
||||||
@ -175,6 +189,16 @@ export default function HomePage() {
|
|||||||
setSearchFilters(filters);
|
setSearchFilters(filters);
|
||||||
|
|
||||||
const filtered = allProperties.filter(property => {
|
const filtered = allProperties.filter(property => {
|
||||||
|
if (filters.mode === 'rent' && property.listingType !== 'rent') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.mode === 'sell' && property.listingType !== 'sale') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.mode === 'buy' && property.listingType !== 'sale') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
|
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -194,6 +218,20 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.ownerSource && filters.ownerSource !== 'all') {
|
||||||
|
if (filters.ownerSource === 'owner' && property.ownerSource !== 'owner') return false;
|
||||||
|
if (filters.ownerSource === 'agency' && property.ownerSource !== 'agency') return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.rentPeriod && filters.rentPeriod !== 'all' && property.listingType === 'rent') {
|
||||||
|
if (filters.rentPeriod === 'daily' && !property.priceDisplay.daily) return false;
|
||||||
|
if (filters.rentPeriod === 'monthly' && !property.priceDisplay.monthly) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.availableToday) {
|
||||||
|
if (property.status !== 'available') return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.identityType && property.allowedIdentities) {
|
if (filters.identityType && property.allowedIdentities) {
|
||||||
if (!property.allowedIdentities.includes(filters.identityType)) {
|
if (!property.allowedIdentities.includes(filters.identityType)) {
|
||||||
return false;
|
return false;
|
||||||
@ -312,7 +350,7 @@ export default function HomePage() {
|
|||||||
</motion.p>
|
</motion.p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{!isOwner && <HeroSearch onSearch={applyFilters} />}
|
{!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -477,6 +515,25 @@ export default function HomePage() {
|
|||||||
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||||
|
<span className="text-gray-600">مصدر العرض: </span>
|
||||||
|
<span className="font-bold text-gray-900">
|
||||||
|
{searchFilters.ownerSource === 'all' ? 'الكل' :
|
||||||
|
searchFilters.ownerSource === 'owner' ? 'من المالك' : 'من مكتب عقاري'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||||
|
<span className="text-gray-600">نوع الإيجار: </span>
|
||||||
|
<span className="font-bold text-gray-900">
|
||||||
|
{searchFilters.rentPeriod === 'all' ? 'الكل' :
|
||||||
|
searchFilters.rentPeriod === 'daily' ? 'إيجار يومي' : 'إيجار شهري'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{searchFilters.availableToday && (
|
||||||
|
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
|
||||||
|
<span className="font-bold text-gray-900">فقط المتاحة من اليوم</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -666,7 +666,7 @@ export default function OwnerRegisterPage() {
|
|||||||
const fileInputBackRef = useRef(null);
|
const fileInputBackRef = useRef(null);
|
||||||
const fileInputLicenseRef = useRef(null);
|
const fileInputLicenseRef = useRef(null);
|
||||||
|
|
||||||
const isCompany = formData.ownerType === OwnerType.COMPANY;
|
const isCompany = formData.ownerType === OwnerType.REAL_ESTATE_AGENCY;
|
||||||
|
|
||||||
const handleImageUpload = (side, file) => {
|
const handleImageUpload = (side, file) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|||||||
226
app/reservations/page.js
Normal file
226
app/reservations/page.js
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
|
||||||
|
MapPin, DollarSign, Home, ArrowLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
import { getRentProperty } from '../utils/api';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||||
|
|
||||||
|
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
|
||||||
|
|
||||||
|
const STATUS_UI = {
|
||||||
|
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||||
|
ownerConfirmed: { label: 'مؤكد من المالك', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
||||||
|
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
|
||||||
|
depositConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||||
|
completed: { label: 'منتهي', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||||
|
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(code) { return STATUS_UI[STATUS_MAP[code]]?.label ?? String(code); }
|
||||||
|
function statusColor(code) { return STATUS_UI[STATUS_MAP[code]]?.color ?? 'bg-gray-100 text-gray-700'; }
|
||||||
|
function statusIcon(code) { return STATUS_UI[STATUS_MAP[code]]?.icon ?? Clock; }
|
||||||
|
|
||||||
|
function StatusBadge({ code }) {
|
||||||
|
const Icon = statusIcon(code);
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${statusColor(code)}`}>
|
||||||
|
<Icon className="w-3 h-3" /> {statusLabel(code)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrich(reservation) {
|
||||||
|
if (!reservation.propertyId) return reservation;
|
||||||
|
try {
|
||||||
|
const prop = await getRentProperty(reservation.propertyId);
|
||||||
|
reservation._prop = prop?.propertyInformation ?? prop ?? null;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const propAddr = (p) => p?.address ?? '';
|
||||||
|
const propImages = (p) => Array.isArray(p?.images) ? p.images : [];
|
||||||
|
const propBeds = (p) => p?.numberOfBedRooms ?? 0;
|
||||||
|
const propBaths = (p) => p?.numberOfBathRooms ?? 0;
|
||||||
|
|
||||||
|
function ReservationCard({ r, onViewDetails }) {
|
||||||
|
const p = r._prop;
|
||||||
|
const imgs = propImages(p);
|
||||||
|
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
|
||||||
|
const addr = propAddr(p);
|
||||||
|
const beds = propBeds(p);
|
||||||
|
const baths = propBaths(p);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity:0,y:20 }} animate={{ opacity:1,y:0 }}
|
||||||
|
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
|
||||||
|
<div className="p-5">
|
||||||
|
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover" /></div>}
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<StatusBadge code={r.status} />
|
||||||
|
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString() ?? '—'}</div>
|
||||||
|
<div className="text-xs text-gray-500">السعر الإجمالي</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(beds||baths) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{beds>0&&<span>{beds} غرف</span>}{baths>0&&<span>{baths} حمامات</span>}</div>}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
||||||
|
<div className="bg-gray-50 p-2 rounded-lg">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-2 rounded-lg">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
|
||||||
|
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-3 border-t border-gray-100">
|
||||||
|
<button onClick={() => onViewDetails(r)}
|
||||||
|
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<Eye className="w-4 h-4"/> التفاصيل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsModal({ r, isOpen, onClose }) {
|
||||||
|
if (!isOpen || !r) return null;
|
||||||
|
const p = r._prop;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
|
||||||
|
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
|
||||||
|
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
|
||||||
|
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold">تفاصيل الحجز</h2>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
|
||||||
|
</div>
|
||||||
|
<p className="text-amber-100 text-sm mt-1">رقم الحجز: #{r.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{p && <div className="bg-gray-50 p-4 rounded-xl">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
|
||||||
|
<p><span className="text-gray-500">العنوان:</span> {propAddr(p)||'—'}</p>
|
||||||
|
{(propBeds(p)||propBaths(p)) && <div className="flex gap-3 mt-2">
|
||||||
|
{propBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBeds(p)} غرف</span>}
|
||||||
|
{propBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBaths(p)} حمامات</span>}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
<div className="bg-gray-50 p-4 rounded-xl">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
|
||||||
|
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
|
||||||
|
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
|
||||||
|
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 p-4 rounded-xl">
|
||||||
|
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/> المعلومات المالية</h3>
|
||||||
|
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??'—'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserReservationsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [reservations, setReservations] = useState([]);
|
||||||
|
const [filtered, setFiltered] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
|
||||||
|
|
||||||
|
const loadReservations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/Reservations/GetUserResevations`, {
|
||||||
|
headers: { Authorization: `Bearer ${AuthService.getToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
let list = json.data || json || [];
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
const enriched = await Promise.all(list.map(enrich));
|
||||||
|
setReservations(enriched);
|
||||||
|
setFiltered(enriched);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('فشل تحميل الحجوزات');
|
||||||
|
setReservations([]);
|
||||||
|
setFiltered([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let r = reservations;
|
||||||
|
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
|
||||||
|
if (searchTerm) { const q = searchTerm.toLowerCase(); r = r.filter(x => propAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q)); }
|
||||||
|
setFiltered(r);
|
||||||
|
}, [reservations, filterStatus, searchTerm]);
|
||||||
|
|
||||||
|
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
|
||||||
|
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
|
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
|
||||||
|
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
|
||||||
|
<p className="text-gray-600">لديك {reservations.length} حجز</p>
|
||||||
|
</motion.div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
{Object.entries(counts).map(([s, c]) => (
|
||||||
|
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
|
||||||
|
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
|
||||||
|
onClick={() => setFilterStatus(s)}>
|
||||||
|
<div className="text-2xl font-bold text-amber-600">{c}</div>
|
||||||
|
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
|
||||||
|
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||||
|
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
|
||||||
|
<p className="text-gray-600">لم تقم بأي حجز حتى الآن</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{filtered.map(r => <ReservationCard key={r.id} r={r} onViewDetails={setSelected} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -93,6 +93,18 @@ const AuthService = Object.freeze({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user id
|
||||||
|
* @returns {number|string|null}
|
||||||
|
*/
|
||||||
|
getUserId() {
|
||||||
|
const user = this.getUser();
|
||||||
|
if (!user?.id) return null;
|
||||||
|
|
||||||
|
const parsedId = Number(user.id);
|
||||||
|
return Number.isFinite(parsedId) ? parsedId : user.id;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get roles array from JWT
|
* Get roles array from JWT
|
||||||
* @returns {string[]}
|
* @returns {string[]}
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export async function getAvailableDateRanges(propertyId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getReservations() {
|
export async function getReservations() {
|
||||||
return apiFetch('/Reservations/GetReservations');
|
return apiFetch('/Reservations/GetAllReservations');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getReservation(id) {
|
export async function getReservation(id) {
|
||||||
@ -366,3 +366,85 @@ export async function addFavoriteProperty(propId) {
|
|||||||
export async function removeFavoriteProperty(favePropId) {
|
export async function removeFavoriteProperty(favePropId) {
|
||||||
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
|
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserNotifications() {
|
||||||
|
return apiFetch('/Notifications/GetUserNotifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Booking/Reservation Management ───
|
||||||
|
|
||||||
|
export async function confirmDepositPayment(bookingId) {
|
||||||
|
return apiFetch('/Reservations/ConfirmDepositPayment', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ bookingId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
|
||||||
|
const token = AuthService.getToken();
|
||||||
|
const endpoint = `${API_BASE}/Reservations/AdminConfirmDeposit/admin-confirm-deposit`;
|
||||||
|
const normalizedComment =
|
||||||
|
typeof comment === 'string' && comment.trim()
|
||||||
|
? comment.trim()
|
||||||
|
: null;
|
||||||
|
const payload = {
|
||||||
|
reservationId,
|
||||||
|
adminId,
|
||||||
|
comment: normalizedComment,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[API] AdminConfirmDeposit request', {
|
||||||
|
method: 'PUT',
|
||||||
|
endpoint,
|
||||||
|
payload,
|
||||||
|
adminIdSource: 'jwt-user-id',
|
||||||
|
hasToken: Boolean(token),
|
||||||
|
tokenPreview: token ? `${token.slice(0, 18)}...${token.slice(-8)}` : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
|
||||||
|
console.log('[API] AdminConfirmDeposit raw response', {
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
endpoint,
|
||||||
|
rawText: text,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
if (data && typeof data === 'object' && 'data' in data) {
|
||||||
|
data = data.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = typeof data === 'object' && data?.message ? data.message : null;
|
||||||
|
|
||||||
|
console.log('[API] AdminConfirmDeposit parsed response', {
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: res.status, data, ok: res.ok, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBookingStatus(bookingId, status) {
|
||||||
|
return apiFetch('/Reservations/UpdateStatus', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ bookingId, status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
292
app/utils/ratings.js
Normal file
292
app/utils/ratings.js
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
// // 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;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// utils/ratings.js
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
|
||||||
|
async function apiFetch(endpoint, options = {}) {
|
||||||
|
const token = AuthService.getToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`API Error ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return json && typeof json === 'object' && 'data' in json ? json.data : json;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /Rating/AddPropertyRating
|
||||||
|
* @param {Object} data - { reservationId, cleanRating, servicesRating, ownerBehaviorRating, experienceRating, comment? }
|
||||||
|
*/
|
||||||
|
export async function addPropertyRating(data) {
|
||||||
|
return apiFetch('/Rating/AddPropertyRating', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /Rating/AddCustomerRating
|
||||||
|
* @param {Object} data - { reservationId, furnitureIntegrityRating, termsComplianceRating, renterBehaviorRating, comment? }
|
||||||
|
*/
|
||||||
|
export async function addCustomerRating(data) {
|
||||||
|
return apiFetch('/Rating/AddCustomerRating', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Rating/GetPropertyRatings
|
||||||
|
* @param {number} propertyId
|
||||||
|
* @param {number} page - default 1
|
||||||
|
* @param {number} pageSize - default 10
|
||||||
|
* @returns {Promise<{ items: Array, totalPages: number, currentPage: number }>}
|
||||||
|
*/
|
||||||
|
export async function getPropertyRatings(propertyId, page = 1, pageSize = 10) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
propertyId: String(propertyId),
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
}).toString();
|
||||||
|
return apiFetch(`/Rating/GetPropertyRatings?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Rating/GetCustomerRatings
|
||||||
|
* @param {number} renterId
|
||||||
|
* @param {number} page
|
||||||
|
* @param {number} pageSize
|
||||||
|
*/
|
||||||
|
export async function getCustomerRatings(renterId, page = 1, pageSize = 10) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
renterId: String(renterId),
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
}).toString();
|
||||||
|
return apiFetch(`/Rating/GetCustomerRatings?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Rating/GetPropertyAverage
|
||||||
|
* @param {number} propertyId
|
||||||
|
* @returns {Promise<number>} average rating (0 if none)
|
||||||
|
*/
|
||||||
|
export async function getPropertyAverageRating(propertyId) {
|
||||||
|
const result = await apiFetch(`/Rating/GetPropertyAverage?propertyId=${propertyId}`);
|
||||||
|
if (typeof result === 'number') return result;
|
||||||
|
if (result && typeof result.average === 'number') return result.average;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user