Add availability calendar to property detail page
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:
Claw AI
2026-03-29 21:16:00 +00:00
parent ca1d83967e
commit 86b8fc591b
2 changed files with 181 additions and 33 deletions

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import toast, { Toaster } from 'react-hot-toast';
import Image from 'next/image';
import Link from 'next/link';
import { useParams } from 'next/navigation';
@ -42,7 +43,7 @@ import {
Download,
ArrowLeft
} 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';
// Map API response to the UI format
@ -134,6 +135,9 @@ export default function PropertyDetailsPage() {
const [loading, setLoading] = useState(true);
const [bookingError, setBookingError] = useState(null);
const [bookingSuccess, setBookingSuccess] = useState(false);
const [availableRanges, setAvailableRanges] = useState([]);
const [calendarMonth, setCalendarMonth] = useState(new Date());
const [selectingEnd, setSelectingEnd] = useState(false);
useEffect(() => {
const id = params.id;
@ -175,6 +179,22 @@ export default function PropertyDetailsPage() {
fetchProperty();
}, [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) => {
return amount?.toLocaleString() + ' ل.س';
};
@ -187,6 +207,67 @@ export default function PropertyDetailsPage() {
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 () => {
setBookingError(null);
setBookingSuccess(false);
@ -238,6 +319,7 @@ export default function PropertyDetailsPage() {
return (
<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="container mx-auto px-4">
<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>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">اختر المدة (أيام)</label>
<div className="flex gap-2">
{[1, 3, 7, 14, 30].map(days => (
<button
key={days}
onClick={() => setSelectedDuration(days)}
className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors ${selectedDuration === days
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{days}
</button>
))}
{/* Selected dates display */}
<div className="mb-4 flex gap-2 text-sm">
<div className="flex-1 bg-gray-50 p-3 rounded-xl">
<span className="text-gray-500 block mb-1">من</span>
<span className="font-medium text-gray-900">{bookingDates.start || '—'}</span>
</div>
<div className="flex-1 bg-gray-50 p-3 rounded-xl">
<span className="text-gray-500 block mb-1">إلى</span>
<span className="font-medium text-gray-900">{bookingDates.end || '—'}</span>
</div>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ البداية</label>
<input
type="date"
value={bookingDates.start}
onChange={(e) => setBookingDates({ ...bookingDates, start: 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"
/>
{bookingDates.start && bookingDates.end && (() => {
const days = Math.ceil((new Date(bookingDates.end) - new Date(bookingDates.start)) / (1000 * 60 * 60 * 24));
return days > 0 ? (
<div className="mb-4 text-center text-sm text-amber-600 font-medium bg-amber-50 p-2 rounded-xl">
{days} يوم{days > 1 ? 'اً' : 'اً'} {selectingEnd ? '— اضغط على تاريخ النهاية' : '✓'}
</div>
) : null;
})()}
{/* 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>
<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>

View File

@ -128,6 +128,11 @@ export async function getTopRecommendations(count = 10) {
// ─── 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() {
return apiFetch('/Reservations/GetReservations');
}