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