date selection

This commit is contained in:
jackiettran
2025-07-18 00:31:23 -04:00
parent 1dbe821e70
commit f289022b5d
9 changed files with 852 additions and 253 deletions

View File

@@ -13,8 +13,9 @@ interface UnavailablePeriod {
interface AvailabilityCalendarProps {
unavailablePeriods: UnavailablePeriod[];
onPeriodsChange: (periods: UnavailablePeriod[]) => void;
priceType?: 'hour' | 'day';
isRentalMode?: boolean;
mode: 'owner' | 'renter'; // Clear mode distinction
onRentalSelect?: (period: { startDate: Date; endDate: Date; startTime?: string; endTime?: string }) => void;
selectedRentalPeriod?: UnavailablePeriod; // External selection to display
}
type ViewType = 'month' | 'week' | 'day';
@@ -22,19 +23,20 @@ type ViewType = 'month' | 'week' | 'day';
const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
unavailablePeriods,
onPeriodsChange,
priceType = 'hour',
isRentalMode = false
mode,
onRentalSelect,
selectedRentalPeriod: externalSelectedPeriod
}) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [viewType, setViewType] = useState<ViewType>('month');
const [selectionStart, setSelectionStart] = useState<Date | null>(null);
const [selectionStartHour, setSelectionStartHour] = useState<number | null>(null);
const [internalSelectedPeriod, setInternalSelectedPeriod] = useState<UnavailablePeriod | null>(null);
const [ownerSelectionStart, setOwnerSelectionStart] = useState<Date | null>(null);
const [ownerSelectionStartHour, setOwnerSelectionStartHour] = useState<number | null>(null);
// Reset to month view if priceType is day and current view is week/day
useEffect(() => {
if (priceType === 'day' && (viewType === 'week' || viewType === 'day')) {
setViewType('month');
}
}, [priceType]);
// Use external selection if provided, otherwise use internal
const selectedRentalPeriod = externalSelectedPeriod || internalSelectedPeriod;
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
@@ -77,10 +79,14 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
const isDateInPeriod = (date: Date, period: UnavailablePeriod) => {
const start = new Date(period.startDate);
const end = new Date(period.endDate);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
const checkDate = new Date(date);
// Normalize all dates to midnight for comparison
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
checkDate.setHours(0, 0, 0, 0);
// Basic date range check
return checkDate >= start && checkDate <= end;
};
@@ -158,157 +164,400 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
});
};
const handleHourClick = (date: Date, hour: number) => {
if (mode !== 'renter') return;
// Check if this hour is unavailable
if (isHourUnavailable(date, hour)) return;
if (!selectionStart || !selectionStartHour) {
// First click - set start date and hour
setSelectionStart(date);
setSelectionStartHour(hour);
setInternalSelectedPeriod(null);
} else {
// Second click - complete selection
const startDate = new Date(selectionStart);
const endDate = new Date(date);
// Determine which date/hour comes first
let finalStart, finalEnd, startHour, endHour;
if (startDate.toDateString() === endDate.toDateString()) {
// Same day - compare hours
if (selectionStartHour <= hour) {
finalStart = startDate;
finalEnd = endDate;
startHour = selectionStartHour;
endHour = hour;
} else {
finalStart = endDate;
finalEnd = startDate;
startHour = hour;
endHour = selectionStartHour;
}
} else if (startDate < endDate) {
finalStart = startDate;
finalEnd = endDate;
startHour = selectionStartHour;
endHour = hour;
} else {
finalStart = endDate;
finalEnd = startDate;
startHour = hour;
endHour = selectionStartHour;
}
// Set the hours
finalStart.setHours(startHour, 0, 0, 0);
finalEnd.setHours(endHour, 59, 59, 999);
// Check if any hour in range is unavailable
let current = new Date(finalStart);
let hasUnavailable = false;
while (current <= finalEnd) {
const currentHour = current.getHours();
if (isHourUnavailable(current, currentHour)) {
hasUnavailable = true;
break;
}
current.setHours(current.getHours() + 1);
}
if (hasUnavailable) {
// Range contains unavailable hours, reset
setSelectionStart(null);
setSelectionStartHour(null);
return;
}
// Create rental selection
const rentalPeriod: UnavailablePeriod = {
id: 'rental-selection',
startDate: finalStart,
endDate: finalEnd,
startTime: `${startHour.toString().padStart(2, '0')}:00`,
endTime: `${endHour.toString().padStart(2, '0')}:00`,
isRentalSelection: true
};
setInternalSelectedPeriod(rentalPeriod);
setSelectionStart(null);
setSelectionStartHour(null);
// Notify parent
if (onRentalSelect) {
onRentalSelect({
startDate: finalStart,
endDate: finalEnd,
startTime: `${startHour.toString().padStart(2, '0')}:00`,
endTime: `${endHour.toString().padStart(2, '0')}:00`
});
}
}
};
const handleDateClick = (date: Date) => {
// Check if this date has an accepted rental
if (mode === 'owner') {
// OWNER MODE: Toggle unavailability for dates
// Check if this date has an accepted rental - can't modify those
const hasAcceptedRental = unavailablePeriods.some(p =>
p.isAcceptedRental && isDateInPeriod(date, p)
);
if (hasAcceptedRental) {
// Don't allow clicking on accepted rental dates
return; // Can't modify dates with accepted rentals
}
// Check if this date is part of any unavailable period
const isUnavailable = isDateUnavailable(date);
if (isUnavailable && !ownerSelectionStart) {
// Single click on an unavailable date - split periods to make just this date available
const updatedPeriods: UnavailablePeriod[] = [];
unavailablePeriods.forEach(period => {
if (period.isAcceptedRental || !isDateInPeriod(date, period)) {
// Keep accepted rentals and periods that don't contain this date
updatedPeriods.push(period);
} else {
// Split the period around this date
const periodStart = new Date(period.startDate);
const periodEnd = new Date(period.endDate);
const targetDate = new Date(date);
periodStart.setHours(0, 0, 0, 0);
periodEnd.setHours(0, 0, 0, 0);
targetDate.setHours(0, 0, 0, 0);
// If there are dates before the target date, create a period for them
if (periodStart < targetDate) {
const beforeDate = new Date(targetDate);
beforeDate.setDate(beforeDate.getDate() - 1);
updatedPeriods.push({
id: Date.now().toString() + '-before',
startDate: periodStart,
endDate: beforeDate,
startTime: period.startTime,
endTime: period.endTime
});
}
// If there are dates after the target date, create a period for them
if (periodEnd > targetDate) {
const afterDate = new Date(targetDate);
afterDate.setDate(afterDate.getDate() + 1);
updatedPeriods.push({
id: Date.now().toString() + '-after',
startDate: afterDate,
endDate: periodEnd,
startTime: period.startTime,
endTime: period.endTime
});
}
}
});
onPeriodsChange(updatedPeriods);
setOwnerSelectionStart(null);
} else if (!ownerSelectionStart) {
// First click - start selection
setOwnerSelectionStart(date);
} else {
// Second click - complete range selection
const start = new Date(Math.min(ownerSelectionStart.getTime(), date.getTime()));
const end = new Date(Math.max(ownerSelectionStart.getTime(), date.getTime()));
// Check if any date in range has accepted rental
let current = new Date(start);
let hasAcceptedInRange = false;
while (current <= end) {
if (unavailablePeriods.some(p => p.isAcceptedRental && isDateInPeriod(current, p))) {
hasAcceptedInRange = true;
break;
}
current.setDate(current.getDate() + 1);
}
if (hasAcceptedInRange) {
// Can't select range with accepted rentals
setOwnerSelectionStart(null);
return;
}
if (!isRentalMode) {
toggleDateAvailability(date);
return;
// Add new unavailable period for the range
const newPeriod: UnavailablePeriod = {
id: Date.now().toString(),
startDate: start,
endDate: end
};
onPeriodsChange([...unavailablePeriods, newPeriod]);
setOwnerSelectionStart(null);
}
} else {
// RENTER MODE: Select rental period
// Check if date is unavailable
if (isDateUnavailable(date)) {
return; // Can't select unavailable dates
}
// Check if clicking on an existing rental selection to clear it
const existingRental = unavailablePeriods.find(p =>
p.isRentalSelection && isDateInPeriod(date, p)
);
if (existingRental) {
// Clear the rental selection
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingRental.id));
setSelectionStart(null);
return;
}
// Two-click selection in rental mode
if (!selectionStart) {
// First click - set start date
setSelectionStart(date);
setSelectionStartHour(null); // Reset hour selection
setInternalSelectedPeriod(null);
} else {
// Second click - create rental period
// Second click - complete selection
const start = new Date(Math.min(selectionStart.getTime(), date.getTime()));
const end = new Date(Math.max(selectionStart.getTime(), date.getTime()));
// Check if any date in range is unavailable
let currentDate = new Date(start);
let current = new Date(start);
let hasUnavailable = false;
while (currentDate <= end) {
if (unavailablePeriods.some(p => !p.isRentalSelection && isDateInPeriod(currentDate, p))) {
while (current <= end) {
if (isDateUnavailable(current)) {
hasUnavailable = true;
break;
}
currentDate.setDate(currentDate.getDate() + 1);
current.setDate(current.getDate() + 1);
}
if (hasUnavailable) {
// Range contains unavailable dates, reset selection
// Range contains unavailable dates, reset
setSelectionStart(null);
return;
}
// Clear existing rental selections and add new one
const nonRentalPeriods = unavailablePeriods.filter(p => !p.isRentalSelection);
const newPeriod: UnavailablePeriod = {
id: Date.now().toString(),
// Create rental selection
const rentalPeriod: UnavailablePeriod = {
id: 'rental-selection',
startDate: start,
endDate: end,
isRentalSelection: true
};
onPeriodsChange([...nonRentalPeriods, newPeriod]);
// Reset selection
setInternalSelectedPeriod(rentalPeriod);
setSelectionStart(null);
setSelectionStartHour(null);
// Notify parent if callback provided
if (onRentalSelect) {
onRentalSelect({
startDate: start,
endDate: end
});
}
}
}
};
const toggleDateAvailability = (date: Date) => {
const dateStr = date.toISOString().split('T')[0];
if (isRentalMode) {
// In rental mode, only handle rental selections (green), not unavailable periods (red)
const existingRentalPeriod = unavailablePeriods.find(period => {
const periodStart = new Date(period.startDate).toISOString().split('T')[0];
const periodEnd = new Date(period.endDate).toISOString().split('T')[0];
return period.isRentalSelection && periodStart === dateStr && periodEnd === dateStr && !period.startTime && !period.endTime;
});
const toggleHourAvailability = (date: Date, hour: number) => {
if (mode !== 'owner') return; // Only owners can toggle hour availability
// Check if date is already unavailable (not a rental selection)
const isUnavailable = unavailablePeriods.some(p =>
!p.isRentalSelection && isDateInPeriod(date, p)
// Check if this hour has an accepted rental
const hasAcceptedRental = unavailablePeriods.some(p =>
p.isAcceptedRental && isHourUnavailable(date, hour)
);
if (isUnavailable) {
// Can't select unavailable dates
if (hasAcceptedRental) {
return; // Can't modify hours with accepted rentals
}
const isUnavailable = isHourUnavailable(date, hour);
if (isUnavailable && !ownerSelectionStartHour) {
// Make just this hour available by splitting periods
const updatedPeriods: UnavailablePeriod[] = [];
unavailablePeriods.forEach(period => {
if (period.isAcceptedRental || !isDateInPeriod(date, period)) {
// Keep accepted rentals and periods that don't contain this date
updatedPeriods.push(period);
} else if (!period.startTime || !period.endTime) {
// This is a full-day period, split it into hourly periods excluding this hour
for (let h = 0; h < 24; h++) {
if (h !== hour) {
updatedPeriods.push({
id: `${period.id}-hour-${h}`,
startDate: date,
endDate: date,
startTime: `${h.toString().padStart(2, '0')}:00`,
endTime: `${h.toString().padStart(2, '0')}:59`
});
}
}
} else {
// This is an hourly period, check if it contains this hour
const startHour = parseInt(period.startTime.split(':')[0]);
const endHour = parseInt(period.endTime.split(':')[0]);
if (hour < startHour || hour > endHour) {
// Hour is outside this period, keep the period
updatedPeriods.push(period);
} else {
// Split the period around this hour
if (startHour < hour) {
updatedPeriods.push({
id: period.id + '-before',
startDate: period.startDate,
endDate: period.endDate,
startTime: period.startTime,
endTime: `${(hour - 1).toString().padStart(2, '0')}:59`
});
}
if (endHour > hour) {
updatedPeriods.push({
id: period.id + '-after',
startDate: period.startDate,
endDate: period.endDate,
startTime: `${(hour + 1).toString().padStart(2, '0')}:00`,
endTime: period.endTime
});
}
}
}
});
onPeriodsChange(updatedPeriods);
setOwnerSelectionStart(null);
setOwnerSelectionStartHour(null);
} else if (!ownerSelectionStartHour) {
// First click - start selection
setOwnerSelectionStart(date);
setOwnerSelectionStartHour(hour);
} else {
// Second click - complete selection
const startDate = new Date(ownerSelectionStart!);
const endDate = new Date(date);
// Determine which date/hour comes first
let finalStart, finalEnd, startHour, endHour;
if (startDate.toDateString() === endDate.toDateString()) {
// Same day - compare hours
if (ownerSelectionStartHour <= hour) {
finalStart = startDate;
finalEnd = endDate;
startHour = ownerSelectionStartHour;
endHour = hour;
} else {
finalStart = endDate;
finalEnd = startDate;
startHour = hour;
endHour = ownerSelectionStartHour;
}
} else if (startDate < endDate) {
finalStart = startDate;
finalEnd = endDate;
startHour = ownerSelectionStartHour;
endHour = hour;
} else {
finalStart = endDate;
finalEnd = startDate;
startHour = hour;
endHour = ownerSelectionStartHour;
}
// Check if any hour in range has accepted rental
let current = new Date(finalStart);
current.setHours(startHour, 0, 0, 0);
const endDateTime = new Date(finalEnd);
endDateTime.setHours(endHour, 59, 59, 999);
let hasAcceptedInRange = false;
while (current <= endDateTime) {
const currentHour = current.getHours();
if (unavailablePeriods.some(p =>
p.isAcceptedRental && isHourUnavailable(current, currentHour)
)) {
hasAcceptedInRange = true;
break;
}
current.setHours(current.getHours() + 1);
}
if (hasAcceptedInRange) {
// Can't select range with accepted rentals
setOwnerSelectionStart(null);
setOwnerSelectionStartHour(null);
return;
}
if (existingRentalPeriod) {
// Remove the rental selection
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingRentalPeriod.id));
} else {
// Add new rental selection
// Create unavailable period
const newPeriod: UnavailablePeriod = {
id: Date.now().toString(),
startDate: date,
endDate: date,
isRentalSelection: true
};
onPeriodsChange([...unavailablePeriods, newPeriod]);
}
} else {
// Original behavior for marking unavailable
const existingPeriod = unavailablePeriods.find(period => {
const periodStart = new Date(period.startDate).toISOString().split('T')[0];
const periodEnd = new Date(period.endDate).toISOString().split('T')[0];
return periodStart === dateStr && periodEnd === dateStr && !period.startTime && !period.endTime;
});
if (existingPeriod) {
// Remove the period to make it available
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingPeriod.id));
} else {
// Add new unavailable period for this date
const newPeriod: UnavailablePeriod = {
id: Date.now().toString(),
startDate: date,
endDate: date
};
onPeriodsChange([...unavailablePeriods, newPeriod]);
}
}
};
const toggleHourAvailability = (date: Date, hour: number) => {
const startTime = `${hour.toString().padStart(2, '0')}:00`;
const endTime = `${hour.toString().padStart(2, '0')}:59`;
const existingPeriod = unavailablePeriods.find(period => {
const periodDate = new Date(period.startDate).toISOString().split('T')[0];
const checkDate = date.toISOString().split('T')[0];
return periodDate === checkDate &&
period.startTime === startTime &&
period.endTime === endTime;
});
if (existingPeriod) {
// Remove the period to make it available
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingPeriod.id));
} else {
// Add new unavailable period for this hour
const newPeriod: UnavailablePeriod = {
id: Date.now().toString(),
startDate: date,
endDate: date,
startTime: startTime,
endTime: endTime
startDate: finalStart,
endDate: finalEnd,
startTime: `${startHour.toString().padStart(2, '0')}:00`,
endTime: `${endHour.toString().padStart(2, '0')}:59`
};
onPeriodsChange([...unavailablePeriods, newPeriod]);
setOwnerSelectionStart(null);
setOwnerSelectionStartHour(null);
}
};
@@ -341,45 +590,66 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
if (date) {
className += ' border cursor-pointer';
const rentalPeriod = unavailablePeriods.find(p =>
p.isRentalSelection && isDateInPeriod(date, p)
);
if (mode === 'owner') {
// OWNER VIEW
const acceptedRental = unavailablePeriods.find(p =>
p.isAcceptedRental && isDateInPeriod(date, p)
);
// Check if date is the selection start
const isSelectionStart = selectionStart && date.getTime() === selectionStart.getTime();
// Check if date would be in the range if this was the end date
const wouldBeInRange = selectionStart && date >=
new Date(Math.min(selectionStart.getTime(), date.getTime())) &&
date <= new Date(Math.max(selectionStart.getTime(), date.getTime()));
const isOwnerStart = ownerSelectionStart && date.getTime() === ownerSelectionStart.getTime();
if (acceptedRental) {
className += ' text-white';
title = 'Booked - This date has an accepted rental';
backgroundColor = '#6f42c1';
} else if (rentalPeriod) {
className += ' bg-success text-white';
title = isRentalMode ? 'Selected for rental - Click to clear' : 'Selected';
} else if (isSelectionStart) {
className += ' bg-primary text-white';
title = 'Start date selected - Click another date to complete selection';
} else if (wouldBeInRange && !isDateFullyUnavailable(date) && !isDatePartiallyUnavailable(date)) {
className += ' bg-info bg-opacity-25';
title = 'Click to set as end date';
} else if (isOwnerStart) {
className += ' bg-warning text-dark';
title = 'Click another date to mark range as unavailable';
} else if (isDateFullyUnavailable(date)) {
className += ' bg-danger text-white';
title = isRentalMode ? 'Unavailable' : 'Fully unavailable - Click to make available';
title = 'Unavailable - Click to make available';
} else if (isDatePartiallyUnavailable(date)) {
className += ' text-dark';
title = isRentalMode ? 'Partially unavailable' : 'Partially unavailable - Click to view details';
title = 'Partially unavailable - Switch to week/day view for details';
backgroundColor = '#ffeb3b';
} else {
className += ' bg-light';
title = isRentalMode ? 'Available - Click to select' : 'Available - Click to make unavailable';
title = ownerSelectionStart ? 'Click to complete selection' : 'Click to start selecting unavailable dates';
}
} else {
// RENTER VIEW
let isInSelectedPeriod = false;
if (selectedRentalPeriod) {
// Special handling for rental selections with specific end times
if (selectedRentalPeriod.endTime === '00:00') {
// If end time is midnight, don't include the end date in highlighting
const start = new Date(selectedRentalPeriod.startDate);
const end = new Date(selectedRentalPeriod.endDate);
const checkDate = new Date(date);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
checkDate.setHours(0, 0, 0, 0);
// Exclude the end date if end time is 00:00
isInSelectedPeriod = checkDate >= start && checkDate < end;
} else {
isInSelectedPeriod = isDateInPeriod(date, selectedRentalPeriod);
}
}
const isSelectionStart = selectionStart && date.getTime() === selectionStart.getTime();
if (isDateFullyUnavailable(date)) {
className += ' bg-danger text-white';
} else if (isDatePartiallyUnavailable(date)) {
className += ' text-dark';
backgroundColor = '#ffeb3b';
} else if (isInSelectedPeriod) {
className += ' bg-success text-white';
} else if (isSelectionStart) {
className += ' bg-success bg-opacity-50 text-dark';
} else {
className += ' bg-light';
}
}
}
@@ -393,7 +663,7 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
cursor: date ? 'pointer' : 'default',
backgroundColor: backgroundColor
}}
title={date ? title : ''}
title={mode === 'owner' && date ? title : ''}
>
{date?.getDate()}
</div>
@@ -428,22 +698,93 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
<tbody>
{hours.map(hour => (
<tr key={hour}>
<td className="text-center small">
{hour.toString().padStart(2, '0')}:00
<td className="text-center small" style={{ paddingBottom: '12px' }}>
{hour === 0 ? '12 AM' : hour === 12 ? '12 PM' : hour < 12 ? `${hour} AM` : `${hour - 12} PM`}
</td>
{days.map((date, dayIndex) => {
const isUnavailable = isHourUnavailable(date, hour);
const isClickable = mode === 'owner';
const isOwnerSelectionStart = mode === 'owner' && ownerSelectionStart &&
date.toDateString() === ownerSelectionStart.toDateString() &&
hour === ownerSelectionStartHour;
// Check if this hour is in the selected period
let isInSelectedPeriod = false;
if (mode === 'renter' && selectedRentalPeriod) {
const cellDate = new Date(date);
cellDate.setHours(0, 0, 0, 0);
const selStartDate = new Date(selectedRentalPeriod.startDate);
selStartDate.setHours(0, 0, 0, 0);
const selEndDate = new Date(selectedRentalPeriod.endDate);
selEndDate.setHours(0, 0, 0, 0);
// Check if date is in range
if (cellDate >= selStartDate && cellDate <= selEndDate) {
// If times are specified, check hour range
if (selectedRentalPeriod.startTime && selectedRentalPeriod.endTime) {
const [startHour] = selectedRentalPeriod.startTime.split(':').map(Number);
const [endHour] = selectedRentalPeriod.endTime.split(':').map(Number);
if (cellDate.getTime() === selStartDate.getTime() && cellDate.getTime() === selEndDate.getTime()) {
// Same day - check if hour is in range (end hour is exclusive)
isInSelectedPeriod = hour >= startHour && hour < endHour;
} else if (cellDate.getTime() === selStartDate.getTime()) {
// Start day - check if hour is after or at start hour
isInSelectedPeriod = hour >= startHour;
} else if (cellDate.getTime() === selEndDate.getTime()) {
// End day - check if hour is before end hour (exclusive)
isInSelectedPeriod = hour < endHour;
} else {
// Middle day - all hours selected
isInSelectedPeriod = true;
}
} else {
isInSelectedPeriod = true;
}
}
}
const isSelectionStart = mode === 'renter' && selectionStart &&
date.toDateString() === selectionStart.toDateString() &&
hour === selectionStartHour;
let cellClass = 'text-center p-1';
if (isUnavailable) {
cellClass += ' bg-danger text-white';
} else if (isInSelectedPeriod) {
cellClass += ' bg-success text-white';
} else if (isOwnerSelectionStart) {
cellClass += ' bg-warning text-dark';
} else if (isSelectionStart) {
cellClass += ' bg-success bg-opacity-50 text-dark';
} else {
cellClass += ' bg-light';
}
cellClass += (isClickable || (mode === 'renter' && !isUnavailable)) ? ' cursor-pointer' : '';
return (
<td
key={dayIndex}
className={`text-center cursor-pointer p-1
${isUnavailable ? 'bg-danger text-white' : 'bg-light'}`}
onClick={() => toggleHourAvailability(date, hour)}
style={{ cursor: 'pointer', height: '30px' }}
title={isUnavailable ? 'Click to make available' : 'Click to make unavailable'}
className={cellClass}
onClick={() => {
if (mode === 'owner') {
toggleHourAvailability(date, hour);
} else if (mode === 'renter' && !isUnavailable) {
handleHourClick(date, hour);
}
}}
style={{
cursor: (isClickable || (mode === 'renter' && !isUnavailable)) ? 'pointer' : 'default',
height: '40px',
borderLeft: dayIndex === 0 ? '1px solid #dee2e6' : 'none'
}}
title={
mode === 'owner'
? (isUnavailable ? 'Click to make this hour available' : (ownerSelectionStartHour !== null ? 'Click to complete selection' : 'Click to start selecting unavailable hours'))
: ''
}
>
{isUnavailable && '×'}
{isUnavailable && <span style={{ fontSize: '1.2rem' }}>×</span>}
</td>
);
})}
@@ -473,18 +814,85 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
<tbody>
{hours.map(hour => {
const isUnavailable = isHourUnavailable(currentDate, hour);
const isClickable = mode === 'owner';
const isOwnerSelectionStart = mode === 'owner' && ownerSelectionStart &&
currentDate.toDateString() === ownerSelectionStart.toDateString() &&
hour === ownerSelectionStartHour;
// Check if this hour is in the selected period
let isInSelectedPeriod = false;
if (mode === 'renter' && selectedRentalPeriod) {
const cellDate = new Date(currentDate);
cellDate.setHours(0, 0, 0, 0);
const selStartDate = new Date(selectedRentalPeriod.startDate);
selStartDate.setHours(0, 0, 0, 0);
const selEndDate = new Date(selectedRentalPeriod.endDate);
selEndDate.setHours(0, 0, 0, 0);
// Check if date is in range
if (cellDate >= selStartDate && cellDate <= selEndDate) {
// If times are specified, check hour range
if (selectedRentalPeriod.startTime && selectedRentalPeriod.endTime) {
const [startHour] = selectedRentalPeriod.startTime.split(':').map(Number);
const [endHour] = selectedRentalPeriod.endTime.split(':').map(Number);
if (cellDate.getTime() === selStartDate.getTime() && cellDate.getTime() === selEndDate.getTime()) {
// Same day - check if hour is in range (end hour is exclusive)
isInSelectedPeriod = hour >= startHour && hour < endHour;
} else if (cellDate.getTime() === selStartDate.getTime()) {
// Start day - check if hour is after or at start hour
isInSelectedPeriod = hour >= startHour;
} else if (cellDate.getTime() === selEndDate.getTime()) {
// End day - check if hour is before end hour (exclusive)
isInSelectedPeriod = hour < endHour;
} else {
// Middle day - all hours selected
isInSelectedPeriod = true;
}
} else {
isInSelectedPeriod = true;
}
}
}
const isSelectionStart = mode === 'renter' && selectionStart &&
currentDate.toDateString() === selectionStart.toDateString() &&
hour === selectionStartHour;
let cellClass = 'text-center p-3';
if (isUnavailable) {
cellClass += ' bg-danger text-white';
} else if (isInSelectedPeriod) {
cellClass += ' bg-success text-white';
} else if (isOwnerSelectionStart) {
cellClass += ' bg-warning text-dark';
} else if (isSelectionStart) {
cellClass += ' bg-success bg-opacity-50 text-dark';
} else {
cellClass += ' bg-light';
}
cellClass += (isClickable || (mode === 'renter' && !isUnavailable)) ? ' cursor-pointer' : '';
return (
<tr key={hour}>
<td className="text-center" style={{ width: '100px' }}>
{hour.toString().padStart(2, '0')}:00
{hour === 0 ? '12:00 AM' : hour === 12 ? '12:00 PM' : hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM`}
</td>
<td
className={`text-center cursor-pointer p-3
${isUnavailable ? 'bg-danger text-white' : 'bg-light'}`}
onClick={() => toggleHourAvailability(currentDate, hour)}
style={{ cursor: 'pointer' }}
title={isUnavailable ? 'Click to make available' : 'Click to make unavailable'}
className={cellClass}
onClick={() => {
if (mode === 'owner') {
toggleHourAvailability(currentDate, hour);
} else if (mode === 'renter' && !isUnavailable) {
handleHourClick(currentDate, hour);
}
}}
style={{ cursor: (isClickable || (mode === 'renter' && !isUnavailable)) ? 'pointer' : 'default' }}
title={
mode === 'owner'
? (isUnavailable ? 'Click to make this hour available' : (ownerSelectionStartHour !== null ? 'Click to complete selection' : 'Click to start selecting unavailable hours'))
: ''
}
>
{isUnavailable ? 'Unavailable' : 'Available'}
</td>
@@ -527,8 +935,6 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
>
Month
</button>
{priceType === 'hour' && (
<>
<button
type="button"
className={`btn btn-sm ${viewType === 'week' ? 'btn-primary' : 'btn-outline-primary'}`}
@@ -543,8 +949,6 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
>
Day
</button>
</>
)}
</div>
<div>
@@ -572,22 +976,22 @@ const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
</div>
<div className="text-muted small">
<div className="mb-2">
<i className="bi bi-info-circle"></i> {isRentalMode ? 'Click start date, then click end date to select rental period' : 'Click on any date or time slot to toggle availability'}
</div>
{viewType === 'month' && (
<div className="d-flex gap-3 justify-content-center flex-wrap">
<span><span className="badge bg-light text-dark"></span> Available</span>
{isRentalMode && (
<span><span className="badge bg-success"></span> Selected</span>
{mode === 'renter' && selectedRentalPeriod && (
<span><span className="badge bg-success"></span> Your Selection</span>
)}
{!isRentalMode && (
<span><span className="badge text-white" style={{ backgroundColor: '#6f42c1' }}></span> Booked</span>
{mode === 'owner' && ownerSelectionStart && (
<span><span className="badge bg-warning text-dark"></span> Selection Start</span>
)}
{mode === 'owner' && (
<span><span className="badge text-white" style={{ backgroundColor: '#6f42c1' }}></span> Booked Rental</span>
)}
{viewType === 'month' && (
<span><span className="badge text-dark" style={{ backgroundColor: '#ffeb3b' }}></span> Partially Unavailable</span>
<span><span className="badge bg-danger"></span> Fully Unavailable</span>
</div>
)}
<span><span className="badge bg-danger"></span> {mode === 'owner' ? 'Marked Unavailable' : 'Not Available'}</span>
</div>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
interface PrivateRouteProps {
@@ -8,6 +8,7 @@ interface PrivateRouteProps {
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
@@ -19,7 +20,7 @@ const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
);
}
return user ? <>{children}</> : <Navigate to="/login" />;
return user ? <>{children}</> : <Navigate to="/login" state={{ from: location }} replace />;
};
export default PrivateRoute;

View File

@@ -435,7 +435,7 @@ const CreateItem: React.FC = () => {
onPeriodsChange={(periods) =>
setFormData(prev => ({ ...prev, unavailablePeriods: periods }))
}
priceType={priceType}
mode="owner"
/>
</div>

View File

@@ -546,7 +546,7 @@ const EditItem: React.FC = () => {
const userPeriods = periods.filter(p => !p.isAcceptedRental);
setFormData(prev => ({ ...prev, unavailablePeriods: userPeriods }));
}}
priceType={priceType}
mode="owner"
/>
</div>

View File

@@ -18,6 +18,9 @@ const ItemDetail: React.FC = () => {
useEffect(() => {
fetchItem();
}, [id]);
useEffect(() => {
if (user) {
checkIfAlreadyRenting();
}
@@ -207,7 +210,7 @@ const ItemDetail: React.FC = () => {
<ItemReviews itemId={item.id} />
<div className="d-flex gap-2">
<div className="d-flex gap-2 mb-5">
{isOwner ? (
<button className="btn btn-primary" onClick={handleEdit}>
Edit Listing

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Login: React.FC = () => {
@@ -9,6 +9,9 @@ const Login: React.FC = () => {
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -17,7 +20,7 @@ const Login: React.FC = () => {
try {
await login(email, password);
navigate('/');
navigate(from, { replace: true });
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to login');
} finally {
@@ -75,7 +78,7 @@ const Login: React.FC = () => {
<div className="text-center mt-3">
<p className="mb-0">
Don't have an account?{' '}
<Link to="/register" className="text-decoration-none">
<Link to="/register" state={{ from: location.state?.from }} className="text-decoration-none">
Sign up
</Link>
</p>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Register: React.FC = () => {
@@ -15,6 +15,9 @@ const Register: React.FC = () => {
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
@@ -30,7 +33,7 @@ const Register: React.FC = () => {
try {
await register(formData);
navigate('/');
navigate(from, { replace: true });
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to create account');
} finally {

View File

@@ -23,6 +23,13 @@ const RentItem: React.FC = () => {
cardName: ''
});
const [manualSelection, setManualSelection] = useState({
startDate: '',
startTime: '09:00',
endDate: '',
endTime: '17:00'
});
const [selectedPeriods, setSelectedPeriods] = useState<Array<{
id: string;
startDate: Date;
@@ -42,6 +49,48 @@ const RentItem: React.FC = () => {
calculateTotal();
}, [selectedPeriods, item]);
useEffect(() => {
// Sync manual selection with selected periods
if (selectedPeriods.length > 0) {
const period = selectedPeriods[0];
// Extract hours from the Date objects if startTime/endTime not provided
let startTimeStr = period.startTime;
let endTimeStr = period.endTime;
if (!startTimeStr) {
const startHour = period.startDate.getHours();
startTimeStr = `${startHour.toString().padStart(2, '0')}:00`;
}
if (!endTimeStr) {
const endHour = period.endDate.getHours();
// If the end hour is 23:59:59, show it as 00:00 of the next day
if (endHour === 23 && period.endDate.getMinutes() === 59) {
endTimeStr = '00:00';
// Adjust the end date to show the next day
const adjustedEndDate = new Date(period.endDate);
adjustedEndDate.setDate(adjustedEndDate.getDate() + 1);
setManualSelection({
startDate: period.startDate.toISOString().split('T')[0],
startTime: startTimeStr,
endDate: adjustedEndDate.toISOString().split('T')[0],
endTime: endTimeStr
});
return;
} else {
endTimeStr = `${endHour.toString().padStart(2, '0')}:00`;
}
}
setManualSelection({
startDate: period.startDate.toISOString().split('T')[0],
startTime: startTimeStr,
endDate: period.endDate.toISOString().split('T')[0],
endTime: endTimeStr
});
}
}, [selectedPeriods]);
const fetchItem = async () => {
try {
const response = await itemAPI.getItem(id!);
@@ -177,6 +226,59 @@ const RentItem: React.FC = () => {
}
};
const handleManualSelectionChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const updatedSelection = {
...manualSelection,
[e.target.name]: e.target.value
};
setManualSelection(updatedSelection);
// Automatically apply selection if both dates are set
if (updatedSelection.startDate && updatedSelection.endDate) {
// Create dates with time set to midnight to avoid timezone issues
const start = new Date(updatedSelection.startDate + 'T00:00:00');
const end = new Date(updatedSelection.endDate + 'T00:00:00');
// Add time for both hourly and daily rentals
const [startHour, startMin] = updatedSelection.startTime.split(':').map(Number);
const [endHour, endMin] = updatedSelection.endTime.split(':').map(Number);
start.setHours(startHour, startMin, 0, 0);
end.setHours(endHour, endMin, 0, 0);
// Note: We keep the times as selected by the user
// The calendar will interpret 00:00 correctly
// Validate dates
if (end < start) {
setError('End date/time must be after start date/time');
return;
}
// Check if dates are available
const unavailable = item?.unavailablePeriods?.some(period => {
const periodStart = new Date(period.startDate);
const periodEnd = new Date(period.endDate);
return (start >= periodStart && start <= periodEnd) ||
(end >= periodStart && end <= periodEnd) ||
(start <= periodStart && end >= periodEnd);
});
if (unavailable) {
setError('Selected dates include unavailable periods');
return;
}
setError(null);
setSelectedPeriods([{
id: Date.now().toString(),
startDate: start,
endDate: end,
startTime: updatedSelection.startTime,
endTime: updatedSelection.endTime
}]);
}
};
if (loading) {
return (
<div className="container mt-5">
@@ -223,42 +325,117 @@ const RentItem: React.FC = () => {
<h5 className="card-title">Select Rental Period</h5>
<AvailabilityCalendar
unavailablePeriods={[
...(item.unavailablePeriods || []),
...selectedPeriods.map(p => ({ ...p, isRentalSelection: true }))
]}
onPeriodsChange={(periods) => {
// Only handle rental selections
const rentalSelections = periods.filter(p => p.isRentalSelection);
setSelectedPeriods(rentalSelections.map(p => {
const { isRentalSelection, ...rest } = p;
return rest;
}));
unavailablePeriods={item.unavailablePeriods || []}
onPeriodsChange={() => {}} // Read-only for renters
mode="renter"
selectedRentalPeriod={selectedPeriods.length > 0 ? {
id: selectedPeriods[0].id,
startDate: selectedPeriods[0].startDate,
endDate: selectedPeriods[0].endDate,
startTime: selectedPeriods[0].startTime,
endTime: selectedPeriods[0].endTime,
isRentalSelection: true
} : undefined}
onRentalSelect={(period) => {
// Update selected periods
setSelectedPeriods([{
id: Date.now().toString(),
startDate: period.startDate,
endDate: period.endDate,
startTime: period.startTime,
endTime: period.endTime
}]);
// Update manual selection to match calendar
setManualSelection({
startDate: period.startDate.toISOString().split('T')[0],
startTime: period.startTime || '09:00',
endDate: period.endDate.toISOString().split('T')[0],
endTime: period.endTime || '17:00'
});
}}
priceType={showHourlyOptions ? "hour" : "day"}
isRentalMode={true}
/>
<div className="mt-4">
<div className="row g-3">
<div className="col-md-3">
<label htmlFor="startDate" className="form-label">Start Date</label>
<input
type="date"
className="form-control"
id="startDate"
name="startDate"
value={manualSelection.startDate}
onChange={handleManualSelectionChange}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="col-md-3">
<label htmlFor="startTime" className="form-label">Start Time</label>
<select
className="form-select"
id="startTime"
name="startTime"
value={manualSelection.startTime}
onChange={handleManualSelectionChange}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
const period = i < 12 ? 'AM' : 'PM';
return (
<option key={i} value={`${i.toString().padStart(2, '0')}:00`}>
{hour}:00 {period}
</option>
);
})}
</select>
</div>
<div className="col-md-3">
<label htmlFor="endDate" className="form-label">End Date</label>
<input
type="date"
className="form-control"
id="endDate"
name="endDate"
value={manualSelection.endDate}
onChange={handleManualSelectionChange}
min={manualSelection.startDate || new Date().toISOString().split('T')[0]}
/>
</div>
<div className="col-md-3">
<label htmlFor="endTime" className="form-label">End Time</label>
<select
className="form-select"
id="endTime"
name="endTime"
value={manualSelection.endTime}
onChange={handleManualSelectionChange}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
const period = i < 12 ? 'AM' : 'PM';
return (
<option key={i} value={`${i.toString().padStart(2, '0')}:00`}>
{hour}:00 {period}
</option>
);
})}
</select>
</div>
</div>
</div>
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
<div className="alert alert-warning mt-3">
Minimum rental period is {minDays} days
</div>
)}
{selectedPeriods.length === 0 && (
<div className="alert alert-info mt-3">
Please select your rental dates on the calendar above
</div>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Delivery Options</h5>
<div className="mb-3">
<label htmlFor="deliveryMethod" className="form-label">Delivery Method *</label>
<div className="my-3">
<select
className="form-select"
id="deliveryMethod"

View File

@@ -21,9 +21,17 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Only redirect to login if we have a token (user was logged in)
const token = localStorage.getItem('token');
if (token) {
// User was logged in but token expired/invalid
localStorage.removeItem('token');
window.location.href = '/login';
}
// For non-authenticated users, just reject the error without redirecting
// Let individual components handle 401 errors as needed
}
return Promise.reject(error);
}
);