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:
jackiettran
2025-07-15 21:21:09 -04:00
commit c09384e3ea
53 changed files with 24425 additions and 0 deletions

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

46
frontend/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

16359
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.10.0",
"bootstrap": "^5.3.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Rentall - Rent gym equipment, tools, and musical instruments from your neighbors"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Rentall - Equipment & Tool Rental Marketplace</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

31
frontend/src/App.css Normal file
View 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;
}

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View 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;

View 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;

View 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';

View 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;
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}