Initial commit - Rentall App

- Full-stack rental marketplace application
- React frontend with TypeScript
- Node.js/Express backend with JWT authentication
- Features: item listings, rental requests, calendar availability, user profiles
This commit is contained in:
jackiettran
2025-07-15 21:21:09 -04:00
commit c09384e3ea
53 changed files with 24425 additions and 0 deletions

View File

@@ -0,0 +1,470 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Item } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { itemAPI, rentalAPI } from '../services/api';
import AvailabilityCalendar from '../components/AvailabilityCalendar';
const RentItem: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [item, setItem] = useState<Item | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
deliveryMethod: 'pickup' as 'pickup' | 'delivery',
deliveryAddress: '',
cardNumber: '',
cardExpiry: '',
cardCVC: '',
cardName: ''
});
const [selectedPeriods, setSelectedPeriods] = useState<Array<{
id: string;
startDate: Date;
endDate: Date;
startTime?: string;
endTime?: string;
}>>([]);
const [totalCost, setTotalCost] = useState(0);
const [rentalDuration, setRentalDuration] = useState({ days: 0, hours: 0 });
useEffect(() => {
fetchItem();
}, [id]);
useEffect(() => {
calculateTotal();
}, [selectedPeriods, item]);
const fetchItem = async () => {
try {
const response = await itemAPI.getItem(id!);
setItem(response.data);
// Check if item is available
if (!response.data.availability) {
setError('This item is not available for rent');
}
// Check if user is trying to rent their own item
if (response.data.ownerId === user?.id) {
setError('You cannot rent your own item');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to fetch item');
} finally {
setLoading(false);
}
};
const calculateTotal = () => {
if (!item || selectedPeriods.length === 0) {
setTotalCost(0);
setRentalDuration({ days: 0, hours: 0 });
return;
}
// For now, we'll use the first selected period
const period = selectedPeriods[0];
const start = new Date(period.startDate);
const end = new Date(period.endDate);
// Add time if hourly rental
if (item.pricePerHour && period.startTime && period.endTime) {
const [startHour, startMin] = period.startTime.split(':').map(Number);
const [endHour, endMin] = period.endTime.split(':').map(Number);
start.setHours(startHour, startMin);
end.setHours(endHour, endMin);
}
const diffMs = end.getTime() - start.getTime();
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
let cost = 0;
let duration = { days: 0, hours: 0 };
if (item.pricePerHour && period.startTime && period.endTime) {
// Hourly rental
cost = diffHours * item.pricePerHour;
duration.hours = diffHours;
} else if (item.pricePerDay) {
// Daily rental
cost = diffDays * item.pricePerDay;
duration.days = diffDays;
}
setTotalCost(cost);
setRentalDuration(duration);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user || !item) return;
setSubmitting(true);
setError(null);
try {
if (selectedPeriods.length === 0) {
setError('Please select a rental period');
setSubmitting(false);
return;
}
const period = selectedPeriods[0];
const rentalData = {
itemId: item.id,
startDate: period.startDate.toISOString().split('T')[0],
endDate: period.endDate.toISOString().split('T')[0],
startTime: period.startTime || undefined,
endTime: period.endTime || undefined,
totalAmount: totalCost,
deliveryMethod: formData.deliveryMethod,
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
};
await rentalAPI.createRental(rentalData);
navigate('/my-rentals');
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to create rental');
setSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name === 'cardNumber') {
// Remove all non-digits
const cleaned = value.replace(/\D/g, '');
// Add spaces every 4 digits
const formatted = cleaned.match(/.{1,4}/g)?.join(' ') || cleaned;
// Limit to 16 digits (19 characters with spaces)
if (cleaned.length <= 16) {
setFormData(prev => ({ ...prev, [name]: formatted }));
}
} else if (name === 'cardExpiry') {
// Remove all non-digits
const cleaned = value.replace(/\D/g, '');
// Add slash after 2 digits
let formatted = cleaned;
if (cleaned.length >= 3) {
formatted = cleaned.slice(0, 2) + '/' + cleaned.slice(2, 4);
}
// Limit to 4 digits
if (cleaned.length <= 4) {
setFormData(prev => ({ ...prev, [name]: formatted }));
}
} else if (name === 'cardCVC') {
// Only allow digits and limit to 4
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 4) {
setFormData(prev => ({ ...prev, [name]: cleaned }));
}
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
if (loading) {
return (
<div className="container mt-5">
<div className="text-center">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!item || error === 'You cannot rent your own item' || error === 'This item is not available for rent') {
return (
<div className="container mt-5">
<div className="alert alert-danger" role="alert">
{error || 'Item not found'}
</div>
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
Go Back
</button>
</div>
);
}
const showHourlyOptions = !!item.pricePerHour;
const minDays = item.minimumRentalDays || 1;
return (
<div className="container mt-4">
<div className="row">
<div className="col-md-8">
<h1>Rent: {item.name}</h1>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Select Rental Period</h5>
<AvailabilityCalendar
unavailablePeriods={[
...(item.unavailablePeriods || []),
...selectedPeriods.map(p => ({ ...p, isRentalSelection: true }))
]}
onPeriodsChange={(periods) => {
// Only handle rental selections
const rentalSelections = periods.filter(p => p.isRentalSelection);
setSelectedPeriods(rentalSelections.map(p => {
const { isRentalSelection, ...rest } = p;
return rest;
}));
}}
priceType={showHourlyOptions ? "hour" : "day"}
isRentalMode={true}
/>
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
<div className="alert alert-warning mt-3">
Minimum rental period is {minDays} days
</div>
)}
{selectedPeriods.length === 0 && (
<div className="alert alert-info mt-3">
Please select your rental dates on the calendar above
</div>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Delivery Options</h5>
<div className="mb-3">
<label htmlFor="deliveryMethod" className="form-label">Delivery Method *</label>
<select
className="form-select"
id="deliveryMethod"
name="deliveryMethod"
value={formData.deliveryMethod}
onChange={handleChange}
>
{item.pickUpAvailable && <option value="pickup">Pick-up</option>}
{item.localDeliveryAvailable && <option value="delivery">Delivery</option>}
</select>
</div>
{formData.deliveryMethod === 'delivery' && (
<div className="mb-3">
<label htmlFor="deliveryAddress" className="form-label">Delivery Address *</label>
<input
type="text"
className="form-control"
id="deliveryAddress"
name="deliveryAddress"
value={formData.deliveryAddress}
onChange={handleChange}
placeholder="Enter delivery address"
required={formData.deliveryMethod === 'delivery'}
/>
{item.localDeliveryRadius && (
<div className="form-text">
Delivery available within {item.localDeliveryRadius} miles
</div>
)}
</div>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Payment</h5>
<div className="mb-3">
<label className="form-label">Payment Method *</label>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="paymentMethod"
id="creditCard"
value="creditCard"
checked
readOnly
/>
<label className="form-check-label" htmlFor="creditCard">
Credit/Debit Card
</label>
</div>
</div>
<div className="row mb-3">
<div className="col-12">
<label htmlFor="cardNumber" className="form-label">Card Number *</label>
<input
type="text"
className="form-control"
id="cardNumber"
name="cardNumber"
value={formData.cardNumber}
onChange={handleChange}
placeholder="1234 5678 9012 3456"
required
/>
</div>
</div>
<div className="row mb-3">
<div className="col-md-6">
<label htmlFor="cardExpiry" className="form-label">Expiry Date *</label>
<input
type="text"
className="form-control"
id="cardExpiry"
name="cardExpiry"
value={formData.cardExpiry}
onChange={handleChange}
placeholder="MM/YY"
required
/>
</div>
<div className="col-md-6">
<label htmlFor="cardCVC" className="form-label">CVC *</label>
<input
type="text"
className="form-control"
id="cardCVC"
name="cardCVC"
value={formData.cardCVC}
onChange={handleChange}
placeholder="123"
required
/>
</div>
</div>
<div className="mb-3">
<label htmlFor="cardName" className="form-label">Name on Card *</label>
<input
type="text"
className="form-control"
id="cardName"
name="cardName"
value={formData.cardName}
onChange={handleChange}
placeholder="John Doe"
required
/>
</div>
<div className="alert alert-info small">
<i className="bi bi-info-circle"></i> Your payment information is secure and encrypted. You will only be charged after the owner accepts your rental request.
</div>
</div>
</div>
<div className="d-grid gap-2">
<button
type="submit"
className="btn btn-primary"
disabled={submitting || selectedPeriods.length === 0 || totalCost === 0 || (rentalDuration.days < minDays && !showHourlyOptions)}
>
{submitting ? 'Processing...' : `Confirm Rental - $${totalCost.toFixed(2)}`}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate(`/items/${id}`)}
>
Cancel
</button>
</div>
</form>
</div>
<div className="col-md-4">
<div className="card">
<div className="card-body">
<h5 className="card-title">Rental Summary</h5>
{item.images && item.images[0] && (
<img
src={item.images[0]}
alt={item.name}
className="img-fluid rounded mb-3"
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
/>
)}
<h6>{item.name}</h6>
<p className="text-muted small">{item.location}</p>
<hr />
<div className="mb-3">
<strong>Pricing:</strong>
{item.pricePerHour && (
<div>${item.pricePerHour}/hour</div>
)}
{item.pricePerDay && (
<div>${item.pricePerDay}/day</div>
)}
</div>
{rentalDuration.days > 0 || rentalDuration.hours > 0 ? (
<>
<div className="mb-3">
<strong>Duration:</strong>
<div>
{rentalDuration.days > 0 && `${rentalDuration.days} days`}
{rentalDuration.hours > 0 && `${rentalDuration.hours} hours`}
</div>
</div>
<hr />
<div className="d-flex justify-content-between">
<strong>Total Cost:</strong>
<strong>${totalCost.toFixed(2)}</strong>
</div>
</>
) : (
<p className="text-muted">Select dates to see total cost</p>
)}
{item.rules && (
<>
<hr />
<div>
<strong>Rules:</strong>
<p className="small">{item.rules}</p>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default RentItem;