Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 1m13s
All checks were successful
Build frontend / build (push) Successful in 1m13s
This commit is contained in:
@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getUserFavoriteProperties, addFavoriteProperty, removeFavoriteProperty } from '../utils/api';
|
||||||
|
import AuthService from '../services/AuthService';
|
||||||
|
|
||||||
const FavoritesContext = createContext();
|
const FavoritesContext = createContext();
|
||||||
|
|
||||||
@ -12,41 +14,105 @@ export const useFavorites = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapApiFavorite(item) {
|
||||||
|
const info = item.propertyInformation || {};
|
||||||
|
let details = {};
|
||||||
|
try { details = JSON.parse(info.detailsJSON || '{}'); } catch {}
|
||||||
|
|
||||||
|
const price = item.monthlyRent || item.dailyRent || 0;
|
||||||
|
const priceUnit = item.monthlyRent ? 'monthly' : 'daily';
|
||||||
|
const buildingType = info.buildingType ?? 0;
|
||||||
|
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
|
||||||
|
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
|
||||||
|
const address = info.address || '';
|
||||||
|
const addressParts = address.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const images = info.images || [];
|
||||||
|
const resolvedImages = images.map(img => {
|
||||||
|
if (!img) return '';
|
||||||
|
if (img.startsWith('http')) return img;
|
||||||
|
return `http://45.93.137.91${img.startsWith('/') ? '' : '/'}${img}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: info.id || item.propertyInformationId,
|
||||||
|
faveId: item.id, // needed to remove from favorites
|
||||||
|
title: `${typeLabel} في ${addressParts[0] || address}`,
|
||||||
|
type,
|
||||||
|
typeLabel,
|
||||||
|
price,
|
||||||
|
priceUnit,
|
||||||
|
bedrooms: info.numberOfBedRooms || 0,
|
||||||
|
bathrooms: info.numberOfBathRooms || 0,
|
||||||
|
area: info.space || 0,
|
||||||
|
location: {
|
||||||
|
city: addressParts[addressParts.length - 1] || '',
|
||||||
|
district: addressParts[0] || '',
|
||||||
|
},
|
||||||
|
images: resolvedImages,
|
||||||
|
rating: item.rating || 0,
|
||||||
|
deposit: item.deposit || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const FavoritesProvider = ({ children }) => {
|
export const FavoritesProvider = ({ children }) => {
|
||||||
const [favorites, setFavorites] = useState([]);
|
const [favorites, setFavorites] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchFavorites = useCallback(async () => {
|
||||||
const stored = localStorage.getItem('favorites');
|
if (!AuthService.isAuthenticated()) {
|
||||||
if (stored) {
|
setFavorites([]);
|
||||||
try {
|
return;
|
||||||
setFavorites(JSON.parse(stored));
|
}
|
||||||
} catch (e) {
|
setIsLoading(true);
|
||||||
console.error('Failed to parse favorites', e);
|
try {
|
||||||
}
|
const data = await getUserFavoriteProperties();
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
setFavorites(items.map(mapApiFavorite));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Favorites] Failed to fetch:', err);
|
||||||
|
setFavorites([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('favorites', JSON.stringify(favorites));
|
fetchFavorites();
|
||||||
}, [favorites]);
|
}, [fetchFavorites]);
|
||||||
|
|
||||||
const addFavorite = (property) => {
|
const addFavorite = async (propId) => {
|
||||||
setFavorites(prev => {
|
if (!AuthService.isAuthenticated()) return false;
|
||||||
if (prev.some(p => p.id === property.id)) return prev;
|
try {
|
||||||
return [...prev, property];
|
await addFavoriteProperty(propId);
|
||||||
});
|
await fetchFavorites(); // refresh list
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Favorites] Add failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFavorite = (propertyId) => {
|
const removeFavorite = async (propId) => {
|
||||||
setFavorites(prev => prev.filter(p => p.id !== propertyId));
|
if (!AuthService.isAuthenticated()) return false;
|
||||||
|
// Find the faveId for this property
|
||||||
|
const fav = favorites.find(f => f.id === propId);
|
||||||
|
if (!fav) return false;
|
||||||
|
try {
|
||||||
|
await removeFavoriteProperty(fav.faveId);
|
||||||
|
setFavorites(prev => prev.filter(f => f.id !== propId));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Favorites] Remove failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFavorite = (propertyId) => {
|
const isFavorite = (propId) => {
|
||||||
return favorites.some(p => p.id === propertyId);
|
return favorites.some(f => f.id === propId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FavoritesContext.Provider value={{ favorites, addFavorite, removeFavorite, isFavorite }}>
|
<FavoritesContext.Provider value={{ favorites, isLoading, addFavorite, removeFavorite, isFavorite, refreshFavorites: fetchFavorites }}>
|
||||||
{children}
|
{children}
|
||||||
</FavoritesContext.Provider>
|
</FavoritesContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,8 +11,7 @@ import AuthService from '@/app/services/AuthService';
|
|||||||
|
|
||||||
export default function FavoritesPage() {
|
export default function FavoritesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { favorites, removeFavorite } = useFavorites();
|
const { favorites, isLoading: favoritesLoading, removeFavorite } = useFavorites();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -21,14 +20,13 @@ export default function FavoritesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsAdmin(AuthService.isAdmin());
|
setIsAdmin(AuthService.isAdmin());
|
||||||
setIsLoading(false);
|
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
return amount?.toLocaleString() + ' ل.س';
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (favoritesLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@ -32,6 +32,9 @@ import {
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getRentProperties, getSaleProperties } from '../utils/api';
|
import { getRentProperties, getSaleProperties } from '../utils/api';
|
||||||
|
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||||
|
import AuthService from '@/app/services/AuthService';
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
// Map API data to UI format
|
// Map API data to UI format
|
||||||
function mapApiProperty(item, index) {
|
function mapApiProperty(item, index) {
|
||||||
@ -95,9 +98,25 @@ function extractCity(address) {
|
|||||||
// API-only — no fallback data
|
// API-only — no fallback data
|
||||||
|
|
||||||
const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
const { isFavorite: checkFavorite, addFavorite, removeFavorite } = useFavorites();
|
||||||
|
const [favLoading, setFavLoading] = useState(false);
|
||||||
const [currentImage, setCurrentImage] = useState(0);
|
const [currentImage, setCurrentImage] = useState(0);
|
||||||
|
|
||||||
|
const isFav = checkFavorite(property.id);
|
||||||
|
|
||||||
|
const toggleFavorite = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!AuthService.isAuthenticated()) { toast.error('سجل الدخول أولاً'); return; }
|
||||||
|
setFavLoading(true);
|
||||||
|
if (isFav) {
|
||||||
|
await removeFavorite(property.id);
|
||||||
|
} else {
|
||||||
|
await addFavorite(property.id);
|
||||||
|
}
|
||||||
|
setFavLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
return amount?.toLocaleString() + ' ل.س';
|
||||||
};
|
};
|
||||||
@ -150,10 +169,11 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
)}
|
)}
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFavorite(!isFavorite)}
|
onClick={toggleFavorite}
|
||||||
|
disabled={favLoading}
|
||||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||||
>
|
>
|
||||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -231,10 +251,11 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFavorite(!isFavorite)}
|
onClick={toggleFavorite}
|
||||||
|
disabled={favLoading}
|
||||||
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
|
||||||
>
|
>
|
||||||
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
<Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -622,6 +643,7 @@ export default function PropertiesPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster position="top-center" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getRentProperty, getSaleProperty, bookReservation, checkAvailability, getAvailableDateRanges } from '../../utils/api';
|
import { getRentProperty, getSaleProperty, bookReservation, checkAvailability, getAvailableDateRanges } from '../../utils/api';
|
||||||
import AuthService from '../../services/AuthService';
|
import AuthService from '../../services/AuthService';
|
||||||
|
import { useFavorites } from '@/app/contexts/FavoritesContext';
|
||||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums';
|
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums';
|
||||||
|
|
||||||
// Copy to clipboard that works on HTTP too
|
// Copy to clipboard that works on HTTP too
|
||||||
@ -161,6 +162,7 @@ function mapApiDetail(item) {
|
|||||||
|
|
||||||
export default function PropertyDetailsPage() {
|
export default function PropertyDetailsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { isFavorite, addFavorite, removeFavorite } = useFavorites();
|
||||||
const [currentImage, setCurrentImage] = useState(0);
|
const [currentImage, setCurrentImage] = useState(0);
|
||||||
const [showContact, setShowContact] = useState(false);
|
const [showContact, setShowContact] = useState(false);
|
||||||
const [showShareMenu, setShowShareMenu] = useState(false);
|
const [showShareMenu, setShowShareMenu] = useState(false);
|
||||||
@ -172,6 +174,7 @@ export default function PropertyDetailsPage() {
|
|||||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||||
const [availableRanges, setAvailableRanges] = useState([]);
|
const [availableRanges, setAvailableRanges] = useState([]);
|
||||||
const [calendarMonth, setCalendarMonth] = useState(new Date());
|
const [calendarMonth, setCalendarMonth] = useState(new Date());
|
||||||
|
const [favLoading, setFavLoading] = useState(false);
|
||||||
const [selectingEnd, setSelectingEnd] = useState(false);
|
const [selectingEnd, setSelectingEnd] = useState(false);
|
||||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
|
|
||||||
@ -417,8 +420,24 @@ export default function PropertyDetailsPage() {
|
|||||||
<span>العودة إلى العقارات</span>
|
<span>العودة إلى العقارات</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button
|
||||||
<Heart className="w-5 h-5 text-gray-600" />
|
onClick={async () => {
|
||||||
|
if (!AuthService.isAuthenticated()) { toast.error('سجل الدخول أولاً'); return; }
|
||||||
|
const propId = property?._raw?.id || parseInt(params.id);
|
||||||
|
setFavLoading(true);
|
||||||
|
if (isFavorite(propId)) {
|
||||||
|
await removeFavorite(propId);
|
||||||
|
toast.success('تمت الإزالة من المفضلة');
|
||||||
|
} else {
|
||||||
|
await addFavorite(propId);
|
||||||
|
toast.success('تمت الإضافة إلى المفضلة');
|
||||||
|
}
|
||||||
|
setFavLoading(false);
|
||||||
|
}}
|
||||||
|
disabled={favLoading}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<Heart className={`w-5 h-5 ${isFavorite(property?._raw?.id || parseInt(params.id)) ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
|
||||||
</button>
|
</button>
|
||||||
{/* Share Dropdown */}
|
{/* Share Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@ -352,3 +352,17 @@ export function isEmail(value) {
|
|||||||
export function isPhoneNumber(value) {
|
export function isPhoneNumber(value) {
|
||||||
return /^\+?\d{7,15}$/.test(value.replace(/[\s\-()]/g, ''));
|
return /^\+?\d{7,15}$/.test(value.replace(/[\s\-()]/g, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Favorites ───
|
||||||
|
|
||||||
|
export async function getUserFavoriteProperties() {
|
||||||
|
return apiFetch('/FavoriteProperty/GetUserFavoriteProperties');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFavoriteProperty(propId) {
|
||||||
|
return apiFetch(`/FavoriteProperty/Add?propId=${propId}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFavoriteProperty(favePropId) {
|
||||||
|
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user