Add availability calendar to property detail page
All checks were successful
Build frontend / build (push) Successful in 42s
All checks were successful
Build frontend / build (push) Successful in 42s
- Fetches available date ranges from /Reservations/GetAvailableDates/available/{id}
- Custom month calendar with green (available), amber (selected), gray (unavailable)
- Click start date then end date to select a range
- Validates entire range is available before confirming
- Shows selected dates and day count
- Month navigation with prev/next arrows
This commit is contained in:
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
@ -42,7 +43,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ArrowLeft
|
ArrowLeft
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getRentProperty, getSaleProperty, bookReservation, checkAvailability } from '../../utils/api';
|
import { getRentProperty, getSaleProperty, bookReservation, checkAvailability, getAvailableDateRanges } from '../../utils/api';
|
||||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums';
|
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from '../../enums';
|
||||||
|
|
||||||
// Map API response to the UI format
|
// Map API response to the UI format
|
||||||
@ -134,6 +135,9 @@ export default function PropertyDetailsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [bookingError, setBookingError] = useState(null);
|
const [bookingError, setBookingError] = useState(null);
|
||||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||||
|
const [availableRanges, setAvailableRanges] = useState([]);
|
||||||
|
const [calendarMonth, setCalendarMonth] = useState(new Date());
|
||||||
|
const [selectingEnd, setSelectingEnd] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
@ -175,6 +179,22 @@ export default function PropertyDetailsPage() {
|
|||||||
fetchProperty();
|
fetchProperty();
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
|
// Fetch available date ranges
|
||||||
|
useEffect(() => {
|
||||||
|
if (!property) return;
|
||||||
|
const propId = property._raw?.id || params.id;
|
||||||
|
console.log('[Property] Fetching available dates for:', propId);
|
||||||
|
getAvailableDateRanges(propId)
|
||||||
|
.then((data) => {
|
||||||
|
const ranges = Array.isArray(data) ? data : [];
|
||||||
|
console.log('[Property] Available date ranges:', ranges);
|
||||||
|
setAvailableRanges(ranges);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[Property] Failed to fetch available dates:', err);
|
||||||
|
});
|
||||||
|
}, [property, params.id]);
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
return amount?.toLocaleString() + ' ل.س';
|
return amount?.toLocaleString() + ' ل.س';
|
||||||
};
|
};
|
||||||
@ -187,6 +207,67 @@ export default function PropertyDetailsPage() {
|
|||||||
return property.price * (days > 0 ? days : 1);
|
return property.price * (days > 0 ? days : 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calendar helpers
|
||||||
|
const isDateAvailable = (dateStr) => {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return availableRanges.some((range) => {
|
||||||
|
const start = new Date(range.startDate);
|
||||||
|
const end = new Date(range.endDate);
|
||||||
|
return d >= start && d <= end;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInRange = (dateStr) => {
|
||||||
|
if (!bookingDates.start) return false;
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
const start = new Date(bookingDates.start + 'T00:00:00');
|
||||||
|
const end = bookingDates.end ? new Date(bookingDates.end + 'T00:00:00') : start;
|
||||||
|
return d >= start && d <= end;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRangeFullyAvailable = (startStr, endStr) => {
|
||||||
|
const start = new Date(startStr + 'T00:00:00');
|
||||||
|
const end = new Date(endStr + 'T00:00:00');
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
if (!isDateAvailable(d.toISOString().split('T')[0])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCalendarClick = (dateStr) => {
|
||||||
|
if (!isDateAvailable(dateStr)) return;
|
||||||
|
|
||||||
|
if (!bookingDates.start || selectingEnd) {
|
||||||
|
if (!bookingDates.start) {
|
||||||
|
setBookingDates({ start: dateStr, end: '' });
|
||||||
|
setSelectingEnd(true);
|
||||||
|
} else {
|
||||||
|
const start = bookingDates.start;
|
||||||
|
const end = dateStr;
|
||||||
|
const [s, e] = end > start ? [start, end] : [end, start];
|
||||||
|
if (isRangeFullyAvailable(s, e)) {
|
||||||
|
setBookingDates({ start: s, end: e });
|
||||||
|
setSelectingEnd(false);
|
||||||
|
} else {
|
||||||
|
toast.error('بعض التواريخ في هذه الفترة غير متاحة');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBookingDates({ start: dateStr, end: '' });
|
||||||
|
setSelectingEnd(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDaysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
|
||||||
|
const getFirstDayOfMonth = (year, month) => new Date(year, month, 1).getDay();
|
||||||
|
|
||||||
|
const formatDateStr = (year, month, day) => {
|
||||||
|
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthNames = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'];
|
||||||
|
const dayNames = ['أح', 'إث', 'ثل', 'أر', 'خم', 'جم', 'سب'];
|
||||||
|
|
||||||
const handleBooking = async () => {
|
const handleBooking = async () => {
|
||||||
setBookingError(null);
|
setBookingError(null);
|
||||||
setBookingSuccess(false);
|
setBookingSuccess(false);
|
||||||
@ -238,6 +319,7 @@ export default function PropertyDetailsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
<div className="bg-white border-b sticky top-16 z-40 shadow-sm">
|
<div className="bg-white border-b sticky top-16 z-40 shadow-sm">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
@ -490,42 +572,103 @@ export default function PropertyDetailsPage() {
|
|||||||
>
|
>
|
||||||
<h2 className="text-xl font-bold mb-4 text-gray-900">احجز هذا العقار</h2>
|
<h2 className="text-xl font-bold mb-4 text-gray-900">احجز هذا العقار</h2>
|
||||||
|
|
||||||
<div className="mb-4">
|
{/* Selected dates display */}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">اختر المدة (أيام)</label>
|
<div className="mb-4 flex gap-2 text-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex-1 bg-gray-50 p-3 rounded-xl">
|
||||||
{[1, 3, 7, 14, 30].map(days => (
|
<span className="text-gray-500 block mb-1">من</span>
|
||||||
<button
|
<span className="font-medium text-gray-900">{bookingDates.start || '—'}</span>
|
||||||
key={days}
|
</div>
|
||||||
onClick={() => setSelectedDuration(days)}
|
<div className="flex-1 bg-gray-50 p-3 rounded-xl">
|
||||||
className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors ${selectedDuration === days
|
<span className="text-gray-500 block mb-1">إلى</span>
|
||||||
? 'bg-gray-800 text-white'
|
<span className="font-medium text-gray-900">{bookingDates.end || '—'}</span>
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{days}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
{bookingDates.start && bookingDates.end && (() => {
|
||||||
<div>
|
const days = Math.ceil((new Date(bookingDates.end) - new Date(bookingDates.start)) / (1000 * 60 * 60 * 24));
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ البداية</label>
|
return days > 0 ? (
|
||||||
<input
|
<div className="mb-4 text-center text-sm text-amber-600 font-medium bg-amber-50 p-2 rounded-xl">
|
||||||
type="date"
|
{days} يوم{days > 1 ? 'اً' : 'اً'} {selectingEnd ? '— اضغط على تاريخ النهاية' : '✓'}
|
||||||
value={bookingDates.start}
|
</div>
|
||||||
onChange={(e) => setBookingDates({ ...bookingDates, start: e.target.value })}
|
) : null;
|
||||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
|
})()}
|
||||||
/>
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<button onClick={() => setCalendarMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1))} className="p-1 hover:bg-gray-100 rounded-lg">
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-bold text-gray-900">{monthNames[calendarMonth.getMonth()]} {calendarMonth.getFullYear()}</span>
|
||||||
|
<button onClick={() => setCalendarMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1))} className="p-1 hover:bg-gray-100 rounded-lg">
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||||
|
{dayNames.map((d) => (
|
||||||
|
<div key={d} className="text-center text-xs text-gray-500 font-medium py-1">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{(() => {
|
||||||
|
const year = calendarMonth.getFullYear();
|
||||||
|
const month = calendarMonth.getMonth();
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
const firstDay = getFirstDayOfMonth(year, month);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
// Empty cells before first day
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
cells.push(<div key={`empty-${i}`} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const dateStr = formatDateStr(year, month, day);
|
||||||
|
const available = isDateAvailable(dateStr);
|
||||||
|
const isStart = bookingDates.start === dateStr;
|
||||||
|
const isEnd = bookingDates.end === dateStr;
|
||||||
|
const inRange = isInRange(dateStr);
|
||||||
|
const isPast = dateStr < today;
|
||||||
|
|
||||||
|
cells.push(
|
||||||
|
<button
|
||||||
|
key={dateStr}
|
||||||
|
onClick={() => !isPast && handleCalendarClick(dateStr)}
|
||||||
|
disabled={isPast || !available}
|
||||||
|
className={`w-full aspect-square rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isStart || isEnd
|
||||||
|
? 'bg-amber-500 text-white'
|
||||||
|
: inRange
|
||||||
|
? 'bg-amber-100 text-amber-800'
|
||||||
|
: available && !isPast
|
||||||
|
? 'bg-green-50 text-green-700 hover:bg-green-100 cursor-pointer'
|
||||||
|
: 'text-gray-300 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-green-50 border border-green-200" />
|
||||||
|
<span>متاح</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-amber-500" />
|
||||||
|
<span>محدد</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-gray-100" />
|
||||||
|
<span>غير متاح</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ النهاية</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={bookingDates.end}
|
|
||||||
onChange={(e) => setBookingDates({ ...bookingDates, end: e.target.value })}
|
|
||||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -128,6 +128,11 @@ export async function getTopRecommendations(count = 10) {
|
|||||||
|
|
||||||
// ─── Reservations ───
|
// ─── Reservations ───
|
||||||
|
|
||||||
|
export async function getAvailableDateRanges(propertyId) {
|
||||||
|
console.log('[API] Fetching available dates for property:', propertyId);
|
||||||
|
return apiFetch(`/Reservations/GetAvailableDates/available/${propertyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getReservations() {
|
export async function getReservations() {
|
||||||
return apiFetch('/Reservations/GetReservations');
|
return apiFetch('/Reservations/GetReservations');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user