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:
31
frontend/src/App.css
Normal file
31
frontend/src/App.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-brand i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-nav .dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
84
frontend/src/App.tsx
Normal file
84
frontend/src/App.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import Navbar from './components/Navbar';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import ItemList from './pages/ItemList';
|
||||
import ItemDetail from './pages/ItemDetail';
|
||||
import EditItem from './pages/EditItem';
|
||||
import RentItem from './pages/RentItem';
|
||||
import CreateItem from './pages/CreateItem';
|
||||
import MyRentals from './pages/MyRentals';
|
||||
import MyListings from './pages/MyListings';
|
||||
import Profile from './pages/Profile';
|
||||
import PrivateRoute from './components/PrivateRoute';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/items" element={<ItemList />} />
|
||||
<Route path="/items/:id" element={<ItemDetail />} />
|
||||
<Route
|
||||
path="/items/:id/edit"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<EditItem />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/items/:id/rent"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RentItem />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/create-item"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CreateItem />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-rentals"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MyRentals />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-listings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MyListings />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Profile />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
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;
|
||||
76
frontend/src/contexts/AuthContext.tsx
Normal file
76
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
|
||||
import { User } from '../types';
|
||||
import { authAPI, userAPI } from '../services/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: any) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
userAPI.getProfile()
|
||||
.then(response => {
|
||||
setUser(response.data);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await authAPI.login({ email, password });
|
||||
localStorage.setItem('token', response.data.token);
|
||||
setUser(response.data.user);
|
||||
};
|
||||
|
||||
const register = async (data: any) => {
|
||||
const response = await authAPI.register(data);
|
||||
localStorage.setItem('token', response.data.token);
|
||||
setUser(response.data.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const updateUser = (user: User) => {
|
||||
setUser(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
20
frontend/src/index.tsx
Normal file
20
frontend/src/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
frontend/src/logo.svg
Normal file
1
frontend/src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
516
frontend/src/pages/CreateItem.tsx
Normal file
516
frontend/src/pages/CreateItem.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import api from "../services/api";
|
||||
import AvailabilityCalendar from "../components/AvailabilityCalendar";
|
||||
import AddressAutocomplete from "../components/AddressAutocomplete";
|
||||
|
||||
interface ItemFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
pickUpAvailable: boolean;
|
||||
localDeliveryAvailable: boolean;
|
||||
localDeliveryRadius?: number;
|
||||
shippingAvailable: boolean;
|
||||
inPlaceUseAvailable: boolean;
|
||||
pricePerHour?: number;
|
||||
pricePerDay?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rules?: string;
|
||||
minimumRentalDays: number;
|
||||
needsTraining: boolean;
|
||||
unavailablePeriods?: Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const CreateItem: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [formData, setFormData] = useState<ItemFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
pickUpAvailable: false,
|
||||
localDeliveryAvailable: false,
|
||||
localDeliveryRadius: 25,
|
||||
shippingAvailable: false,
|
||||
inPlaceUseAvailable: false,
|
||||
pricePerDay: undefined,
|
||||
replacementCost: 0,
|
||||
location: "",
|
||||
minimumRentalDays: 1,
|
||||
needsTraining: false,
|
||||
unavailablePeriods: [],
|
||||
});
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [priceType, setPriceType] = useState<"hour" | "day">("day");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) {
|
||||
setError("You must be logged in to create a listing");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// For now, we'll store image URLs as base64 strings
|
||||
// In production, you'd upload to a service like S3
|
||||
const imageUrls = imagePreviews;
|
||||
|
||||
const response = await api.post("/items", {
|
||||
...formData,
|
||||
images: imageUrls,
|
||||
});
|
||||
navigate(`/items/${response.data.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Failed to create listing");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === "checkbox") {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||
} else if (type === "number") {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value ? parseFloat(value) : undefined,
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagInput.trim()],
|
||||
}));
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter((t) => t !== tag),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
|
||||
// Limit to 5 images
|
||||
if (imageFiles.length + files.length > 5) {
|
||||
setError("You can upload a maximum of 5 images");
|
||||
return;
|
||||
}
|
||||
|
||||
const newImageFiles = [...imageFiles, ...files];
|
||||
setImageFiles(newImageFiles);
|
||||
|
||||
// Create previews
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImageFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1>List an Item for Rent</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Images (Max 5)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={handleImageChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={imageFiles.length >= 5}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Upload up to 5 images of your item
|
||||
</div>
|
||||
|
||||
{imagePreviews.length > 0 && (
|
||||
<div className="row mt-3">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
|
||||
<div className="position-relative">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="name" className="form-label">
|
||||
Item Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Tags</label>
|
||||
<div className="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addTag())
|
||||
}
|
||||
placeholder="Add a tag"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={addTag}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-primary me-2 mb-2">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
onClick={() => removeTag(tag)}
|
||||
style={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="location" className="form-label">
|
||||
Location *
|
||||
</label>
|
||||
<AddressAutocomplete
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={(value, lat, lon) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
location: value,
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}));
|
||||
}}
|
||||
placeholder="Address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Availability Type</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="pickUpAvailable"
|
||||
name="pickUpAvailable"
|
||||
checked={formData.pickUpAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="pickUpAvailable">
|
||||
Pick-Up
|
||||
<div className="small text-muted">They pick-up the item from your location and they return the item to your location</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="localDeliveryAvailable"
|
||||
name="localDeliveryAvailable"
|
||||
checked={formData.localDeliveryAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label d-flex align-items-center"
|
||||
htmlFor="localDeliveryAvailable"
|
||||
>
|
||||
<div>
|
||||
Local Delivery
|
||||
{formData.localDeliveryAvailable && (
|
||||
<span className="ms-2">
|
||||
(Delivery Radius:
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm d-inline-block mx-1"
|
||||
id="localDeliveryRadius"
|
||||
name="localDeliveryRadius"
|
||||
value={formData.localDeliveryRadius || ''}
|
||||
onChange={handleChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="25"
|
||||
min="1"
|
||||
max="100"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
miles)
|
||||
</span>
|
||||
)}
|
||||
<div className="small text-muted">You deliver and then pick-up the item when the rental period ends</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="shippingAvailable"
|
||||
name="shippingAvailable"
|
||||
checked={formData.shippingAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="shippingAvailable">
|
||||
Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="inPlaceUseAvailable"
|
||||
name="inPlaceUseAvailable"
|
||||
checked={formData.inPlaceUseAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="inPlaceUseAvailable"
|
||||
>
|
||||
In-Place Use
|
||||
<div className="small text-muted">They use at your location</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<label className="col-form-label">Price per</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
className="form-select"
|
||||
value={priceType}
|
||||
onChange={(e) => setPriceType(e.target.value as "hour" | "day")}
|
||||
>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
value={priceType === "hour" ? (formData.pricePerHour || "") : (formData.pricePerDay || "")}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="minimumRentalDays" className="form-label">
|
||||
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="minimumRentalDays"
|
||||
name="minimumRentalDays"
|
||||
value={formData.minimumRentalDays}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Availability Schedule</h5>
|
||||
<p className="text-muted">Select dates when the item is NOT available for rent</p>
|
||||
<AvailabilityCalendar
|
||||
unavailablePeriods={formData.unavailablePeriods || []}
|
||||
onPeriodsChange={(periods) =>
|
||||
setFormData(prev => ({ ...prev, unavailablePeriods: periods }))
|
||||
}
|
||||
priceType={priceType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="rules" className="form-label">
|
||||
Rental Rules & Guidelines
|
||||
</label>
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="needsTraining"
|
||||
name="needsTraining"
|
||||
checked={formData.needsTraining}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="needsTraining">
|
||||
Requires in-person training before rental
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="rules"
|
||||
name="rules"
|
||||
rows={3}
|
||||
value={formData.rules || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Any specific rules or guidelines for renting this item"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="replacementCost" className="form-label">
|
||||
Replacement Cost *
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="replacementCost"
|
||||
name="replacementCost"
|
||||
value={formData.replacementCost}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-text">
|
||||
The cost to replace the item if damaged or lost
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Creating..." : "Create Listing"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateItem;
|
||||
641
frontend/src/pages/EditItem.tsx
Normal file
641
frontend/src/pages/EditItem.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item, Rental } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||
|
||||
interface ItemFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
pickUpAvailable: boolean;
|
||||
localDeliveryAvailable: boolean;
|
||||
localDeliveryRadius?: number;
|
||||
shippingAvailable: boolean;
|
||||
inPlaceUseAvailable: boolean;
|
||||
pricePerHour?: number;
|
||||
pricePerDay?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rules?: string;
|
||||
minimumRentalDays: number;
|
||||
needsTraining: boolean;
|
||||
availability: boolean;
|
||||
unavailablePeriods?: Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const EditItem: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [priceType, setPriceType] = useState<"hour" | "day">("day");
|
||||
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
|
||||
const [formData, setFormData] = useState<ItemFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
pickUpAvailable: false,
|
||||
localDeliveryAvailable: false,
|
||||
shippingAvailable: false,
|
||||
inPlaceUseAvailable: false,
|
||||
pricePerHour: undefined,
|
||||
pricePerDay: undefined,
|
||||
replacementCost: 0,
|
||||
location: '',
|
||||
rules: '',
|
||||
minimumRentalDays: 1,
|
||||
needsTraining: false,
|
||||
availability: true,
|
||||
unavailablePeriods: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
fetchAcceptedRentals();
|
||||
}, [id]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
const item: Item = response.data;
|
||||
|
||||
if (item.ownerId !== user?.id) {
|
||||
setError('You are not authorized to edit this item');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the price type based on available pricing
|
||||
if (item.pricePerHour) {
|
||||
setPriceType('hour');
|
||||
} else if (item.pricePerDay) {
|
||||
setPriceType('day');
|
||||
}
|
||||
|
||||
// Convert item data to form data format
|
||||
setFormData({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tags: item.tags || [],
|
||||
pickUpAvailable: item.pickUpAvailable || false,
|
||||
localDeliveryAvailable: item.localDeliveryAvailable || false,
|
||||
localDeliveryRadius: item.localDeliveryRadius || 25,
|
||||
shippingAvailable: item.shippingAvailable || false,
|
||||
inPlaceUseAvailable: item.inPlaceUseAvailable || false,
|
||||
pricePerHour: item.pricePerHour,
|
||||
pricePerDay: item.pricePerDay,
|
||||
replacementCost: item.replacementCost,
|
||||
location: item.location,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
rules: item.rules || '',
|
||||
minimumRentalDays: item.minimumRentalDays,
|
||||
needsTraining: item.needsTraining || false,
|
||||
availability: item.availability,
|
||||
unavailablePeriods: item.unavailablePeriods || [],
|
||||
});
|
||||
|
||||
// Set existing images as previews
|
||||
if (item.images && item.images.length > 0) {
|
||||
setImagePreviews(item.images);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAcceptedRentals = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyListings();
|
||||
const rentals: Rental[] = response.data;
|
||||
// Filter for accepted rentals for this specific item
|
||||
const itemRentals = rentals.filter(rental =>
|
||||
rental.itemId === id &&
|
||||
['confirmed', 'active'].includes(rental.status)
|
||||
);
|
||||
setAcceptedRentals(itemRentals);
|
||||
} catch (err) {
|
||||
console.error('Error fetching rentals:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === "checkbox") {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||
} else if (type === "number") {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value ? parseFloat(value) : undefined,
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use existing image previews (which includes both old and new images)
|
||||
const imageUrls = imagePreviews;
|
||||
|
||||
await itemAPI.updateItem(id!, {
|
||||
...formData,
|
||||
images: imageUrls,
|
||||
isPortable: formData.pickUpAvailable || formData.shippingAvailable,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate(`/items/${id}`);
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to update item');
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagInput.trim()],
|
||||
}));
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter((t) => t !== tag),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
|
||||
// Limit to 5 images total
|
||||
if (imagePreviews.length + files.length > 5) {
|
||||
setError("You can upload a maximum of 5 images");
|
||||
return;
|
||||
}
|
||||
|
||||
const newImageFiles = [...imageFiles, ...files];
|
||||
setImageFiles(newImageFiles);
|
||||
|
||||
// Create previews
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && error.includes('authorized')) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1>Edit Listing</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success" role="alert">
|
||||
Item updated successfully! Redirecting...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Images (Max 5)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={handleImageChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={imagePreviews.length >= 5}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Upload up to 5 images of your item
|
||||
</div>
|
||||
|
||||
{imagePreviews.length > 0 && (
|
||||
<div className="row mt-3">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
|
||||
<div className="position-relative">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="name" className="form-label">
|
||||
Item Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Tags</label>
|
||||
<div className="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addTag())
|
||||
}
|
||||
placeholder="Add a tag"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={addTag}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-primary me-2 mb-2">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
onClick={() => removeTag(tag)}
|
||||
style={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="location" className="form-label">
|
||||
Location *
|
||||
</label>
|
||||
<AddressAutocomplete
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={(value, lat, lon) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
location: value,
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}));
|
||||
}}
|
||||
placeholder="Address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Availability Type</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="pickUpAvailable"
|
||||
name="pickUpAvailable"
|
||||
checked={formData.pickUpAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="pickUpAvailable">
|
||||
Pick-Up
|
||||
<div className="small text-muted">They pick-up the item from your location and they return the item to your location</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="localDeliveryAvailable"
|
||||
name="localDeliveryAvailable"
|
||||
checked={formData.localDeliveryAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label d-flex align-items-center"
|
||||
htmlFor="localDeliveryAvailable"
|
||||
>
|
||||
<div>
|
||||
Local Delivery
|
||||
{formData.localDeliveryAvailable && (
|
||||
<span className="ms-2">
|
||||
(Delivery Radius:
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm d-inline-block mx-1"
|
||||
id="localDeliveryRadius"
|
||||
name="localDeliveryRadius"
|
||||
value={formData.localDeliveryRadius || ''}
|
||||
onChange={handleChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="25"
|
||||
min="1"
|
||||
max="100"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
miles)
|
||||
</span>
|
||||
)}
|
||||
<div className="small text-muted">You deliver and then pick-up the item when the rental period ends</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="shippingAvailable"
|
||||
name="shippingAvailable"
|
||||
checked={formData.shippingAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="shippingAvailable">
|
||||
Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="inPlaceUseAvailable"
|
||||
name="inPlaceUseAvailable"
|
||||
checked={formData.inPlaceUseAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="inPlaceUseAvailable"
|
||||
>
|
||||
In-Place Use
|
||||
<div className="small text-muted">They use at your location</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<label className="col-form-label">Price per</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
className="form-select"
|
||||
value={priceType}
|
||||
onChange={(e) => setPriceType(e.target.value as "hour" | "day")}
|
||||
>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
value={priceType === "hour" ? (formData.pricePerHour || "") : (formData.pricePerDay || "")}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="minimumRentalDays" className="form-label">
|
||||
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="minimumRentalDays"
|
||||
name="minimumRentalDays"
|
||||
value={formData.minimumRentalDays}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Availability Schedule</h5>
|
||||
<p className="text-muted">Select dates when the item is NOT available for rent. Dates with accepted rentals are shown in purple.</p>
|
||||
<AvailabilityCalendar
|
||||
unavailablePeriods={[
|
||||
...(formData.unavailablePeriods || []),
|
||||
...acceptedRentals.map(rental => ({
|
||||
id: `rental-${rental.id}`,
|
||||
startDate: new Date(rental.startDate),
|
||||
endDate: new Date(rental.endDate),
|
||||
isAcceptedRental: true
|
||||
}))
|
||||
]}
|
||||
onPeriodsChange={(periods) => {
|
||||
// Filter out accepted rental periods when saving
|
||||
const userPeriods = periods.filter(p => !p.isAcceptedRental);
|
||||
setFormData(prev => ({ ...prev, unavailablePeriods: userPeriods }));
|
||||
}}
|
||||
priceType={priceType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="rules" className="form-label">
|
||||
Rental Rules & Guidelines
|
||||
</label>
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="needsTraining"
|
||||
name="needsTraining"
|
||||
checked={formData.needsTraining}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="needsTraining">
|
||||
Requires in-person training before rental
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="rules"
|
||||
name="rules"
|
||||
rows={3}
|
||||
value={formData.rules || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Any specific rules or guidelines for renting this item"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="replacementCost" className="form-label">
|
||||
Replacement Cost *
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="replacementCost"
|
||||
name="replacementCost"
|
||||
value={formData.replacementCost}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-text">
|
||||
The cost to replace the item if damaged or lost
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="availability"
|
||||
name="availability"
|
||||
checked={formData.availability}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="availability">
|
||||
Available for rent
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Updating..." : "Update Listing"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditItem;
|
||||
137
frontend/src/pages/Home.tsx
Normal file
137
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="py-5 bg-light">
|
||||
<div className="container">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-lg-6">
|
||||
<h1 className="display-4 fw-bold mb-4">
|
||||
Rent Equipment from Your Neighbors
|
||||
</h1>
|
||||
<p className="lead mb-4">
|
||||
Why buy when you can rent? Find gym equipment, tools, and musical instruments
|
||||
available for rent in your area. Save money and space while getting access to
|
||||
everything you need.
|
||||
</p>
|
||||
<div className="d-flex gap-3">
|
||||
<Link to="/items" className="btn btn-primary btn-lg">
|
||||
Browse Items
|
||||
</Link>
|
||||
{user ? (
|
||||
<Link to="/create-item" className="btn btn-outline-primary btn-lg">
|
||||
List Your Item
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/register" className="btn btn-outline-primary btn-lg">
|
||||
Start Renting
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600"
|
||||
alt="Equipment rental"
|
||||
className="img-fluid rounded shadow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-5">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-5">Popular Categories</h2>
|
||||
<div className="row g-4">
|
||||
<div className="col-md-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div className="card-body text-center">
|
||||
<i className="bi bi-tools display-3 text-primary mb-3"></i>
|
||||
<h4>Tools</h4>
|
||||
<p className="text-muted">
|
||||
Power tools, hand tools, and equipment for your DIY projects
|
||||
</p>
|
||||
<Link to="/items?tags=tools" className="btn btn-sm btn-outline-primary">
|
||||
Browse Tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div className="card-body text-center">
|
||||
<i className="bi bi-heart-pulse display-3 text-primary mb-3"></i>
|
||||
<h4>Gym Equipment</h4>
|
||||
<p className="text-muted">
|
||||
Weights, machines, and fitness gear for your workout needs
|
||||
</p>
|
||||
<Link to="/items?tags=gym" className="btn btn-sm btn-outline-primary">
|
||||
Browse Gym Equipment
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div className="card-body text-center">
|
||||
<i className="bi bi-music-note-beamed display-3 text-primary mb-3"></i>
|
||||
<h4>Musical Instruments</h4>
|
||||
<p className="text-muted">
|
||||
Guitars, keyboards, drums, and more for musicians
|
||||
</p>
|
||||
<Link to="/items?tags=music" className="btn btn-sm btn-outline-primary">
|
||||
Browse Instruments
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-5 bg-light">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-5">How It Works</h2>
|
||||
<div className="row g-4">
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">1</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Search</h5>
|
||||
<p className="text-muted">Find the equipment you need in your area</p>
|
||||
</div>
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">2</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Book</h5>
|
||||
<p className="text-muted">Reserve items for the dates you need</p>
|
||||
</div>
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">3</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Pick Up</h5>
|
||||
<p className="text-muted">Collect items or have them delivered</p>
|
||||
</div>
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">4</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Return</h5>
|
||||
<p className="text-muted">Return items when you're done</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
204
frontend/src/pages/ItemDetail.tsx
Normal file
204
frontend/src/pages/ItemDetail.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item, Rental } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
|
||||
const ItemDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [item, setItem] = useState<Item | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
if (user) {
|
||||
checkIfAlreadyRenting();
|
||||
}
|
||||
}, [id, user]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
setItem(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfAlreadyRenting = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyRentals();
|
||||
const rentals: Rental[] = response.data;
|
||||
// Check if user has an active rental for this item
|
||||
const hasActiveRental = rentals.some(rental =>
|
||||
rental.item?.id === id &&
|
||||
['pending', 'confirmed', 'active'].includes(rental.status)
|
||||
);
|
||||
setIsAlreadyRenting(hasActiveRental);
|
||||
} catch (err) {
|
||||
console.error('Failed to check rental status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
navigate(`/items/${id}/edit`);
|
||||
};
|
||||
|
||||
const handleRent = () => {
|
||||
navigate(`/items/${id}/rent`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !item) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = user?.id === item.ownerId;
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-10">
|
||||
{item.images.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={item.images[selectedImage]}
|
||||
alt={item.name}
|
||||
className="img-fluid rounded mb-3"
|
||||
style={{ width: '100%', maxHeight: '500px', objectFit: 'cover' }}
|
||||
/>
|
||||
{item.images.length > 1 && (
|
||||
<div className="d-flex gap-2 overflow-auto justify-content-center">
|
||||
{item.images.map((image, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={image}
|
||||
alt={`${item.name} ${index + 1}`}
|
||||
className={`rounded cursor-pointer ${selectedImage === index ? 'border border-primary' : ''}`}
|
||||
style={{ width: '80px', height: '80px', objectFit: 'cover', cursor: 'pointer' }}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-light rounded d-flex align-items-center justify-content-center mb-4" style={{ height: '400px' }}>
|
||||
<span className="text-muted">No image available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<h1>{item.name}</h1>
|
||||
<p className="text-muted">{item.location}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
{item.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-secondary me-2">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Description</h5>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Pricing</h5>
|
||||
<div className="row">
|
||||
{item.pricePerHour && (
|
||||
<div className="col-6">
|
||||
<strong>Per Hour:</strong> ${item.pricePerHour}
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerDay && (
|
||||
<div className="col-6">
|
||||
<strong>Per Day:</strong> ${item.pricePerDay}
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerWeek && (
|
||||
<div className="col-6">
|
||||
<strong>Per Week:</strong> ${item.pricePerWeek}
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerMonth && (
|
||||
<div className="col-6">
|
||||
<strong>Per Month:</strong> ${item.pricePerMonth}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Details</h5>
|
||||
<p><strong>Replacement Cost:</strong> ${item.replacementCost}</p>
|
||||
{item.minimumRentalDays && (
|
||||
<p><strong>Minimum Rental:</strong> {item.minimumRentalDays} days</p>
|
||||
)}
|
||||
{item.maximumRentalDays && (
|
||||
<p><strong>Maximum Rental:</strong> {item.maximumRentalDays} days</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.rules && (
|
||||
<div className="mb-4">
|
||||
<h5>Rules</h5>
|
||||
<p>{item.rules}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
{isOwner ? (
|
||||
<button className="btn btn-primary" onClick={handleEdit}>
|
||||
Edit Listing
|
||||
</button>
|
||||
) : (
|
||||
item.availability && !isAlreadyRenting && (
|
||||
<button className="btn btn-primary" onClick={handleRent}>
|
||||
Rent This Item
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{!isOwner && isAlreadyRenting && (
|
||||
<button className="btn btn-success" disabled style={{ opacity: 0.8 }}>
|
||||
✓ Renting
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemDetail;
|
||||
164
frontend/src/pages/ItemList.tsx
Normal file
164
frontend/src/pages/ItemList.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { itemAPI } from '../services/api';
|
||||
|
||||
const ItemList: React.FC = () => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterTag, setFilterTag] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItems();
|
||||
console.log('API Response:', response);
|
||||
// Access the items array from response.data.items
|
||||
const allItems = response.data.items || response.data || [];
|
||||
// Filter only available items
|
||||
const availableItems = allItems.filter((item: Item) => item.availability);
|
||||
setItems(availableItems);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching items:', err);
|
||||
console.error('Error response:', err.response);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique tags from all items
|
||||
const allTags = Array.from(new Set(items.flatMap(item => item.tags || [])));
|
||||
|
||||
// Filter items based on search term and selected tag
|
||||
const filteredItems = items.filter(item => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTag = filterTag === '' || (item.tags && item.tags.includes(filterTag));
|
||||
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>Browse Items</h1>
|
||||
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-6">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search items..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(e.target.value)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{allTags.map(tag => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<span className="text-muted">{filteredItems.length} items found</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<p className="text-center text-muted">No items available for rent.</p>
|
||||
) : (
|
||||
<div className="row">
|
||||
{filteredItems.map((item) => (
|
||||
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<Link
|
||||
to={`/items/${item.id}`}
|
||||
className="text-decoration-none"
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||
{item.images && item.images[0] && (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h5 className="card-title text-dark">
|
||||
{item.name}
|
||||
</h5>
|
||||
<p className="card-text text-truncate text-dark">{item.description}</p>
|
||||
|
||||
<div className="mb-2">
|
||||
{item.tags && item.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-secondary me-1">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
{item.pricePerDay && (
|
||||
<div className="text-primary">
|
||||
<strong>${item.pricePerDay}/day</strong>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerHour && (
|
||||
<div className="text-primary">
|
||||
<strong>${item.pricePerHour}/hour</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-muted small">
|
||||
<i className="bi bi-geo-alt"></i> {item.location}
|
||||
</div>
|
||||
|
||||
{item.owner && (
|
||||
<small className="text-muted">by {item.owner.firstName} {item.owner.lastName}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemList;
|
||||
91
frontend/src/pages/Login.tsx
Normal file
91
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-5">
|
||||
<div className="card shadow">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="text-center mb-4">Login</h2>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-3">
|
||||
<p className="mb-0">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-decoration-none">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
295
frontend/src/pages/MyListings.tsx
Normal file
295
frontend/src/pages/MyListings.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import api from '../services/api';
|
||||
import { Item, Rental } from '../types';
|
||||
import { rentalAPI } from '../services/api';
|
||||
|
||||
const MyListings: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [listings, setListings] = useState<Item[]>([]);
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyListings();
|
||||
fetchRentalRequests();
|
||||
}, [user]);
|
||||
|
||||
const fetchMyListings = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(''); // Clear any previous errors
|
||||
const response = await api.get('/items');
|
||||
|
||||
// Filter items to only show ones owned by current user
|
||||
const myItems = response.data.items.filter((item: Item) => item.ownerId === user.id);
|
||||
setListings(myItems);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching listings:', err);
|
||||
// Only show error for actual API failures
|
||||
if (err.response && err.response.status >= 500) {
|
||||
setError('Failed to get your listings. Please try again later.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this listing?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/${itemId}`);
|
||||
setListings(listings.filter(item => item.id !== itemId));
|
||||
} catch (err: any) {
|
||||
alert('Failed to delete listing');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAvailability = async (item: Item) => {
|
||||
try {
|
||||
await api.put(`/items/${item.id}`, {
|
||||
...item,
|
||||
availability: !item.availability
|
||||
});
|
||||
setListings(listings.map(i =>
|
||||
i.id === item.id ? { ...i, availability: !i.availability } : i
|
||||
));
|
||||
} catch (err: any) {
|
||||
alert('Failed to update availability');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRentalRequests = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const response = await rentalAPI.getMyListings();
|
||||
setRentals(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching rental requests:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptRental = async (rentalId: string) => {
|
||||
try {
|
||||
await rentalAPI.updateRentalStatus(rentalId, 'confirmed');
|
||||
// Refresh the rentals list
|
||||
fetchRentalRequests();
|
||||
} catch (err) {
|
||||
console.error('Failed to accept rental request:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectRental = async (rentalId: string) => {
|
||||
try {
|
||||
await api.put(`/rentals/${rentalId}/status`, {
|
||||
status: 'cancelled',
|
||||
rejectionReason: 'Request declined by owner'
|
||||
});
|
||||
// Refresh the rentals list
|
||||
fetchRentalRequests();
|
||||
} catch (err) {
|
||||
console.error('Failed to reject rental request:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>My Listings</h1>
|
||||
<Link to="/create-item" className="btn btn-primary">
|
||||
Add New Item
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const pendingCount = rentals.filter(r => r.status === 'pending').length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
return (
|
||||
<div className="alert alert-info d-flex align-items-center" role="alert">
|
||||
<i className="bi bi-bell-fill me-2"></i>
|
||||
You have {pendingCount} pending rental request{pendingCount > 1 ? 's' : ''} to review.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{listings.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<p className="text-muted">You haven't listed any items yet.</p>
|
||||
<Link to="/create-item" className="btn btn-primary mt-3">
|
||||
List Your First Item
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{listings.map((item) => (
|
||||
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<Link
|
||||
to={`/items/${item.id}/edit`}
|
||||
className="text-decoration-none"
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button') || target.closest('.rental-requests')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||
{item.images && item.images[0] && (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h5 className="card-title text-dark">
|
||||
{item.name}
|
||||
</h5>
|
||||
<p className="card-text text-truncate text-dark">{item.description}</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<span className={`badge ${item.availability ? 'bg-success' : 'bg-secondary'}`}>
|
||||
{item.availability ? 'Available' : 'Not Available'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
{item.pricePerDay && (
|
||||
<div className="text-muted small">
|
||||
${item.pricePerDay}/day
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerHour && (
|
||||
<div className="text-muted small">
|
||||
${item.pricePerHour}/hour
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
onClick={() => toggleAvailability(item)}
|
||||
className="btn btn-sm btn-outline-info"
|
||||
>
|
||||
{item.availability ? 'Mark Unavailable' : 'Mark Available'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const pendingRentals = rentals.filter(r =>
|
||||
r.itemId === item.id && r.status === 'pending'
|
||||
);
|
||||
const acceptedRentals = rentals.filter(r =>
|
||||
r.itemId === item.id && ['confirmed', 'active'].includes(r.status)
|
||||
);
|
||||
|
||||
if (pendingRentals.length > 0 || acceptedRentals.length > 0) {
|
||||
return (
|
||||
<div className="mt-3 border-top pt-3 rental-requests">
|
||||
{pendingRentals.length > 0 && (
|
||||
<>
|
||||
<h6 className="text-primary mb-2">
|
||||
<i className="bi bi-bell-fill"></i> Pending Requests ({pendingRentals.length})
|
||||
</h6>
|
||||
{pendingRentals.map(rental => (
|
||||
<div key={rental.id} className="border rounded p-2 mb-2 bg-light">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div className="small">
|
||||
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong><br/>
|
||||
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}<br/>
|
||||
<span className="text-muted">${rental.totalAmount}</span>
|
||||
</div>
|
||||
<div className="d-flex gap-1">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleAcceptRental(rental.id)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRejectRental(rental.id)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{acceptedRentals.length > 0 && (
|
||||
<>
|
||||
<h6 className="text-success mb-2 mt-3">
|
||||
<i className="bi bi-check-circle-fill"></i> Accepted Rentals ({acceptedRentals.length})
|
||||
</h6>
|
||||
{acceptedRentals.map(rental => (
|
||||
<div key={rental.id} className="border rounded p-2 mb-2 bg-light">
|
||||
<div className="small">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong><br/>
|
||||
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}<br/>
|
||||
<span className="text-muted">${rental.totalAmount}</span>
|
||||
</div>
|
||||
<span className={`badge ${rental.status === 'active' ? 'bg-success' : 'bg-info'}`}>
|
||||
{rental.status === 'active' ? 'Active' : 'Confirmed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyListings;
|
||||
200
frontend/src/pages/MyRentals.tsx
Normal file
200
frontend/src/pages/MyRentals.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { rentalAPI } from '../services/api';
|
||||
import { Rental } from '../types';
|
||||
|
||||
const MyRentals: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'past'>('active');
|
||||
|
||||
useEffect(() => {
|
||||
fetchRentals();
|
||||
}, []);
|
||||
|
||||
const fetchRentals = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyRentals();
|
||||
setRentals(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch rentals');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRental = async (rentalId: string) => {
|
||||
if (!window.confirm('Are you sure you want to cancel this rental?')) return;
|
||||
|
||||
try {
|
||||
await rentalAPI.updateRentalStatus(rentalId, 'cancelled');
|
||||
fetchRentals(); // Refresh the list
|
||||
} catch (err: any) {
|
||||
alert('Failed to cancel rental');
|
||||
}
|
||||
};
|
||||
|
||||
// Filter rentals based on status
|
||||
const activeRentals = rentals.filter(r =>
|
||||
['pending', 'confirmed', 'active'].includes(r.status)
|
||||
);
|
||||
const pastRentals = rentals.filter(r =>
|
||||
['completed', 'cancelled'].includes(r.status)
|
||||
);
|
||||
|
||||
const displayedRentals = activeTab === 'active' ? activeRentals : pastRentals;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>My Rentals</h1>
|
||||
|
||||
<ul className="nav nav-tabs mb-4">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'active' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('active')}
|
||||
>
|
||||
Active Rentals ({activeRentals.length})
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'past' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('past')}
|
||||
>
|
||||
Past Rentals ({pastRentals.length})
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{displayedRentals.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<p className="text-muted">
|
||||
{activeTab === 'active'
|
||||
? "You don't have any active rentals."
|
||||
: "You don't have any past rentals."}
|
||||
</p>
|
||||
<Link to="/items" className="btn btn-primary mt-3">
|
||||
Browse Items to Rent
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{displayedRentals.map((rental) => (
|
||||
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<Link
|
||||
to={rental.item ? `/items/${rental.item.id}` : '#'}
|
||||
className="text-decoration-none"
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!rental.item || target.closest('button')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: rental.item ? 'pointer' : 'default' }}>
|
||||
{rental.item?.images && rental.item.images[0] && (
|
||||
<img
|
||||
src={rental.item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={rental.item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h5 className="card-title text-dark">
|
||||
{rental.item ? rental.item.name : 'Item Unavailable'}
|
||||
</h5>
|
||||
|
||||
<div className="mb-2">
|
||||
<span className={`badge ${
|
||||
rental.status === 'active' ? 'bg-success' :
|
||||
rental.status === 'pending' ? 'bg-warning' :
|
||||
rental.status === 'confirmed' ? 'bg-info' :
|
||||
rental.status === 'completed' ? 'bg-secondary' :
|
||||
'bg-danger'
|
||||
}`}>
|
||||
{rental.status.charAt(0).toUpperCase() + rental.status.slice(1)}
|
||||
</span>
|
||||
{rental.paymentStatus === 'paid' && (
|
||||
<span className="badge bg-success ms-2">Paid</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Rental Period:</strong><br />
|
||||
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Total:</strong> ${rental.totalAmount}
|
||||
</p>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Delivery:</strong> {rental.deliveryMethod === 'pickup' ? 'Pick-up' : 'Delivery'}
|
||||
</p>
|
||||
|
||||
{rental.owner && (
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Owner:</strong> {rental.owner.firstName} {rental.owner.lastName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rental.status === 'cancelled' && rental.rejectionReason && (
|
||||
<div className="alert alert-warning mt-2 mb-1 p-2 small">
|
||||
<strong>Rejection reason:</strong> {rental.rejectionReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
{rental.status === 'pending' && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => cancelRental(rental.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{rental.status === 'completed' && !rental.rating && (
|
||||
<button className="btn btn-sm btn-primary">
|
||||
Leave Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyRentals;
|
||||
381
frontend/src/pages/Profile.tsx
Normal file
381
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { userAPI, itemAPI, rentalAPI } from '../services/api';
|
||||
import { User, Item, Rental } from '../types';
|
||||
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||
|
||||
const Profile: React.FC = () => {
|
||||
const { user, updateUser, logout } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [profileData, setProfileData] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
profileImage: ''
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState({
|
||||
itemsListed: 0,
|
||||
acceptedRentals: 0,
|
||||
totalRentals: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await userAPI.getProfile();
|
||||
setProfileData(response.data);
|
||||
setFormData({
|
||||
firstName: response.data.firstName || '',
|
||||
lastName: response.data.lastName || '',
|
||||
email: response.data.email || '',
|
||||
phone: response.data.phone || '',
|
||||
address: response.data.address || '',
|
||||
profileImage: response.data.profileImage || ''
|
||||
});
|
||||
if (response.data.profileImage) {
|
||||
setImagePreview(response.data.profileImage);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Fetch user's items
|
||||
const itemsResponse = await itemAPI.getItems();
|
||||
const allItems = itemsResponse.data.items || itemsResponse.data || [];
|
||||
const myItems = allItems.filter((item: Item) => item.ownerId === user?.id);
|
||||
|
||||
// Fetch rentals where user is the owner (rentals on user's items)
|
||||
const ownerRentalsResponse = await rentalAPI.getMyListings();
|
||||
const ownerRentals: Rental[] = ownerRentalsResponse.data;
|
||||
|
||||
const acceptedRentals = ownerRentals.filter(r => ['confirmed', 'active'].includes(r.status));
|
||||
|
||||
setStats({
|
||||
itemsListed: myItems.length,
|
||||
acceptedRentals: acceptedRentals.length,
|
||||
totalRentals: ownerRentals.length
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
...formData,
|
||||
profileImage: imagePreview || formData.profileImage
|
||||
};
|
||||
|
||||
const response = await userAPI.updateProfile(updateData);
|
||||
setProfileData(response.data);
|
||||
updateUser(response.data); // Update the auth context
|
||||
setSuccess('Profile updated successfully!');
|
||||
setEditing(false);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to update profile');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
// Reset form to original data
|
||||
if (profileData) {
|
||||
setFormData({
|
||||
firstName: profileData.firstName || '',
|
||||
lastName: profileData.lastName || '',
|
||||
email: profileData.email || '',
|
||||
phone: profileData.phone || '',
|
||||
address: profileData.address || '',
|
||||
profileImage: profileData.profileImage || ''
|
||||
});
|
||||
setImagePreview(profileData.profileImage || null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1 className="mb-4">My Profile</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success" role="alert">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="text-center mb-4">
|
||||
<div className="position-relative d-inline-block">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="rounded-circle"
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||
style={{ width: '150px', height: '150px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<label
|
||||
htmlFor="profileImage"
|
||||
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
||||
style={{ width: '40px', height: '40px', padding: '0' }}
|
||||
>
|
||||
<i className="bi bi-camera-fill"></i>
|
||||
<input
|
||||
type="file"
|
||||
id="profileImage"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="d-none"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<h5 className="mt-3">{profileData?.firstName} {profileData?.lastName}</h5>
|
||||
<p className="text-muted">@{profileData?.username}</p>
|
||||
{profileData?.isVerified && (
|
||||
<span className="badge bg-success">
|
||||
<i className="bi bi-check-circle-fill"></i> Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="firstName" className="form-label">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
disabled={!editing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="lastName" className="form-label">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
disabled={!editing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={!editing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="phone" className="form-label">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
disabled={!editing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="address" className="form-label">Address</label>
|
||||
{editing ? (
|
||||
<AddressAutocomplete
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, address: value }));
|
||||
}}
|
||||
placeholder="Enter your address"
|
||||
required={false}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Account Statistics</h5>
|
||||
<div className="row text-center">
|
||||
<div className="col-md-4">
|
||||
<h3 className="text-primary">{stats.itemsListed}</h3>
|
||||
<p className="text-muted">Items Listed</p>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<h3 className="text-success">{stats.acceptedRentals}</h3>
|
||||
<p className="text-muted">Accepted Rentals</p>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<h3 className="text-info">{stats.totalRentals}</h3>
|
||||
<p className="text-muted">Total Rentals</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Account Settings</h5>
|
||||
<div className="list-group list-group-flush">
|
||||
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i className="bi bi-bell me-2"></i>
|
||||
Notification Settings
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i className="bi bi-shield-lock me-2"></i>
|
||||
Privacy & Security
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i className="bi bi-credit-card me-2"></i>
|
||||
Payment Methods
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-danger border-0 w-100 text-start"
|
||||
>
|
||||
<div>
|
||||
<i className="bi bi-box-arrow-right me-2"></i>
|
||||
Log Out
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
163
frontend/src/pages/Register.tsx
Normal file
163
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(formData);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to create account');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-5">
|
||||
<div className="card shadow">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="text-center mb-4">Create Account</h2>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="firstName" className="form-label">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="lastName" className="form-label">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="phone" className="form-label">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-3">
|
||||
<p className="mb-0">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-decoration-none">
|
||||
Login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
470
frontend/src/pages/RentItem.tsx
Normal file
470
frontend/src/pages/RentItem.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||
|
||||
const RentItem: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [item, setItem] = useState<Item | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
deliveryMethod: 'pickup' as 'pickup' | 'delivery',
|
||||
deliveryAddress: '',
|
||||
cardNumber: '',
|
||||
cardExpiry: '',
|
||||
cardCVC: '',
|
||||
cardName: ''
|
||||
});
|
||||
|
||||
const [selectedPeriods, setSelectedPeriods] = useState<Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>>([]);
|
||||
|
||||
const [totalCost, setTotalCost] = useState(0);
|
||||
const [rentalDuration, setRentalDuration] = useState({ days: 0, hours: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateTotal();
|
||||
}, [selectedPeriods, item]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
setItem(response.data);
|
||||
|
||||
// Check if item is available
|
||||
if (!response.data.availability) {
|
||||
setError('This item is not available for rent');
|
||||
}
|
||||
|
||||
// Check if user is trying to rent their own item
|
||||
if (response.data.ownerId === user?.id) {
|
||||
setError('You cannot rent your own item');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
if (!item || selectedPeriods.length === 0) {
|
||||
setTotalCost(0);
|
||||
setRentalDuration({ days: 0, hours: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, we'll use the first selected period
|
||||
const period = selectedPeriods[0];
|
||||
const start = new Date(period.startDate);
|
||||
const end = new Date(period.endDate);
|
||||
|
||||
// Add time if hourly rental
|
||||
if (item.pricePerHour && period.startTime && period.endTime) {
|
||||
const [startHour, startMin] = period.startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = period.endTime.split(':').map(Number);
|
||||
start.setHours(startHour, startMin);
|
||||
end.setHours(endHour, endMin);
|
||||
}
|
||||
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
let cost = 0;
|
||||
let duration = { days: 0, hours: 0 };
|
||||
|
||||
if (item.pricePerHour && period.startTime && period.endTime) {
|
||||
// Hourly rental
|
||||
cost = diffHours * item.pricePerHour;
|
||||
duration.hours = diffHours;
|
||||
} else if (item.pricePerDay) {
|
||||
// Daily rental
|
||||
cost = diffDays * item.pricePerDay;
|
||||
duration.days = diffDays;
|
||||
}
|
||||
|
||||
setTotalCost(cost);
|
||||
setRentalDuration(duration);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user || !item) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (selectedPeriods.length === 0) {
|
||||
setError('Please select a rental period');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const period = selectedPeriods[0];
|
||||
const rentalData = {
|
||||
itemId: item.id,
|
||||
startDate: period.startDate.toISOString().split('T')[0],
|
||||
endDate: period.endDate.toISOString().split('T')[0],
|
||||
startTime: period.startTime || undefined,
|
||||
endTime: period.endTime || undefined,
|
||||
totalAmount: totalCost,
|
||||
deliveryMethod: formData.deliveryMethod,
|
||||
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
|
||||
};
|
||||
|
||||
await rentalAPI.createRental(rentalData);
|
||||
navigate('/my-rentals');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to create rental');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'cardNumber') {
|
||||
// Remove all non-digits
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
|
||||
// Add spaces every 4 digits
|
||||
const formatted = cleaned.match(/.{1,4}/g)?.join(' ') || cleaned;
|
||||
|
||||
// Limit to 16 digits (19 characters with spaces)
|
||||
if (cleaned.length <= 16) {
|
||||
setFormData(prev => ({ ...prev, [name]: formatted }));
|
||||
}
|
||||
} else if (name === 'cardExpiry') {
|
||||
// Remove all non-digits
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
|
||||
// Add slash after 2 digits
|
||||
let formatted = cleaned;
|
||||
if (cleaned.length >= 3) {
|
||||
formatted = cleaned.slice(0, 2) + '/' + cleaned.slice(2, 4);
|
||||
}
|
||||
|
||||
// Limit to 4 digits
|
||||
if (cleaned.length <= 4) {
|
||||
setFormData(prev => ({ ...prev, [name]: formatted }));
|
||||
}
|
||||
} else if (name === 'cardCVC') {
|
||||
// Only allow digits and limit to 4
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 4) {
|
||||
setFormData(prev => ({ ...prev, [name]: cleaned }));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item || error === 'You cannot rent your own item' || error === 'This item is not available for rent') {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showHourlyOptions = !!item.pricePerHour;
|
||||
const minDays = item.minimumRentalDays || 1;
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<h1>Rent: {item.name}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<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;
|
||||
}));
|
||||
}}
|
||||
priceType={showHourlyOptions ? "hour" : "day"}
|
||||
isRentalMode={true}
|
||||
/>
|
||||
|
||||
{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>
|
||||
<select
|
||||
className="form-select"
|
||||
id="deliveryMethod"
|
||||
name="deliveryMethod"
|
||||
value={formData.deliveryMethod}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{item.pickUpAvailable && <option value="pickup">Pick-up</option>}
|
||||
{item.localDeliveryAvailable && <option value="delivery">Delivery</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.deliveryMethod === 'delivery' && (
|
||||
<div className="mb-3">
|
||||
<label htmlFor="deliveryAddress" className="form-label">Delivery Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="deliveryAddress"
|
||||
name="deliveryAddress"
|
||||
value={formData.deliveryAddress}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter delivery address"
|
||||
required={formData.deliveryMethod === 'delivery'}
|
||||
/>
|
||||
{item.localDeliveryRadius && (
|
||||
<div className="form-text">
|
||||
Delivery available within {item.localDeliveryRadius} miles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Payment</h5>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Payment Method *</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="paymentMethod"
|
||||
id="creditCard"
|
||||
value="creditCard"
|
||||
checked
|
||||
readOnly
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="creditCard">
|
||||
Credit/Debit Card
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-12">
|
||||
<label htmlFor="cardNumber" className="form-label">Card Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardNumber"
|
||||
name="cardNumber"
|
||||
value={formData.cardNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardExpiry" className="form-label">Expiry Date *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardExpiry"
|
||||
name="cardExpiry"
|
||||
value={formData.cardExpiry}
|
||||
onChange={handleChange}
|
||||
placeholder="MM/YY"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardCVC" className="form-label">CVC *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardCVC"
|
||||
name="cardCVC"
|
||||
value={formData.cardCVC}
|
||||
onChange={handleChange}
|
||||
placeholder="123"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="cardName" className="form-label">Name on Card *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardName"
|
||||
name="cardName"
|
||||
value={formData.cardName}
|
||||
onChange={handleChange}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-info small">
|
||||
<i className="bi bi-info-circle"></i> Your payment information is secure and encrypted. You will only be charged after the owner accepts your rental request.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || selectedPeriods.length === 0 || totalCost === 0 || (rentalDuration.days < minDays && !showHourlyOptions)}
|
||||
>
|
||||
{submitting ? 'Processing...' : `Confirm Rental - $${totalCost.toFixed(2)}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(`/items/${id}`)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Rental Summary</h5>
|
||||
|
||||
{item.images && item.images[0] && (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
className="img-fluid rounded mb-3"
|
||||
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h6>{item.name}</h6>
|
||||
<p className="text-muted small">{item.location}</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Pricing:</strong>
|
||||
{item.pricePerHour && (
|
||||
<div>${item.pricePerHour}/hour</div>
|
||||
)}
|
||||
{item.pricePerDay && (
|
||||
<div>${item.pricePerDay}/day</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rentalDuration.days > 0 || rentalDuration.hours > 0 ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<strong>Duration:</strong>
|
||||
<div>
|
||||
{rentalDuration.days > 0 && `${rentalDuration.days} days`}
|
||||
{rentalDuration.hours > 0 && `${rentalDuration.hours} hours`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong>Total Cost:</strong>
|
||||
<strong>${totalCost.toFixed(2)}</strong>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted">Select dates to see total cost</p>
|
||||
)}
|
||||
|
||||
{item.rules && (
|
||||
<>
|
||||
<hr />
|
||||
<div>
|
||||
<strong>Rules:</strong>
|
||||
<p className="small">{item.rules}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RentItem;
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
60
frontend/src/services/api.ts
Normal file
60
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:5001/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const authAPI = {
|
||||
register: (data: any) => api.post('/auth/register', data),
|
||||
login: (data: any) => api.post('/auth/login', data),
|
||||
};
|
||||
|
||||
export const userAPI = {
|
||||
getProfile: () => api.get('/users/profile'),
|
||||
updateProfile: (data: any) => api.put('/users/profile', data),
|
||||
};
|
||||
|
||||
export const itemAPI = {
|
||||
getItems: (params?: any) => api.get('/items', { params }),
|
||||
getItem: (id: string) => api.get(`/items/${id}`),
|
||||
createItem: (data: any) => api.post('/items', data),
|
||||
updateItem: (id: string, data: any) => api.put(`/items/${id}`, data),
|
||||
deleteItem: (id: string) => api.delete(`/items/${id}`),
|
||||
getRecommendations: () => api.get('/items/recommendations'),
|
||||
};
|
||||
|
||||
export const rentalAPI = {
|
||||
createRental: (data: any) => api.post('/rentals', data),
|
||||
getMyRentals: () => api.get('/rentals/my-rentals'),
|
||||
getMyListings: () => api.get('/rentals/my-listings'),
|
||||
updateRentalStatus: (id: string, status: string) =>
|
||||
api.put(`/rentals/${id}/status`, { status }),
|
||||
addReview: (id: string, data: any) =>
|
||||
api.post(`/rentals/${id}/review`, data),
|
||||
};
|
||||
|
||||
export default api;
|
||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
74
frontend/src/types/index.ts
Normal file
74
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
profileImage?: string;
|
||||
isVerified: boolean;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
isPortable: boolean;
|
||||
pickUpAvailable?: boolean;
|
||||
localDeliveryAvailable?: boolean;
|
||||
localDeliveryRadius?: number;
|
||||
shippingAvailable?: boolean;
|
||||
inPlaceUseAvailable?: boolean;
|
||||
pricePerHour?: number;
|
||||
pricePerDay?: number;
|
||||
pricePerWeek?: number;
|
||||
pricePerMonth?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
images: string[];
|
||||
condition: 'excellent' | 'good' | 'fair' | 'poor';
|
||||
availability: boolean;
|
||||
specifications: Record<string, any>;
|
||||
rules?: string;
|
||||
minimumRentalDays: number;
|
||||
maximumRentalDays?: number;
|
||||
needsTraining?: boolean;
|
||||
unavailablePeriods?: Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>;
|
||||
ownerId: string;
|
||||
owner?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Rental {
|
||||
id: string;
|
||||
itemId: string;
|
||||
renterId: string;
|
||||
ownerId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalAmount: number;
|
||||
status: 'pending' | 'confirmed' | 'active' | 'completed' | 'cancelled';
|
||||
paymentStatus: 'pending' | 'paid' | 'refunded';
|
||||
deliveryMethod: 'pickup' | 'delivery';
|
||||
deliveryAddress?: string;
|
||||
notes?: string;
|
||||
rating?: number;
|
||||
review?: string;
|
||||
rejectionReason?: string;
|
||||
item?: Item;
|
||||
renter?: User;
|
||||
owner?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user