Initial commit - Rentall App
- Full-stack rental marketplace application - React frontend with TypeScript - Node.js/Express backend with JWT authentication - Features: item listings, rental requests, calendar availability, user profiles
This commit is contained in:
159
frontend/src/components/AddressAutocomplete.tsx
Normal file
159
frontend/src/components/AddressAutocomplete.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface AddressSuggestion {
|
||||
place_id: string;
|
||||
display_name: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
}
|
||||
|
||||
interface AddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string, lat?: number, lon?: number) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Address",
|
||||
required = false,
|
||||
className = "form-control",
|
||||
id,
|
||||
name
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const debounceTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// Handle clicking outside to close suggestions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchAddressSuggestions = async (query: string) => {
|
||||
if (query.length < 3) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Using Nominatim API (OpenStreetMap) for free geocoding
|
||||
// In production, you might want to use Google Places API or another service
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`q=${encodeURIComponent(query)}&` +
|
||||
`format=json&` +
|
||||
`limit=5&` +
|
||||
`countrycodes=us`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuggestions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching address suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
setShowSuggestions(true);
|
||||
|
||||
// Debounce the API call
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
fetchAddressSuggestions(newValue);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: AddressSuggestion) => {
|
||||
onChange(
|
||||
suggestion.display_name,
|
||||
parseFloat(suggestion.lat),
|
||||
parseFloat(suggestion.lon)
|
||||
);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="position-relative">
|
||||
<input
|
||||
type="text"
|
||||
className={className}
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||||
<div
|
||||
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
|
||||
style={{ top: '100%', zIndex: 1000, maxHeight: '300px', overflowY: 'auto' }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-2 text-center text-muted">
|
||||
<small>Searching addresses...</small>
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.place_id}
|
||||
className="p-2 border-bottom cursor-pointer"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
onMouseEnter={(e) => e.currentTarget.classList.add('bg-light')}
|
||||
onMouseLeave={(e) => e.currentTarget.classList.remove('bg-light')}
|
||||
>
|
||||
<small className="d-block text-truncate">
|
||||
{suggestion.display_name}
|
||||
</small>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
596
frontend/src/components/AvailabilityCalendar.tsx
Normal file
596
frontend/src/components/AvailabilityCalendar.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface UnavailablePeriod {
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
isRentalSelection?: boolean;
|
||||
isAcceptedRental?: boolean;
|
||||
}
|
||||
|
||||
interface AvailabilityCalendarProps {
|
||||
unavailablePeriods: UnavailablePeriod[];
|
||||
onPeriodsChange: (periods: UnavailablePeriod[]) => void;
|
||||
priceType?: 'hour' | 'day';
|
||||
isRentalMode?: boolean;
|
||||
}
|
||||
|
||||
type ViewType = 'month' | 'week' | 'day';
|
||||
|
||||
const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
|
||||
unavailablePeriods,
|
||||
onPeriodsChange,
|
||||
priceType = 'hour',
|
||||
isRentalMode = false
|
||||
}) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewType, setViewType] = useState<ViewType>('month');
|
||||
const [selectionStart, setSelectionStart] = useState<Date | 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]);
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
const days: (Date | null)[] = [];
|
||||
|
||||
// Add empty cells for days before month starts
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add all days in month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const getWeekDays = (date: Date) => {
|
||||
const startOfWeek = new Date(date);
|
||||
const day = startOfWeek.getDay();
|
||||
const diff = startOfWeek.getDate() - day;
|
||||
startOfWeek.setDate(diff);
|
||||
|
||||
const days: Date[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(startOfWeek);
|
||||
day.setDate(startOfWeek.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
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);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
return checkDate >= start && checkDate <= end;
|
||||
};
|
||||
|
||||
const isDateUnavailable = (date: Date) => {
|
||||
return unavailablePeriods.some(period => {
|
||||
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);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
return checkDate >= start && checkDate <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const isDateFullyUnavailable = (date: Date) => {
|
||||
// Check if there's a period that covers the entire day without specific times
|
||||
const hasFullDayPeriod = unavailablePeriods.some(period => {
|
||||
const start = new Date(period.startDate);
|
||||
const end = new Date(period.endDate);
|
||||
const checkDate = new Date(date);
|
||||
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return checkDate >= start && checkDate <= end && !period.startTime && !period.endTime;
|
||||
});
|
||||
|
||||
if (hasFullDayPeriod) return true;
|
||||
|
||||
// Check if all 24 hours are covered by hour-specific periods
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const hoursWithPeriods = new Set<number>();
|
||||
|
||||
unavailablePeriods.forEach(period => {
|
||||
const periodDateStr = new Date(period.startDate).toISOString().split('T')[0];
|
||||
if (periodDateStr === dateStr && period.startTime && period.endTime) {
|
||||
const startHour = parseInt(period.startTime.split(':')[0]);
|
||||
const endHour = parseInt(period.endTime.split(':')[0]);
|
||||
for (let h = startHour; h <= endHour; h++) {
|
||||
hoursWithPeriods.add(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return hoursWithPeriods.size === 24;
|
||||
};
|
||||
|
||||
const isDatePartiallyUnavailable = (date: Date) => {
|
||||
return isDateUnavailable(date) && !isDateFullyUnavailable(date);
|
||||
};
|
||||
|
||||
const isHourUnavailable = (date: Date, hour: number) => {
|
||||
return unavailablePeriods.some(period => {
|
||||
const start = new Date(period.startDate);
|
||||
const end = new Date(period.endDate);
|
||||
|
||||
// Check if date is within period
|
||||
const dateOnly = new Date(date);
|
||||
dateOnly.setHours(0, 0, 0, 0);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dateOnly < start || dateOnly > end) return false;
|
||||
|
||||
// If no specific times, entire day is unavailable
|
||||
if (!period.startTime || !period.endTime) return true;
|
||||
|
||||
// Check specific hour
|
||||
const startHour = parseInt(period.startTime.split(':')[0]);
|
||||
const endHour = parseInt(period.endTime.split(':')[0]);
|
||||
|
||||
return hour >= startHour && hour <= endHour;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
// Check if this date has an accepted rental
|
||||
const hasAcceptedRental = unavailablePeriods.some(p =>
|
||||
p.isAcceptedRental && isDateInPeriod(date, p)
|
||||
);
|
||||
|
||||
if (hasAcceptedRental) {
|
||||
// Don't allow clicking on accepted rental dates
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRentalMode) {
|
||||
toggleDateAvailability(date);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
// Second click - create rental period
|
||||
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 hasUnavailable = false;
|
||||
while (currentDate <= end) {
|
||||
if (unavailablePeriods.some(p => !p.isRentalSelection && isDateInPeriod(currentDate, p))) {
|
||||
hasUnavailable = true;
|
||||
break;
|
||||
}
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
if (hasUnavailable) {
|
||||
// Range contains unavailable dates, reset selection
|
||||
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(),
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
isRentalSelection: true
|
||||
};
|
||||
onPeriodsChange([...nonRentalPeriods, newPeriod]);
|
||||
|
||||
// Reset selection
|
||||
setSelectionStart(null);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Check if date is already unavailable (not a rental selection)
|
||||
const isUnavailable = unavailablePeriods.some(p =>
|
||||
!p.isRentalSelection && isDateInPeriod(date, p)
|
||||
);
|
||||
|
||||
if (isUnavailable) {
|
||||
// Can't select unavailable dates
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingRentalPeriod) {
|
||||
// Remove the rental selection
|
||||
onPeriodsChange(unavailablePeriods.filter(p => p.id !== existingRentalPeriod.id));
|
||||
} else {
|
||||
// Add new rental selection
|
||||
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
|
||||
};
|
||||
onPeriodsChange([...unavailablePeriods, newPeriod]);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const renderMonthView = () => {
|
||||
const days = getDaysInMonth(currentDate);
|
||||
const monthName = currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6 className="text-center mb-3">{monthName}</h6>
|
||||
<div className="d-grid" style={{ gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="text-center fw-bold p-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{days.map((date, index) => {
|
||||
let className = 'p-2 text-center';
|
||||
let title = '';
|
||||
let backgroundColor = undefined;
|
||||
|
||||
if (date) {
|
||||
className += ' border cursor-pointer';
|
||||
|
||||
const rentalPeriod = unavailablePeriods.find(p =>
|
||||
p.isRentalSelection && isDateInPeriod(date, p)
|
||||
);
|
||||
|
||||
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()));
|
||||
|
||||
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 (isDateFullyUnavailable(date)) {
|
||||
className += ' bg-danger text-white';
|
||||
title = isRentalMode ? 'Unavailable' : 'Fully unavailable - Click to make available';
|
||||
} else if (isDatePartiallyUnavailable(date)) {
|
||||
className += ' text-dark';
|
||||
title = isRentalMode ? 'Partially unavailable' : 'Partially unavailable - Click to view details';
|
||||
backgroundColor = '#ffeb3b';
|
||||
} else {
|
||||
className += ' bg-light';
|
||||
title = isRentalMode ? 'Available - Click to select' : 'Available - Click to make unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={className}
|
||||
onClick={() => date && handleDateClick(date)}
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
cursor: date ? 'pointer' : 'default',
|
||||
backgroundColor: backgroundColor
|
||||
}}
|
||||
title={date ? title : ''}
|
||||
>
|
||||
{date?.getDate()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeekView = () => {
|
||||
const days = getWeekDays(currentDate);
|
||||
const weekRange = `${formatDate(days[0])} - ${formatDate(days[6])}`;
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6 className="text-center mb-3">{weekRange}</h6>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table table-bordered table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '60px' }}>Time</th>
|
||||
{days.map((date, index) => (
|
||||
<th key={index} className="text-center" style={{ minWidth: '100px' }}>
|
||||
<div>{date.toLocaleDateString('en-US', { weekday: 'short' })}</div>
|
||||
<div>{date.getDate()}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hours.map(hour => (
|
||||
<tr key={hour}>
|
||||
<td className="text-center small">
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</td>
|
||||
{days.map((date, dayIndex) => {
|
||||
const isUnavailable = isHourUnavailable(date, hour);
|
||||
|
||||
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'}
|
||||
>
|
||||
{isUnavailable && '×'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDayView = () => {
|
||||
const dayName = currentDate.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6 className="text-center mb-3">{dayName}</h6>
|
||||
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
||||
<table className="table table-bordered">
|
||||
<tbody>
|
||||
{hours.map(hour => {
|
||||
const isUnavailable = isHourUnavailable(currentDate, hour);
|
||||
|
||||
return (
|
||||
<tr key={hour}>
|
||||
<td className="text-center" style={{ width: '100px' }}>
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</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'}
|
||||
>
|
||||
{isUnavailable ? 'Unavailable' : 'Available'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const navigateDate = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentDate);
|
||||
|
||||
switch (viewType) {
|
||||
case 'month':
|
||||
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
|
||||
break;
|
||||
case 'week':
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
|
||||
break;
|
||||
case 'day':
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||
break;
|
||||
}
|
||||
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="availability-calendar">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<div className="btn-group" role="group">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${viewType === 'month' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setViewType('month')}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
{priceType === 'hour' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${viewType === 'week' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setViewType('week')}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${viewType === 'day' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setViewType('day')}
|
||||
>
|
||||
Day
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary me-2"
|
||||
onClick={() => navigateDate('prev')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => navigateDate('next')}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="calendar-view mb-3">
|
||||
{viewType === 'month' && renderMonthView()}
|
||||
{viewType === 'week' && renderWeekView()}
|
||||
{viewType === 'day' && renderDayView()}
|
||||
</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>
|
||||
)}
|
||||
{!isRentalMode && (
|
||||
<span><span className="badge text-white" style={{ backgroundColor: '#6f42c1' }}>□</span> Booked</span>
|
||||
)}
|
||||
<span><span className="badge text-dark" style={{ backgroundColor: '#ffeb3b' }}>□</span> Partially Unavailable</span>
|
||||
<span><span className="badge bg-danger">□</span> Fully Unavailable</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailabilityCalendar;
|
||||
81
frontend/src/components/InfoTooltip.tsx
Normal file
81
frontend/src/components/InfoTooltip.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface InfoTooltipProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const InfoTooltip: React.FC<InfoTooltipProps> = ({ text }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
tooltipRef.current &&
|
||||
!tooltipRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showTooltip) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showTooltip]);
|
||||
|
||||
return (
|
||||
<span className="position-relative">
|
||||
<span
|
||||
ref={buttonRef}
|
||||
className="text-muted ms-1"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowTooltip(!showTooltip);
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-info-circle"></i>
|
||||
</span>
|
||||
{showTooltip && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="position-absolute bg-dark text-white p-2 rounded"
|
||||
style={{
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '5px',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.875rem',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
<div
|
||||
className="position-absolute"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderTop: '5px solid var(--bs-dark)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoTooltip;
|
||||
110
frontend/src/components/Navbar.tsx
Normal file
110
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
|
||||
<div className="container">
|
||||
<Link className="navbar-brand fw-bold" to="/">
|
||||
<i className="bi bi-box-seam me-2"></i>
|
||||
Rentall
|
||||
</Link>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div className="collapse navbar-collapse" id="navbarNav">
|
||||
<ul className="navbar-nav me-auto">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/items">
|
||||
Browse Items
|
||||
</Link>
|
||||
</li>
|
||||
{user && (
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/create-item">
|
||||
List an Item
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<ul className="navbar-nav">
|
||||
{user ? (
|
||||
<>
|
||||
<li className="nav-item dropdown">
|
||||
<a
|
||||
className="nav-link dropdown-toggle"
|
||||
href="#"
|
||||
id="navbarDropdown"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i className="bi bi-person-circle me-1"></i>
|
||||
{user.firstName}
|
||||
</a>
|
||||
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/profile">
|
||||
<i className="bi bi-person me-2"></i>Profile
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/my-rentals">
|
||||
<i className="bi bi-calendar-check me-2"></i>My Rentals
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/my-listings">
|
||||
<i className="bi bi-list-ul me-2"></i>My Listings
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<hr className="dropdown-divider" />
|
||||
</li>
|
||||
<li>
|
||||
<button className="dropdown-item" onClick={handleLogout}>
|
||||
<i className="bi bi-box-arrow-right me-2"></i>Logout
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/login">
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="btn btn-primary btn-sm ms-2" to="/register">
|
||||
Sign Up
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
25
frontend/src/components/PrivateRoute.tsx
Normal file
25
frontend/src/components/PrivateRoute.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface PrivateRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '80vh' }}>
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? <>{children}</> : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
export default PrivateRoute;
|
||||
Reference in New Issue
Block a user