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:
516
frontend/src/pages/CreateItem.tsx
Normal file
516
frontend/src/pages/CreateItem.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import api from "../services/api";
|
||||
import AvailabilityCalendar from "../components/AvailabilityCalendar";
|
||||
import AddressAutocomplete from "../components/AddressAutocomplete";
|
||||
|
||||
interface ItemFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
pickUpAvailable: boolean;
|
||||
localDeliveryAvailable: boolean;
|
||||
localDeliveryRadius?: number;
|
||||
shippingAvailable: boolean;
|
||||
inPlaceUseAvailable: boolean;
|
||||
pricePerHour?: number;
|
||||
pricePerDay?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rules?: string;
|
||||
minimumRentalDays: number;
|
||||
needsTraining: boolean;
|
||||
unavailablePeriods?: Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const CreateItem: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [formData, setFormData] = useState<ItemFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
pickUpAvailable: false,
|
||||
localDeliveryAvailable: false,
|
||||
localDeliveryRadius: 25,
|
||||
shippingAvailable: false,
|
||||
inPlaceUseAvailable: false,
|
||||
pricePerDay: undefined,
|
||||
replacementCost: 0,
|
||||
location: "",
|
||||
minimumRentalDays: 1,
|
||||
needsTraining: false,
|
||||
unavailablePeriods: [],
|
||||
});
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [priceType, setPriceType] = useState<"hour" | "day">("day");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) {
|
||||
setError("You must be logged in to create a listing");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// For now, we'll store image URLs as base64 strings
|
||||
// In production, you'd upload to a service like S3
|
||||
const imageUrls = imagePreviews;
|
||||
|
||||
const response = await api.post("/items", {
|
||||
...formData,
|
||||
images: imageUrls,
|
||||
});
|
||||
navigate(`/items/${response.data.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Failed to create listing");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === "checkbox") {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||
} else if (type === "number") {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value ? parseFloat(value) : undefined,
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagInput.trim()],
|
||||
}));
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter((t) => t !== tag),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
|
||||
// Limit to 5 images
|
||||
if (imageFiles.length + files.length > 5) {
|
||||
setError("You can upload a maximum of 5 images");
|
||||
return;
|
||||
}
|
||||
|
||||
const newImageFiles = [...imageFiles, ...files];
|
||||
setImageFiles(newImageFiles);
|
||||
|
||||
// Create previews
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImageFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1>List an Item for Rent</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Images (Max 5)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={handleImageChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={imageFiles.length >= 5}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Upload up to 5 images of your item
|
||||
</div>
|
||||
|
||||
{imagePreviews.length > 0 && (
|
||||
<div className="row mt-3">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
|
||||
<div className="position-relative">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="name" className="form-label">
|
||||
Item Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Tags</label>
|
||||
<div className="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addTag())
|
||||
}
|
||||
placeholder="Add a tag"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={addTag}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-primary me-2 mb-2">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
onClick={() => removeTag(tag)}
|
||||
style={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="location" className="form-label">
|
||||
Location *
|
||||
</label>
|
||||
<AddressAutocomplete
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={(value, lat, lon) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
location: value,
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}));
|
||||
}}
|
||||
placeholder="Address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Availability Type</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="pickUpAvailable"
|
||||
name="pickUpAvailable"
|
||||
checked={formData.pickUpAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="pickUpAvailable">
|
||||
Pick-Up
|
||||
<div className="small text-muted">They pick-up the item from your location and they return the item to your location</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="localDeliveryAvailable"
|
||||
name="localDeliveryAvailable"
|
||||
checked={formData.localDeliveryAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label d-flex align-items-center"
|
||||
htmlFor="localDeliveryAvailable"
|
||||
>
|
||||
<div>
|
||||
Local Delivery
|
||||
{formData.localDeliveryAvailable && (
|
||||
<span className="ms-2">
|
||||
(Delivery Radius:
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm d-inline-block mx-1"
|
||||
id="localDeliveryRadius"
|
||||
name="localDeliveryRadius"
|
||||
value={formData.localDeliveryRadius || ''}
|
||||
onChange={handleChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="25"
|
||||
min="1"
|
||||
max="100"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
miles)
|
||||
</span>
|
||||
)}
|
||||
<div className="small text-muted">You deliver and then pick-up the item when the rental period ends</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="shippingAvailable"
|
||||
name="shippingAvailable"
|
||||
checked={formData.shippingAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="shippingAvailable">
|
||||
Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="inPlaceUseAvailable"
|
||||
name="inPlaceUseAvailable"
|
||||
checked={formData.inPlaceUseAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="inPlaceUseAvailable"
|
||||
>
|
||||
In-Place Use
|
||||
<div className="small text-muted">They use at your location</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<label className="col-form-label">Price per</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
className="form-select"
|
||||
value={priceType}
|
||||
onChange={(e) => setPriceType(e.target.value as "hour" | "day")}
|
||||
>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
value={priceType === "hour" ? (formData.pricePerHour || "") : (formData.pricePerDay || "")}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="minimumRentalDays" className="form-label">
|
||||
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="minimumRentalDays"
|
||||
name="minimumRentalDays"
|
||||
value={formData.minimumRentalDays}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Availability Schedule</h5>
|
||||
<p className="text-muted">Select dates when the item is NOT available for rent</p>
|
||||
<AvailabilityCalendar
|
||||
unavailablePeriods={formData.unavailablePeriods || []}
|
||||
onPeriodsChange={(periods) =>
|
||||
setFormData(prev => ({ ...prev, unavailablePeriods: periods }))
|
||||
}
|
||||
priceType={priceType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="rules" className="form-label">
|
||||
Rental Rules & Guidelines
|
||||
</label>
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="needsTraining"
|
||||
name="needsTraining"
|
||||
checked={formData.needsTraining}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="needsTraining">
|
||||
Requires in-person training before rental
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="rules"
|
||||
name="rules"
|
||||
rows={3}
|
||||
value={formData.rules || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Any specific rules or guidelines for renting this item"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="replacementCost" className="form-label">
|
||||
Replacement Cost *
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="replacementCost"
|
||||
name="replacementCost"
|
||||
value={formData.replacementCost}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-text">
|
||||
The cost to replace the item if damaged or lost
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Creating..." : "Create Listing"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateItem;
|
||||
641
frontend/src/pages/EditItem.tsx
Normal file
641
frontend/src/pages/EditItem.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item, Rental } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||
|
||||
interface ItemFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
pickUpAvailable: boolean;
|
||||
localDeliveryAvailable: boolean;
|
||||
localDeliveryRadius?: number;
|
||||
shippingAvailable: boolean;
|
||||
inPlaceUseAvailable: boolean;
|
||||
pricePerHour?: number;
|
||||
pricePerDay?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rules?: string;
|
||||
minimumRentalDays: number;
|
||||
needsTraining: boolean;
|
||||
availability: boolean;
|
||||
unavailablePeriods?: Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const EditItem: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [priceType, setPriceType] = useState<"hour" | "day">("day");
|
||||
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
|
||||
const [formData, setFormData] = useState<ItemFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
pickUpAvailable: false,
|
||||
localDeliveryAvailable: false,
|
||||
shippingAvailable: false,
|
||||
inPlaceUseAvailable: false,
|
||||
pricePerHour: undefined,
|
||||
pricePerDay: undefined,
|
||||
replacementCost: 0,
|
||||
location: '',
|
||||
rules: '',
|
||||
minimumRentalDays: 1,
|
||||
needsTraining: false,
|
||||
availability: true,
|
||||
unavailablePeriods: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
fetchAcceptedRentals();
|
||||
}, [id]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
const item: Item = response.data;
|
||||
|
||||
if (item.ownerId !== user?.id) {
|
||||
setError('You are not authorized to edit this item');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the price type based on available pricing
|
||||
if (item.pricePerHour) {
|
||||
setPriceType('hour');
|
||||
} else if (item.pricePerDay) {
|
||||
setPriceType('day');
|
||||
}
|
||||
|
||||
// Convert item data to form data format
|
||||
setFormData({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tags: item.tags || [],
|
||||
pickUpAvailable: item.pickUpAvailable || false,
|
||||
localDeliveryAvailable: item.localDeliveryAvailable || false,
|
||||
localDeliveryRadius: item.localDeliveryRadius || 25,
|
||||
shippingAvailable: item.shippingAvailable || false,
|
||||
inPlaceUseAvailable: item.inPlaceUseAvailable || false,
|
||||
pricePerHour: item.pricePerHour,
|
||||
pricePerDay: item.pricePerDay,
|
||||
replacementCost: item.replacementCost,
|
||||
location: item.location,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
rules: item.rules || '',
|
||||
minimumRentalDays: item.minimumRentalDays,
|
||||
needsTraining: item.needsTraining || false,
|
||||
availability: item.availability,
|
||||
unavailablePeriods: item.unavailablePeriods || [],
|
||||
});
|
||||
|
||||
// Set existing images as previews
|
||||
if (item.images && item.images.length > 0) {
|
||||
setImagePreviews(item.images);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAcceptedRentals = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyListings();
|
||||
const rentals: Rental[] = response.data;
|
||||
// Filter for accepted rentals for this specific item
|
||||
const itemRentals = rentals.filter(rental =>
|
||||
rental.itemId === id &&
|
||||
['confirmed', 'active'].includes(rental.status)
|
||||
);
|
||||
setAcceptedRentals(itemRentals);
|
||||
} catch (err) {
|
||||
console.error('Error fetching rentals:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === "checkbox") {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||
} else if (type === "number") {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value ? parseFloat(value) : undefined,
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use existing image previews (which includes both old and new images)
|
||||
const imageUrls = imagePreviews;
|
||||
|
||||
await itemAPI.updateItem(id!, {
|
||||
...formData,
|
||||
images: imageUrls,
|
||||
isPortable: formData.pickUpAvailable || formData.shippingAvailable,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate(`/items/${id}`);
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to update item');
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagInput.trim()],
|
||||
}));
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter((t) => t !== tag),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
|
||||
// Limit to 5 images total
|
||||
if (imagePreviews.length + files.length > 5) {
|
||||
setError("You can upload a maximum of 5 images");
|
||||
return;
|
||||
}
|
||||
|
||||
const newImageFiles = [...imageFiles, ...files];
|
||||
setImageFiles(newImageFiles);
|
||||
|
||||
// Create previews
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews((prev) => [...prev, reader.result as string]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && error.includes('authorized')) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1>Edit Listing</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success" role="alert">
|
||||
Item updated successfully! Redirecting...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Images (Max 5)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
onChange={handleImageChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={imagePreviews.length >= 5}
|
||||
/>
|
||||
<div className="form-text">
|
||||
Upload up to 5 images of your item
|
||||
</div>
|
||||
|
||||
{imagePreviews.length > 0 && (
|
||||
<div className="row mt-3">
|
||||
{imagePreviews.map((preview, index) => (
|
||||
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
|
||||
<div className="position-relative">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||
onClick={() => removeImage(index)}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="name" className="form-label">
|
||||
Item Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Tags</label>
|
||||
<div className="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addTag())
|
||||
}
|
||||
placeholder="Add a tag"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={addTag}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-primary me-2 mb-2">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
onClick={() => removeTag(tag)}
|
||||
style={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="location" className="form-label">
|
||||
Location *
|
||||
</label>
|
||||
<AddressAutocomplete
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={(value, lat, lon) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
location: value,
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}));
|
||||
}}
|
||||
placeholder="Address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Availability Type</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="pickUpAvailable"
|
||||
name="pickUpAvailable"
|
||||
checked={formData.pickUpAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="pickUpAvailable">
|
||||
Pick-Up
|
||||
<div className="small text-muted">They pick-up the item from your location and they return the item to your location</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="localDeliveryAvailable"
|
||||
name="localDeliveryAvailable"
|
||||
checked={formData.localDeliveryAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label d-flex align-items-center"
|
||||
htmlFor="localDeliveryAvailable"
|
||||
>
|
||||
<div>
|
||||
Local Delivery
|
||||
{formData.localDeliveryAvailable && (
|
||||
<span className="ms-2">
|
||||
(Delivery Radius:
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm d-inline-block mx-1"
|
||||
id="localDeliveryRadius"
|
||||
name="localDeliveryRadius"
|
||||
value={formData.localDeliveryRadius || ''}
|
||||
onChange={handleChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="25"
|
||||
min="1"
|
||||
max="100"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
miles)
|
||||
</span>
|
||||
)}
|
||||
<div className="small text-muted">You deliver and then pick-up the item when the rental period ends</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="shippingAvailable"
|
||||
name="shippingAvailable"
|
||||
checked={formData.shippingAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="shippingAvailable">
|
||||
Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="inPlaceUseAvailable"
|
||||
name="inPlaceUseAvailable"
|
||||
checked={formData.inPlaceUseAvailable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="inPlaceUseAvailable"
|
||||
>
|
||||
In-Place Use
|
||||
<div className="small text-muted">They use at your location</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-auto">
|
||||
<label className="col-form-label">Price per</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
className="form-select"
|
||||
value={priceType}
|
||||
onChange={(e) => setPriceType(e.target.value as "hour" | "day")}
|
||||
>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
|
||||
value={priceType === "hour" ? (formData.pricePerHour || "") : (formData.pricePerDay || "")}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="minimumRentalDays" className="form-label">
|
||||
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="minimumRentalDays"
|
||||
name="minimumRentalDays"
|
||||
value={formData.minimumRentalDays}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Availability Schedule</h5>
|
||||
<p className="text-muted">Select dates when the item is NOT available for rent. Dates with accepted rentals are shown in purple.</p>
|
||||
<AvailabilityCalendar
|
||||
unavailablePeriods={[
|
||||
...(formData.unavailablePeriods || []),
|
||||
...acceptedRentals.map(rental => ({
|
||||
id: `rental-${rental.id}`,
|
||||
startDate: new Date(rental.startDate),
|
||||
endDate: new Date(rental.endDate),
|
||||
isAcceptedRental: true
|
||||
}))
|
||||
]}
|
||||
onPeriodsChange={(periods) => {
|
||||
// Filter out accepted rental periods when saving
|
||||
const userPeriods = periods.filter(p => !p.isAcceptedRental);
|
||||
setFormData(prev => ({ ...prev, unavailablePeriods: userPeriods }));
|
||||
}}
|
||||
priceType={priceType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="rules" className="form-label">
|
||||
Rental Rules & Guidelines
|
||||
</label>
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="needsTraining"
|
||||
name="needsTraining"
|
||||
checked={formData.needsTraining}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="needsTraining">
|
||||
Requires in-person training before rental
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="rules"
|
||||
name="rules"
|
||||
rows={3}
|
||||
value={formData.rules || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Any specific rules or guidelines for renting this item"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="replacementCost" className="form-label">
|
||||
Replacement Cost *
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="replacementCost"
|
||||
name="replacementCost"
|
||||
value={formData.replacementCost}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-text">
|
||||
The cost to replace the item if damaged or lost
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="availability"
|
||||
name="availability"
|
||||
checked={formData.availability}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="availability">
|
||||
Available for rent
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Updating..." : "Update Listing"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditItem;
|
||||
137
frontend/src/pages/Home.tsx
Normal file
137
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="py-5 bg-light">
|
||||
<div className="container">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-lg-6">
|
||||
<h1 className="display-4 fw-bold mb-4">
|
||||
Rent Equipment from Your Neighbors
|
||||
</h1>
|
||||
<p className="lead mb-4">
|
||||
Why buy when you can rent? Find gym equipment, tools, and musical instruments
|
||||
available for rent in your area. Save money and space while getting access to
|
||||
everything you need.
|
||||
</p>
|
||||
<div className="d-flex gap-3">
|
||||
<Link to="/items" className="btn btn-primary btn-lg">
|
||||
Browse Items
|
||||
</Link>
|
||||
{user ? (
|
||||
<Link to="/create-item" className="btn btn-outline-primary btn-lg">
|
||||
List Your Item
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/register" className="btn btn-outline-primary btn-lg">
|
||||
Start Renting
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600"
|
||||
alt="Equipment rental"
|
||||
className="img-fluid rounded shadow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-5">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-5">Popular Categories</h2>
|
||||
<div className="row g-4">
|
||||
<div className="col-md-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div className="card-body text-center">
|
||||
<i className="bi bi-tools display-3 text-primary mb-3"></i>
|
||||
<h4>Tools</h4>
|
||||
<p className="text-muted">
|
||||
Power tools, hand tools, and equipment for your DIY projects
|
||||
</p>
|
||||
<Link to="/items?tags=tools" className="btn btn-sm btn-outline-primary">
|
||||
Browse Tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div className="card-body text-center">
|
||||
<i className="bi bi-heart-pulse display-3 text-primary mb-3"></i>
|
||||
<h4>Gym Equipment</h4>
|
||||
<p className="text-muted">
|
||||
Weights, machines, and fitness gear for your workout needs
|
||||
</p>
|
||||
<Link to="/items?tags=gym" className="btn btn-sm btn-outline-primary">
|
||||
Browse Gym Equipment
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card h-100 shadow-sm">
|
||||
<div className="card-body text-center">
|
||||
<i className="bi bi-music-note-beamed display-3 text-primary mb-3"></i>
|
||||
<h4>Musical Instruments</h4>
|
||||
<p className="text-muted">
|
||||
Guitars, keyboards, drums, and more for musicians
|
||||
</p>
|
||||
<Link to="/items?tags=music" className="btn btn-sm btn-outline-primary">
|
||||
Browse Instruments
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-5 bg-light">
|
||||
<div className="container">
|
||||
<h2 className="text-center mb-5">How It Works</h2>
|
||||
<div className="row g-4">
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">1</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Search</h5>
|
||||
<p className="text-muted">Find the equipment you need in your area</p>
|
||||
</div>
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">2</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Book</h5>
|
||||
<p className="text-muted">Reserve items for the dates you need</p>
|
||||
</div>
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">3</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Pick Up</h5>
|
||||
<p className="text-muted">Collect items or have them delivered</p>
|
||||
</div>
|
||||
<div className="col-md-3 text-center">
|
||||
<div className="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style={{ width: '80px', height: '80px' }}>
|
||||
<span className="fs-3 fw-bold">4</span>
|
||||
</div>
|
||||
<h5 className="mt-3">Return</h5>
|
||||
<p className="text-muted">Return items when you're done</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
204
frontend/src/pages/ItemDetail.tsx
Normal file
204
frontend/src/pages/ItemDetail.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item, Rental } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
|
||||
const ItemDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [item, setItem] = useState<Item | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
if (user) {
|
||||
checkIfAlreadyRenting();
|
||||
}
|
||||
}, [id, user]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
setItem(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfAlreadyRenting = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyRentals();
|
||||
const rentals: Rental[] = response.data;
|
||||
// Check if user has an active rental for this item
|
||||
const hasActiveRental = rentals.some(rental =>
|
||||
rental.item?.id === id &&
|
||||
['pending', 'confirmed', 'active'].includes(rental.status)
|
||||
);
|
||||
setIsAlreadyRenting(hasActiveRental);
|
||||
} catch (err) {
|
||||
console.error('Failed to check rental status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
navigate(`/items/${id}/edit`);
|
||||
};
|
||||
|
||||
const handleRent = () => {
|
||||
navigate(`/items/${id}/rent`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !item) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = user?.id === item.ownerId;
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-10">
|
||||
{item.images.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={item.images[selectedImage]}
|
||||
alt={item.name}
|
||||
className="img-fluid rounded mb-3"
|
||||
style={{ width: '100%', maxHeight: '500px', objectFit: 'cover' }}
|
||||
/>
|
||||
{item.images.length > 1 && (
|
||||
<div className="d-flex gap-2 overflow-auto justify-content-center">
|
||||
{item.images.map((image, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={image}
|
||||
alt={`${item.name} ${index + 1}`}
|
||||
className={`rounded cursor-pointer ${selectedImage === index ? 'border border-primary' : ''}`}
|
||||
style={{ width: '80px', height: '80px', objectFit: 'cover', cursor: 'pointer' }}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-light rounded d-flex align-items-center justify-content-center mb-4" style={{ height: '400px' }}>
|
||||
<span className="text-muted">No image available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<h1>{item.name}</h1>
|
||||
<p className="text-muted">{item.location}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
{item.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-secondary me-2">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Description</h5>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Pricing</h5>
|
||||
<div className="row">
|
||||
{item.pricePerHour && (
|
||||
<div className="col-6">
|
||||
<strong>Per Hour:</strong> ${item.pricePerHour}
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerDay && (
|
||||
<div className="col-6">
|
||||
<strong>Per Day:</strong> ${item.pricePerDay}
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerWeek && (
|
||||
<div className="col-6">
|
||||
<strong>Per Week:</strong> ${item.pricePerWeek}
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerMonth && (
|
||||
<div className="col-6">
|
||||
<strong>Per Month:</strong> ${item.pricePerMonth}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Details</h5>
|
||||
<p><strong>Replacement Cost:</strong> ${item.replacementCost}</p>
|
||||
{item.minimumRentalDays && (
|
||||
<p><strong>Minimum Rental:</strong> {item.minimumRentalDays} days</p>
|
||||
)}
|
||||
{item.maximumRentalDays && (
|
||||
<p><strong>Maximum Rental:</strong> {item.maximumRentalDays} days</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.rules && (
|
||||
<div className="mb-4">
|
||||
<h5>Rules</h5>
|
||||
<p>{item.rules}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
{isOwner ? (
|
||||
<button className="btn btn-primary" onClick={handleEdit}>
|
||||
Edit Listing
|
||||
</button>
|
||||
) : (
|
||||
item.availability && !isAlreadyRenting && (
|
||||
<button className="btn btn-primary" onClick={handleRent}>
|
||||
Rent This Item
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{!isOwner && isAlreadyRenting && (
|
||||
<button className="btn btn-success" disabled style={{ opacity: 0.8 }}>
|
||||
✓ Renting
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemDetail;
|
||||
164
frontend/src/pages/ItemList.tsx
Normal file
164
frontend/src/pages/ItemList.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { itemAPI } from '../services/api';
|
||||
|
||||
const ItemList: React.FC = () => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterTag, setFilterTag] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItems();
|
||||
console.log('API Response:', response);
|
||||
// Access the items array from response.data.items
|
||||
const allItems = response.data.items || response.data || [];
|
||||
// Filter only available items
|
||||
const availableItems = allItems.filter((item: Item) => item.availability);
|
||||
setItems(availableItems);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching items:', err);
|
||||
console.error('Error response:', err.response);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique tags from all items
|
||||
const allTags = Array.from(new Set(items.flatMap(item => item.tags || [])));
|
||||
|
||||
// Filter items based on search term and selected tag
|
||||
const filteredItems = items.filter(item => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTag = filterTag === '' || (item.tags && item.tags.includes(filterTag));
|
||||
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>Browse Items</h1>
|
||||
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-6">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search items..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(e.target.value)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{allTags.map(tag => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<span className="text-muted">{filteredItems.length} items found</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<p className="text-center text-muted">No items available for rent.</p>
|
||||
) : (
|
||||
<div className="row">
|
||||
{filteredItems.map((item) => (
|
||||
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<Link
|
||||
to={`/items/${item.id}`}
|
||||
className="text-decoration-none"
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||
{item.images && item.images[0] && (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h5 className="card-title text-dark">
|
||||
{item.name}
|
||||
</h5>
|
||||
<p className="card-text text-truncate text-dark">{item.description}</p>
|
||||
|
||||
<div className="mb-2">
|
||||
{item.tags && item.tags.map((tag, index) => (
|
||||
<span key={index} className="badge bg-secondary me-1">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
{item.pricePerDay && (
|
||||
<div className="text-primary">
|
||||
<strong>${item.pricePerDay}/day</strong>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerHour && (
|
||||
<div className="text-primary">
|
||||
<strong>${item.pricePerHour}/hour</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-muted small">
|
||||
<i className="bi bi-geo-alt"></i> {item.location}
|
||||
</div>
|
||||
|
||||
{item.owner && (
|
||||
<small className="text-muted">by {item.owner.firstName} {item.owner.lastName}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemList;
|
||||
91
frontend/src/pages/Login.tsx
Normal file
91
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-5">
|
||||
<div className="card shadow">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="text-center mb-4">Login</h2>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-3">
|
||||
<p className="mb-0">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-decoration-none">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
295
frontend/src/pages/MyListings.tsx
Normal file
295
frontend/src/pages/MyListings.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import api from '../services/api';
|
||||
import { Item, Rental } from '../types';
|
||||
import { rentalAPI } from '../services/api';
|
||||
|
||||
const MyListings: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [listings, setListings] = useState<Item[]>([]);
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyListings();
|
||||
fetchRentalRequests();
|
||||
}, [user]);
|
||||
|
||||
const fetchMyListings = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(''); // Clear any previous errors
|
||||
const response = await api.get('/items');
|
||||
|
||||
// Filter items to only show ones owned by current user
|
||||
const myItems = response.data.items.filter((item: Item) => item.ownerId === user.id);
|
||||
setListings(myItems);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching listings:', err);
|
||||
// Only show error for actual API failures
|
||||
if (err.response && err.response.status >= 500) {
|
||||
setError('Failed to get your listings. Please try again later.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this listing?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/${itemId}`);
|
||||
setListings(listings.filter(item => item.id !== itemId));
|
||||
} catch (err: any) {
|
||||
alert('Failed to delete listing');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAvailability = async (item: Item) => {
|
||||
try {
|
||||
await api.put(`/items/${item.id}`, {
|
||||
...item,
|
||||
availability: !item.availability
|
||||
});
|
||||
setListings(listings.map(i =>
|
||||
i.id === item.id ? { ...i, availability: !i.availability } : i
|
||||
));
|
||||
} catch (err: any) {
|
||||
alert('Failed to update availability');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRentalRequests = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const response = await rentalAPI.getMyListings();
|
||||
setRentals(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching rental requests:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptRental = async (rentalId: string) => {
|
||||
try {
|
||||
await rentalAPI.updateRentalStatus(rentalId, 'confirmed');
|
||||
// Refresh the rentals list
|
||||
fetchRentalRequests();
|
||||
} catch (err) {
|
||||
console.error('Failed to accept rental request:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectRental = async (rentalId: string) => {
|
||||
try {
|
||||
await api.put(`/rentals/${rentalId}/status`, {
|
||||
status: 'cancelled',
|
||||
rejectionReason: 'Request declined by owner'
|
||||
});
|
||||
// Refresh the rentals list
|
||||
fetchRentalRequests();
|
||||
} catch (err) {
|
||||
console.error('Failed to reject rental request:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>My Listings</h1>
|
||||
<Link to="/create-item" className="btn btn-primary">
|
||||
Add New Item
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const pendingCount = rentals.filter(r => r.status === 'pending').length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
return (
|
||||
<div className="alert alert-info d-flex align-items-center" role="alert">
|
||||
<i className="bi bi-bell-fill me-2"></i>
|
||||
You have {pendingCount} pending rental request{pendingCount > 1 ? 's' : ''} to review.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{listings.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<p className="text-muted">You haven't listed any items yet.</p>
|
||||
<Link to="/create-item" className="btn btn-primary mt-3">
|
||||
List Your First Item
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{listings.map((item) => (
|
||||
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<Link
|
||||
to={`/items/${item.id}/edit`}
|
||||
className="text-decoration-none"
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button') || target.closest('.rental-requests')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: 'pointer' }}>
|
||||
{item.images && item.images[0] && (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h5 className="card-title text-dark">
|
||||
{item.name}
|
||||
</h5>
|
||||
<p className="card-text text-truncate text-dark">{item.description}</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<span className={`badge ${item.availability ? 'bg-success' : 'bg-secondary'}`}>
|
||||
{item.availability ? 'Available' : 'Not Available'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
{item.pricePerDay && (
|
||||
<div className="text-muted small">
|
||||
${item.pricePerDay}/day
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerHour && (
|
||||
<div className="text-muted small">
|
||||
${item.pricePerHour}/hour
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
onClick={() => toggleAvailability(item)}
|
||||
className="btn btn-sm btn-outline-info"
|
||||
>
|
||||
{item.availability ? 'Mark Unavailable' : 'Mark Available'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const pendingRentals = rentals.filter(r =>
|
||||
r.itemId === item.id && r.status === 'pending'
|
||||
);
|
||||
const acceptedRentals = rentals.filter(r =>
|
||||
r.itemId === item.id && ['confirmed', 'active'].includes(r.status)
|
||||
);
|
||||
|
||||
if (pendingRentals.length > 0 || acceptedRentals.length > 0) {
|
||||
return (
|
||||
<div className="mt-3 border-top pt-3 rental-requests">
|
||||
{pendingRentals.length > 0 && (
|
||||
<>
|
||||
<h6 className="text-primary mb-2">
|
||||
<i className="bi bi-bell-fill"></i> Pending Requests ({pendingRentals.length})
|
||||
</h6>
|
||||
{pendingRentals.map(rental => (
|
||||
<div key={rental.id} className="border rounded p-2 mb-2 bg-light">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div className="small">
|
||||
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong><br/>
|
||||
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}<br/>
|
||||
<span className="text-muted">${rental.totalAmount}</span>
|
||||
</div>
|
||||
<div className="d-flex gap-1">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleAcceptRental(rental.id)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRejectRental(rental.id)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{acceptedRentals.length > 0 && (
|
||||
<>
|
||||
<h6 className="text-success mb-2 mt-3">
|
||||
<i className="bi bi-check-circle-fill"></i> Accepted Rentals ({acceptedRentals.length})
|
||||
</h6>
|
||||
{acceptedRentals.map(rental => (
|
||||
<div key={rental.id} className="border rounded p-2 mb-2 bg-light">
|
||||
<div className="small">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{rental.renter?.firstName} {rental.renter?.lastName}</strong><br/>
|
||||
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}<br/>
|
||||
<span className="text-muted">${rental.totalAmount}</span>
|
||||
</div>
|
||||
<span className={`badge ${rental.status === 'active' ? 'bg-success' : 'bg-info'}`}>
|
||||
{rental.status === 'active' ? 'Active' : 'Confirmed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyListings;
|
||||
200
frontend/src/pages/MyRentals.tsx
Normal file
200
frontend/src/pages/MyRentals.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { rentalAPI } from '../services/api';
|
||||
import { Rental } from '../types';
|
||||
|
||||
const MyRentals: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'past'>('active');
|
||||
|
||||
useEffect(() => {
|
||||
fetchRentals();
|
||||
}, []);
|
||||
|
||||
const fetchRentals = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyRentals();
|
||||
setRentals(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch rentals');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRental = async (rentalId: string) => {
|
||||
if (!window.confirm('Are you sure you want to cancel this rental?')) return;
|
||||
|
||||
try {
|
||||
await rentalAPI.updateRentalStatus(rentalId, 'cancelled');
|
||||
fetchRentals(); // Refresh the list
|
||||
} catch (err: any) {
|
||||
alert('Failed to cancel rental');
|
||||
}
|
||||
};
|
||||
|
||||
// Filter rentals based on status
|
||||
const activeRentals = rentals.filter(r =>
|
||||
['pending', 'confirmed', 'active'].includes(r.status)
|
||||
);
|
||||
const pastRentals = rentals.filter(r =>
|
||||
['completed', 'cancelled'].includes(r.status)
|
||||
);
|
||||
|
||||
const displayedRentals = activeTab === 'active' ? activeRentals : pastRentals;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>My Rentals</h1>
|
||||
|
||||
<ul className="nav nav-tabs mb-4">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'active' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('active')}
|
||||
>
|
||||
Active Rentals ({activeRentals.length})
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'past' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('past')}
|
||||
>
|
||||
Past Rentals ({pastRentals.length})
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{displayedRentals.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<p className="text-muted">
|
||||
{activeTab === 'active'
|
||||
? "You don't have any active rentals."
|
||||
: "You don't have any past rentals."}
|
||||
</p>
|
||||
<Link to="/items" className="btn btn-primary mt-3">
|
||||
Browse Items to Rent
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{displayedRentals.map((rental) => (
|
||||
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<Link
|
||||
to={rental.item ? `/items/${rental.item.id}` : '#'}
|
||||
className="text-decoration-none"
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!rental.item || target.closest('button')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: rental.item ? 'pointer' : 'default' }}>
|
||||
{rental.item?.images && rental.item.images[0] && (
|
||||
<img
|
||||
src={rental.item.images[0]}
|
||||
className="card-img-top"
|
||||
alt={rental.item.name}
|
||||
style={{ height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h5 className="card-title text-dark">
|
||||
{rental.item ? rental.item.name : 'Item Unavailable'}
|
||||
</h5>
|
||||
|
||||
<div className="mb-2">
|
||||
<span className={`badge ${
|
||||
rental.status === 'active' ? 'bg-success' :
|
||||
rental.status === 'pending' ? 'bg-warning' :
|
||||
rental.status === 'confirmed' ? 'bg-info' :
|
||||
rental.status === 'completed' ? 'bg-secondary' :
|
||||
'bg-danger'
|
||||
}`}>
|
||||
{rental.status.charAt(0).toUpperCase() + rental.status.slice(1)}
|
||||
</span>
|
||||
{rental.paymentStatus === 'paid' && (
|
||||
<span className="badge bg-success ms-2">Paid</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Rental Period:</strong><br />
|
||||
{new Date(rental.startDate).toLocaleDateString()} - {new Date(rental.endDate).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Total:</strong> ${rental.totalAmount}
|
||||
</p>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Delivery:</strong> {rental.deliveryMethod === 'pickup' ? 'Pick-up' : 'Delivery'}
|
||||
</p>
|
||||
|
||||
{rental.owner && (
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Owner:</strong> {rental.owner.firstName} {rental.owner.lastName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rental.status === 'cancelled' && rental.rejectionReason && (
|
||||
<div className="alert alert-warning mt-2 mb-1 p-2 small">
|
||||
<strong>Rejection reason:</strong> {rental.rejectionReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
{rental.status === 'pending' && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => cancelRental(rental.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{rental.status === 'completed' && !rental.rating && (
|
||||
<button className="btn btn-sm btn-primary">
|
||||
Leave Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyRentals;
|
||||
381
frontend/src/pages/Profile.tsx
Normal file
381
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { userAPI, itemAPI, rentalAPI } from '../services/api';
|
||||
import { User, Item, Rental } from '../types';
|
||||
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||
|
||||
const Profile: React.FC = () => {
|
||||
const { user, updateUser, logout } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [profileData, setProfileData] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
profileImage: ''
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState({
|
||||
itemsListed: 0,
|
||||
acceptedRentals: 0,
|
||||
totalRentals: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await userAPI.getProfile();
|
||||
setProfileData(response.data);
|
||||
setFormData({
|
||||
firstName: response.data.firstName || '',
|
||||
lastName: response.data.lastName || '',
|
||||
email: response.data.email || '',
|
||||
phone: response.data.phone || '',
|
||||
address: response.data.address || '',
|
||||
profileImage: response.data.profileImage || ''
|
||||
});
|
||||
if (response.data.profileImage) {
|
||||
setImagePreview(response.data.profileImage);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Fetch user's items
|
||||
const itemsResponse = await itemAPI.getItems();
|
||||
const allItems = itemsResponse.data.items || itemsResponse.data || [];
|
||||
const myItems = allItems.filter((item: Item) => item.ownerId === user?.id);
|
||||
|
||||
// Fetch rentals where user is the owner (rentals on user's items)
|
||||
const ownerRentalsResponse = await rentalAPI.getMyListings();
|
||||
const ownerRentals: Rental[] = ownerRentalsResponse.data;
|
||||
|
||||
const acceptedRentals = ownerRentals.filter(r => ['confirmed', 'active'].includes(r.status));
|
||||
|
||||
setStats({
|
||||
itemsListed: myItems.length,
|
||||
acceptedRentals: acceptedRentals.length,
|
||||
totalRentals: ownerRentals.length
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
...formData,
|
||||
profileImage: imagePreview || formData.profileImage
|
||||
};
|
||||
|
||||
const response = await userAPI.updateProfile(updateData);
|
||||
setProfileData(response.data);
|
||||
updateUser(response.data); // Update the auth context
|
||||
setSuccess('Profile updated successfully!');
|
||||
setEditing(false);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to update profile');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
// Reset form to original data
|
||||
if (profileData) {
|
||||
setFormData({
|
||||
firstName: profileData.firstName || '',
|
||||
lastName: profileData.lastName || '',
|
||||
email: profileData.email || '',
|
||||
phone: profileData.phone || '',
|
||||
address: profileData.address || '',
|
||||
profileImage: profileData.profileImage || ''
|
||||
});
|
||||
setImagePreview(profileData.profileImage || null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<h1 className="mb-4">My Profile</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success" role="alert">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="text-center mb-4">
|
||||
<div className="position-relative d-inline-block">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="rounded-circle"
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||
style={{ width: '150px', height: '150px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '3rem' }}></i>
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<label
|
||||
htmlFor="profileImage"
|
||||
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
||||
style={{ width: '40px', height: '40px', padding: '0' }}
|
||||
>
|
||||
<i className="bi bi-camera-fill"></i>
|
||||
<input
|
||||
type="file"
|
||||
id="profileImage"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="d-none"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<h5 className="mt-3">{profileData?.firstName} {profileData?.lastName}</h5>
|
||||
<p className="text-muted">@{profileData?.username}</p>
|
||||
{profileData?.isVerified && (
|
||||
<span className="badge bg-success">
|
||||
<i className="bi bi-check-circle-fill"></i> Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="firstName" className="form-label">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
disabled={!editing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="lastName" className="form-label">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
disabled={!editing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={!editing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="phone" className="form-label">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
disabled={!editing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="address" className="form-label">Address</label>
|
||||
{editing ? (
|
||||
<AddressAutocomplete
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, address: value }));
|
||||
}}
|
||||
placeholder="Enter your address"
|
||||
required={false}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Account Statistics</h5>
|
||||
<div className="row text-center">
|
||||
<div className="col-md-4">
|
||||
<h3 className="text-primary">{stats.itemsListed}</h3>
|
||||
<p className="text-muted">Items Listed</p>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<h3 className="text-success">{stats.acceptedRentals}</h3>
|
||||
<p className="text-muted">Accepted Rentals</p>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<h3 className="text-info">{stats.totalRentals}</h3>
|
||||
<p className="text-muted">Total Rentals</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Account Settings</h5>
|
||||
<div className="list-group list-group-flush">
|
||||
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i className="bi bi-bell me-2"></i>
|
||||
Notification Settings
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i className="bi bi-shield-lock me-2"></i>
|
||||
Privacy & Security
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<a href="#" className="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i className="bi bi-credit-card me-2"></i>
|
||||
Payment Methods
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-danger border-0 w-100 text-start"
|
||||
>
|
||||
<div>
|
||||
<i className="bi bi-box-arrow-right me-2"></i>
|
||||
Log Out
|
||||
</div>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
163
frontend/src/pages/Register.tsx
Normal file
163
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(formData);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to create account');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-5">
|
||||
<div className="card shadow">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="text-center mb-4">Create Account</h2>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="firstName" className="form-label">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="lastName" className="form-label">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="form-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="phone" className="form-label">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-3">
|
||||
<p className="mb-0">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-decoration-none">
|
||||
Login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
470
frontend/src/pages/RentItem.tsx
Normal file
470
frontend/src/pages/RentItem.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||
|
||||
const RentItem: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [item, setItem] = useState<Item | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
deliveryMethod: 'pickup' as 'pickup' | 'delivery',
|
||||
deliveryAddress: '',
|
||||
cardNumber: '',
|
||||
cardExpiry: '',
|
||||
cardCVC: '',
|
||||
cardName: ''
|
||||
});
|
||||
|
||||
const [selectedPeriods, setSelectedPeriods] = useState<Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}>>([]);
|
||||
|
||||
const [totalCost, setTotalCost] = useState(0);
|
||||
const [rentalDuration, setRentalDuration] = useState({ days: 0, hours: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateTotal();
|
||||
}, [selectedPeriods, item]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
setItem(response.data);
|
||||
|
||||
// Check if item is available
|
||||
if (!response.data.availability) {
|
||||
setError('This item is not available for rent');
|
||||
}
|
||||
|
||||
// Check if user is trying to rent their own item
|
||||
if (response.data.ownerId === user?.id) {
|
||||
setError('You cannot rent your own item');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
if (!item || selectedPeriods.length === 0) {
|
||||
setTotalCost(0);
|
||||
setRentalDuration({ days: 0, hours: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, we'll use the first selected period
|
||||
const period = selectedPeriods[0];
|
||||
const start = new Date(period.startDate);
|
||||
const end = new Date(period.endDate);
|
||||
|
||||
// Add time if hourly rental
|
||||
if (item.pricePerHour && period.startTime && period.endTime) {
|
||||
const [startHour, startMin] = period.startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = period.endTime.split(':').map(Number);
|
||||
start.setHours(startHour, startMin);
|
||||
end.setHours(endHour, endMin);
|
||||
}
|
||||
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
let cost = 0;
|
||||
let duration = { days: 0, hours: 0 };
|
||||
|
||||
if (item.pricePerHour && period.startTime && period.endTime) {
|
||||
// Hourly rental
|
||||
cost = diffHours * item.pricePerHour;
|
||||
duration.hours = diffHours;
|
||||
} else if (item.pricePerDay) {
|
||||
// Daily rental
|
||||
cost = diffDays * item.pricePerDay;
|
||||
duration.days = diffDays;
|
||||
}
|
||||
|
||||
setTotalCost(cost);
|
||||
setRentalDuration(duration);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user || !item) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (selectedPeriods.length === 0) {
|
||||
setError('Please select a rental period');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const period = selectedPeriods[0];
|
||||
const rentalData = {
|
||||
itemId: item.id,
|
||||
startDate: period.startDate.toISOString().split('T')[0],
|
||||
endDate: period.endDate.toISOString().split('T')[0],
|
||||
startTime: period.startTime || undefined,
|
||||
endTime: period.endTime || undefined,
|
||||
totalAmount: totalCost,
|
||||
deliveryMethod: formData.deliveryMethod,
|
||||
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
|
||||
};
|
||||
|
||||
await rentalAPI.createRental(rentalData);
|
||||
navigate('/my-rentals');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to create rental');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'cardNumber') {
|
||||
// Remove all non-digits
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
|
||||
// Add spaces every 4 digits
|
||||
const formatted = cleaned.match(/.{1,4}/g)?.join(' ') || cleaned;
|
||||
|
||||
// Limit to 16 digits (19 characters with spaces)
|
||||
if (cleaned.length <= 16) {
|
||||
setFormData(prev => ({ ...prev, [name]: formatted }));
|
||||
}
|
||||
} else if (name === 'cardExpiry') {
|
||||
// Remove all non-digits
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
|
||||
// Add slash after 2 digits
|
||||
let formatted = cleaned;
|
||||
if (cleaned.length >= 3) {
|
||||
formatted = cleaned.slice(0, 2) + '/' + cleaned.slice(2, 4);
|
||||
}
|
||||
|
||||
// Limit to 4 digits
|
||||
if (cleaned.length <= 4) {
|
||||
setFormData(prev => ({ ...prev, [name]: formatted }));
|
||||
}
|
||||
} else if (name === 'cardCVC') {
|
||||
// Only allow digits and limit to 4
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 4) {
|
||||
setFormData(prev => ({ ...prev, [name]: cleaned }));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item || error === 'You cannot rent your own item' || error === 'This item is not available for rent') {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showHourlyOptions = !!item.pricePerHour;
|
||||
const minDays = item.minimumRentalDays || 1;
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<h1>Rent: {item.name}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Select Rental Period</h5>
|
||||
|
||||
<AvailabilityCalendar
|
||||
unavailablePeriods={[
|
||||
...(item.unavailablePeriods || []),
|
||||
...selectedPeriods.map(p => ({ ...p, isRentalSelection: true }))
|
||||
]}
|
||||
onPeriodsChange={(periods) => {
|
||||
// Only handle rental selections
|
||||
const rentalSelections = periods.filter(p => p.isRentalSelection);
|
||||
setSelectedPeriods(rentalSelections.map(p => {
|
||||
const { isRentalSelection, ...rest } = p;
|
||||
return rest;
|
||||
}));
|
||||
}}
|
||||
priceType={showHourlyOptions ? "hour" : "day"}
|
||||
isRentalMode={true}
|
||||
/>
|
||||
|
||||
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
|
||||
<div className="alert alert-warning mt-3">
|
||||
Minimum rental period is {minDays} days
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPeriods.length === 0 && (
|
||||
<div className="alert alert-info mt-3">
|
||||
Please select your rental dates on the calendar above
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Delivery Options</h5>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="deliveryMethod" className="form-label">Delivery Method *</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="deliveryMethod"
|
||||
name="deliveryMethod"
|
||||
value={formData.deliveryMethod}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{item.pickUpAvailable && <option value="pickup">Pick-up</option>}
|
||||
{item.localDeliveryAvailable && <option value="delivery">Delivery</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.deliveryMethod === 'delivery' && (
|
||||
<div className="mb-3">
|
||||
<label htmlFor="deliveryAddress" className="form-label">Delivery Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="deliveryAddress"
|
||||
name="deliveryAddress"
|
||||
value={formData.deliveryAddress}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter delivery address"
|
||||
required={formData.deliveryMethod === 'delivery'}
|
||||
/>
|
||||
{item.localDeliveryRadius && (
|
||||
<div className="form-text">
|
||||
Delivery available within {item.localDeliveryRadius} miles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Payment</h5>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Payment Method *</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="paymentMethod"
|
||||
id="creditCard"
|
||||
value="creditCard"
|
||||
checked
|
||||
readOnly
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="creditCard">
|
||||
Credit/Debit Card
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-12">
|
||||
<label htmlFor="cardNumber" className="form-label">Card Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardNumber"
|
||||
name="cardNumber"
|
||||
value={formData.cardNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardExpiry" className="form-label">Expiry Date *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardExpiry"
|
||||
name="cardExpiry"
|
||||
value={formData.cardExpiry}
|
||||
onChange={handleChange}
|
||||
placeholder="MM/YY"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardCVC" className="form-label">CVC *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardCVC"
|
||||
name="cardCVC"
|
||||
value={formData.cardCVC}
|
||||
onChange={handleChange}
|
||||
placeholder="123"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="cardName" className="form-label">Name on Card *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardName"
|
||||
name="cardName"
|
||||
value={formData.cardName}
|
||||
onChange={handleChange}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-info small">
|
||||
<i className="bi bi-info-circle"></i> Your payment information is secure and encrypted. You will only be charged after the owner accepts your rental request.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || selectedPeriods.length === 0 || totalCost === 0 || (rentalDuration.days < minDays && !showHourlyOptions)}
|
||||
>
|
||||
{submitting ? 'Processing...' : `Confirm Rental - $${totalCost.toFixed(2)}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(`/items/${id}`)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Rental Summary</h5>
|
||||
|
||||
{item.images && item.images[0] && (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
className="img-fluid rounded mb-3"
|
||||
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h6>{item.name}</h6>
|
||||
<p className="text-muted small">{item.location}</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Pricing:</strong>
|
||||
{item.pricePerHour && (
|
||||
<div>${item.pricePerHour}/hour</div>
|
||||
)}
|
||||
{item.pricePerDay && (
|
||||
<div>${item.pricePerDay}/day</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rentalDuration.days > 0 || rentalDuration.hours > 0 ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<strong>Duration:</strong>
|
||||
<div>
|
||||
{rentalDuration.days > 0 && `${rentalDuration.days} days`}
|
||||
{rentalDuration.hours > 0 && `${rentalDuration.hours} hours`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong>Total Cost:</strong>
|
||||
<strong>${totalCost.toFixed(2)}</strong>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted">Select dates to see total cost</p>
|
||||
)}
|
||||
|
||||
{item.rules && (
|
||||
<>
|
||||
<hr />
|
||||
<div>
|
||||
<strong>Rules:</strong>
|
||||
<p className="small">{item.rules}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RentItem;
|
||||
Reference in New Issue
Block a user