Integrate FavoriteProperty API: add/remove/get favorites with real backend
All checks were successful
Build frontend / build (push) Successful in 42s

This commit is contained in:
Claw AI
2026-03-30 17:54:42 +00:00
parent 1f40c6a4fd
commit 3b9831a513
5 changed files with 152 additions and 33 deletions

View File

@ -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,42 +14,106 @@ 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>
); );
}; };

View File

@ -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">

View File

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

View File

@ -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">

View File

@ -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' });
}