getting to payment screen. Bug fixes and formatting changes for item detail

This commit is contained in:
jackiettran
2025-08-21 16:44:05 -04:00
parent b624841350
commit 022c0b9c06
10 changed files with 859 additions and 815 deletions

View File

@@ -1,158 +1,111 @@
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';
import React, { useState, useEffect } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { Item } from "../types";
import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from "../services/api";
const RentItem: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [searchParams] = useSearchParams();
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 [manualSelection, setManualSelection] = useState({
startDate: '',
startTime: '09:00',
endDate: '',
endTime: '17:00'
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 [manualSelection, setManualSelection] = useState({
startDate: searchParams.get("startDate") || "",
startTime: searchParams.get("startTime") || "09:00",
endDate: searchParams.get("endDate") || "",
endTime: searchParams.get("endTime") || "17:00",
});
const [totalCost, setTotalCost] = useState(0);
const [rentalDuration, setRentalDuration] = useState({ days: 0, hours: 0 });
const formatDate = (dateString: string) => {
if (!dateString) return "";
return new Date(dateString).toLocaleDateString();
};
const formatTime = (timeString: string) => {
if (!timeString) return "";
const [hour, minute] = timeString.split(":");
const hour12 =
parseInt(hour) === 0
? 12
: parseInt(hour) > 12
? parseInt(hour) - 12
: parseInt(hour);
const period = parseInt(hour) < 12 ? "AM" : "PM";
return `${hour12}:${minute} ${period}`;
};
const calculateTotalCost = () => {
if (!item || !manualSelection.startDate || !manualSelection.endDate) {
setTotalCost(0);
return;
}
const startDateTime = new Date(
`${manualSelection.startDate}T${manualSelection.startTime}`
);
const endDateTime = new Date(
`${manualSelection.endDate}T${manualSelection.endTime}`
);
const diffMs = endDateTime.getTime() - startDateTime.getTime();
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
let cost = 0;
if (item.pricePerHour && diffHours <= 24) {
cost = diffHours * Number(item.pricePerHour);
} else if (item.pricePerDay) {
cost = diffDays * Number(item.pricePerDay);
}
setTotalCost(cost);
};
useEffect(() => {
fetchItem();
}, [id]);
useEffect(() => {
calculateTotal();
}, [selectedPeriods, item]);
useEffect(() => {
// Sync manual selection with selected periods
if (selectedPeriods.length > 0) {
const period = selectedPeriods[0];
// Extract hours from the Date objects if startTime/endTime not provided
let startTimeStr = period.startTime;
let endTimeStr = period.endTime;
if (!startTimeStr) {
const startHour = period.startDate.getHours();
startTimeStr = `${startHour.toString().padStart(2, '0')}:00`;
}
if (!endTimeStr) {
const endHour = period.endDate.getHours();
// If the end hour is 23:59:59, show it as 00:00 of the next day
if (endHour === 23 && period.endDate.getMinutes() === 59) {
endTimeStr = '00:00';
// Adjust the end date to show the next day
const adjustedEndDate = new Date(period.endDate);
adjustedEndDate.setDate(adjustedEndDate.getDate() + 1);
setManualSelection({
startDate: period.startDate.toISOString().split('T')[0],
startTime: startTimeStr,
endDate: adjustedEndDate.toISOString().split('T')[0],
endTime: endTimeStr
});
return;
} else {
endTimeStr = `${endHour.toString().padStart(2, '0')}:00`;
}
}
setManualSelection({
startDate: period.startDate.toISOString().split('T')[0],
startTime: startTimeStr,
endDate: period.endDate.toISOString().split('T')[0],
endTime: endTimeStr
});
}
}, [selectedPeriods]);
calculateTotalCost();
}, [item, manualSelection]);
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');
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');
setError("You cannot rent your own item");
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to fetch item');
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;
@@ -161,121 +114,70 @@ const RentItem: React.FC = () => {
setError(null);
try {
if (selectedPeriods.length === 0) {
setError('Please select a rental period');
if (!manualSelection.startDate || !manualSelection.endDate) {
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,
startDate: manualSelection.startDate,
endDate: manualSelection.endDate,
startTime: manualSelection.startTime,
endTime: manualSelection.endTime,
totalAmount: totalCost,
deliveryMethod: formData.deliveryMethod,
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
deliveryMethod: "pickup",
};
await rentalAPI.createRental(rentalData);
navigate('/my-rentals');
navigate("/my-rentals");
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to create rental');
setError(err.response?.data?.message || "Failed to create rental");
setSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const { name, value } = e.target;
if (name === 'cardNumber') {
if (name === "cardNumber") {
// Remove all non-digits
const cleaned = value.replace(/\D/g, '');
const cleaned = value.replace(/\D/g, "");
// Add spaces every 4 digits
const formatted = cleaned.match(/.{1,4}/g)?.join(' ') || cleaned;
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 }));
setFormData((prev) => ({ ...prev, [name]: formatted }));
}
} else if (name === 'cardExpiry') {
} else if (name === "cardExpiry") {
// Remove all non-digits
const cleaned = value.replace(/\D/g, '');
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);
formatted = cleaned.slice(0, 2) + "/" + cleaned.slice(2, 4);
}
// Limit to 4 digits
if (cleaned.length <= 4) {
setFormData(prev => ({ ...prev, [name]: formatted }));
setFormData((prev) => ({ ...prev, [name]: formatted }));
}
} else if (name === 'cardCVC') {
} else if (name === "cardCVC") {
// Only allow digits and limit to 4
const cleaned = value.replace(/\D/g, '');
const cleaned = value.replace(/\D/g, "");
if (cleaned.length <= 4) {
setFormData(prev => ({ ...prev, [name]: cleaned }));
setFormData((prev) => ({ ...prev, [name]: cleaned }));
}
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
const handleManualSelectionChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const updatedSelection = {
...manualSelection,
[e.target.name]: e.target.value
};
setManualSelection(updatedSelection);
// Automatically apply selection if both dates are set
if (updatedSelection.startDate && updatedSelection.endDate) {
// Create dates with time set to midnight to avoid timezone issues
const start = new Date(updatedSelection.startDate + 'T00:00:00');
const end = new Date(updatedSelection.endDate + 'T00:00:00');
// Add time for both hourly and daily rentals
const [startHour, startMin] = updatedSelection.startTime.split(':').map(Number);
const [endHour, endMin] = updatedSelection.endTime.split(':').map(Number);
start.setHours(startHour, startMin, 0, 0);
end.setHours(endHour, endMin, 0, 0);
// Note: We keep the times as selected by the user
// The calendar will interpret 00:00 correctly
// Validate dates
if (end < start) {
setError('End date/time must be after start date/time');
return;
}
// Check if dates are available
const unavailable = item?.unavailablePeriods?.some(period => {
const periodStart = new Date(period.startDate);
const periodEnd = new Date(period.endDate);
return (start >= periodStart && start <= periodEnd) ||
(end >= periodStart && end <= periodEnd) ||
(start <= periodStart && end >= periodEnd);
});
if (unavailable) {
setError('Selected dates include unavailable periods');
return;
}
setError(null);
setSelectedPeriods([{
id: Date.now().toString(),
startDate: start,
endDate: end,
startTime: updatedSelection.startTime,
endTime: updatedSelection.endTime
}]);
setFormData((prev) => ({ ...prev, [name]: value }));
}
};
@@ -291,11 +193,15 @@ const RentItem: React.FC = () => {
);
}
if (!item || error === 'You cannot rent your own item' || error === 'This item is not available for rent') {
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'}
{error || "Item not found"}
</div>
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
Go Back
@@ -304,338 +210,213 @@ const RentItem: React.FC = () => {
);
}
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>
<h1>Renting {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 || []}
onPeriodsChange={() => {}} // Read-only for renters
mode="renter"
selectedRentalPeriod={selectedPeriods.length > 0 ? {
id: selectedPeriods[0].id,
startDate: selectedPeriods[0].startDate,
endDate: selectedPeriods[0].endDate,
startTime: selectedPeriods[0].startTime,
endTime: selectedPeriods[0].endTime,
isRentalSelection: true
} : undefined}
onRentalSelect={(period) => {
// Update selected periods
setSelectedPeriods([{
id: Date.now().toString(),
startDate: period.startDate,
endDate: period.endDate,
startTime: period.startTime,
endTime: period.endTime
}]);
// Update manual selection to match calendar
setManualSelection({
startDate: period.startDate.toISOString().split('T')[0],
startTime: period.startTime || '09:00',
endDate: period.endDate.toISOString().split('T')[0],
endTime: period.endTime || '17:00'
});
}}
/>
<div className="mt-4">
<div className="row g-3">
<div className="col-md-3">
<label htmlFor="startDate" className="form-label">Start Date</label>
<div className="row">
<div className="col-md-8">
<form onSubmit={handleSubmit}>
<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="date"
type="text"
className="form-control"
id="startDate"
name="startDate"
value={manualSelection.startDate}
onChange={handleManualSelectionChange}
min={new Date().toISOString().split('T')[0]}
id="cardName"
name="cardName"
value={formData.cardName}
onChange={handleChange}
placeholder="John Doe"
required
/>
</div>
<div className="col-md-3">
<label htmlFor="startTime" className="form-label">Start Time</label>
<select
className="form-select"
id="startTime"
name="startTime"
value={manualSelection.startTime}
onChange={handleManualSelectionChange}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
const period = i < 12 ? 'AM' : 'PM';
return (
<option key={i} value={`${i.toString().padStart(2, '0')}:00`}>
{hour}:00 {period}
</option>
);
})}
</select>
<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 className="col-md-3">
<label htmlFor="endDate" className="form-label">End Date</label>
<input
type="date"
className="form-control"
id="endDate"
name="endDate"
value={manualSelection.endDate}
onChange={handleManualSelectionChange}
min={manualSelection.startDate || new Date().toISOString().split('T')[0]}
/>
</div>
<div className="col-md-3">
<label htmlFor="endTime" className="form-label">End Time</label>
<select
className="form-select"
id="endTime"
name="endTime"
value={manualSelection.endTime}
onChange={handleManualSelectionChange}
<div className="d-grid gap-2">
<button
type="submit"
className="btn btn-primary"
disabled={
!manualSelection.startDate || !manualSelection.endDate
}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
const period = i < 12 ? 'AM' : 'PM';
return (
<option key={i} value={`${i.toString().padStart(2, '0')}:00`}>
{hour}:00 {period}
</option>
);
})}
</select>
{submitting
? "Processing..."
: `Confirm Rental - $${totalCost}`}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate(`/items/${id}`)}
>
Cancel
</button>
</div>
</div>
</div>
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
<div className="alert alert-warning mt-3">
Minimum rental period is {minDays} days
</div>
)}
</div>
</form>
</div>
<div className="card mb-4">
<div className="card-body">
<div className="my-3">
<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'}
<div className="col-md-4">
<div className="card">
<div className="card-body">
{item.images && item.images[0] && (
<img
src={item.images[0]}
alt={item.name}
className="img-fluid rounded mb-3"
style={{
width: "100%",
height: "150px",
objectFit: "cover",
}}
/>
{item.localDeliveryRadius && (
<div className="form-text">
Delivery available within {item.localDeliveryRadius} miles
</div>
)}
<h6>{item.name}</h6>
<p className="text-muted small">
{item.city && item.state
? `${item.city}, ${item.state}`
: item.location}
</p>
<hr />
{/* Pricing */}
<div className="mb-3 text-center">
{totalCost === 0 ? (
<h6>Free to Borrow</h6>
) : (
<>
{item.pricePerHour && Number(item.pricePerHour) > 0 && (
<h6>${Math.floor(Number(item.pricePerHour))}/Hour</h6>
)}
{item.pricePerDay && Number(item.pricePerDay) > 0 && (
<h6>${Math.floor(Number(item.pricePerDay))}/Day</h6>
)}
</>
)}
</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`}
{/* Selected Dates */}
{manualSelection.startDate && manualSelection.endDate && (
<div className="mb-3">
<div className="small mb-1">
<strong>Check-in:</strong>{" "}
{formatDate(manualSelection.startDate)} at{" "}
{formatTime(manualSelection.startTime)}
</div>
<div className="small">
<strong>Check-out:</strong>{" "}
{formatDate(manualSelection.endDate)} at{" "}
{formatTime(manualSelection.endTime)}
</div>
</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>
</>
)}
)}
{/* Total Cost */}
{totalCost > 0 && (
<>
<hr />
<div className="d-flex justify-content-between">
<strong>Total:</strong>
<strong>${totalCost}</strong>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
@@ -644,4 +425,4 @@ const RentItem: React.FC = () => {
);
};
export default RentItem;
export default RentItem;