getting to payment screen. Bug fixes and formatting changes for item detail
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
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 LocationMap from '../components/LocationMap';
|
||||
import ItemReviews from '../components/ItemReviews';
|
||||
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 LocationMap from "../components/LocationMap";
|
||||
import ItemReviews from "../components/ItemReviews";
|
||||
|
||||
const ItemDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -15,6 +15,13 @@ const ItemDetail: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
||||
const [rentalDates, setRentalDates] = useState({
|
||||
startDate: "",
|
||||
startTime: "14:00",
|
||||
endDate: "",
|
||||
endTime: "12:00",
|
||||
});
|
||||
const [totalCost, setTotalCost] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
@@ -23,6 +30,8 @@ const ItemDetail: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
checkIfAlreadyRenting();
|
||||
} else {
|
||||
setIsAlreadyRenting(false);
|
||||
}
|
||||
}, [id, user]);
|
||||
|
||||
@@ -31,7 +40,7 @@ const ItemDetail: React.FC = () => {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
setItem(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
setError(err.response?.data?.message || "Failed to fetch item");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -42,13 +51,14 @@ const ItemDetail: React.FC = () => {
|
||||
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)
|
||||
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);
|
||||
console.error("Failed to check rental status:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,9 +67,65 @@ const ItemDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRent = () => {
|
||||
navigate(`/items/${id}/rent`);
|
||||
const params = new URLSearchParams({
|
||||
startDate: rentalDates.startDate,
|
||||
startTime: rentalDates.startTime,
|
||||
endDate: rentalDates.endDate,
|
||||
endTime: rentalDates.endTime
|
||||
});
|
||||
navigate(`/items/${id}/rent?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleDateTimeChange = (field: string, value: string) => {
|
||||
setRentalDates((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const calculateTotalCost = () => {
|
||||
if (!item || !rentalDates.startDate || !rentalDates.endDate) {
|
||||
setTotalCost(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateTime = new Date(
|
||||
`${rentalDates.startDate}T${rentalDates.startTime}`
|
||||
);
|
||||
const endDateTime = new Date(
|
||||
`${rentalDates.endDate}T${rentalDates.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);
|
||||
};
|
||||
|
||||
const generateTimeOptions = () => {
|
||||
const options = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const time24 = `${hour.toString().padStart(2, "0")}:00`;
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
const period = hour < 12 ? "AM" : "PM";
|
||||
const time12 = `${hour12}:00 ${period}`;
|
||||
options.push({ value: time24, label: time12 });
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
calculateTotalCost();
|
||||
}, [rentalDates, item]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
@@ -76,7 +142,7 @@ const ItemDetail: React.FC = () => {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
{error || "Item not found"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -96,139 +162,318 @@ const ItemDetail: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 className="row">
|
||||
<div className="col-md-8">
|
||||
{/* Images */}
|
||||
{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>
|
||||
) : (
|
||||
<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">
|
||||
{/* Item Name */}
|
||||
<h1 className="mb-3">{item.name}</h1>
|
||||
|
||||
{/* Owner Info */}
|
||||
{item.owner && (
|
||||
<div
|
||||
className="d-flex align-items-center mb-4"
|
||||
onClick={() => navigate(`/users/${item.ownerId}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{item.owner.profileImage ? (
|
||||
<img
|
||||
src={item.owner.profileImage}
|
||||
alt={`${item.owner.firstName} ${item.owner.lastName}`}
|
||||
className="rounded-circle me-2"
|
||||
style={{ width: '30px', height: '30px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted">{item.owner.firstName} {item.owner.lastName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (no label) */}
|
||||
{/* Item Name */}
|
||||
<h1 className="mb-3">{item.name}</h1>
|
||||
|
||||
{/* Owner Info */}
|
||||
{item.owner && (
|
||||
<div
|
||||
className="d-flex align-items-center mb-4"
|
||||
onClick={() => navigate(`/users/${item.ownerId}`)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{item.owner.profileImage ? (
|
||||
<img
|
||||
src={item.owner.profileImage}
|
||||
alt={`${item.owner.firstName} ${item.owner.lastName}`}
|
||||
className="rounded-circle me-2"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: "30px", height: "30px" }}
|
||||
>
|
||||
<i
|
||||
className="bi bi-person-fill text-white"
|
||||
style={{ fontSize: "0.8rem" }}
|
||||
></i>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted">
|
||||
{item.owner.firstName} {item.owner.lastName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (no label) */}
|
||||
<div className="mb-4">
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<LocationMap
|
||||
latitude={item.latitude}
|
||||
longitude={item.longitude}
|
||||
location={item.location}
|
||||
itemName={item.name}
|
||||
/>
|
||||
|
||||
<ItemReviews itemId={item.id} />
|
||||
|
||||
{/* Rules */}
|
||||
{item.rules && (
|
||||
<div className="mb-4">
|
||||
<p>{item.description}</p>
|
||||
<h5>Rules</h5>
|
||||
<p>{item.rules}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Side - Pricing Card */}
|
||||
<div className="col-md-4">
|
||||
<div className="card">
|
||||
<div className="card-body text-center">
|
||||
{item.pricePerHour && (
|
||||
<div className="mb-2">
|
||||
<h4>${Math.floor(item.pricePerHour)}/Hour</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerDay && (
|
||||
<div className="mb-2">
|
||||
<h4>${Math.floor(item.pricePerDay)}/Day</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerWeek && (
|
||||
<div className="mb-2">
|
||||
<h4>${Math.floor(item.pricePerWeek)}/Week</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerMonth && (
|
||||
<div className="mb-4">
|
||||
<h4>${Math.floor(item.pricePerMonth)}/Month</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isOwner && item.availability && !isAlreadyRenting && (
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary" onClick={handleRent}>
|
||||
Rent This Item
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isOwner && isAlreadyRenting && (
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-success" disabled style={{ opacity: 0.8 }}>
|
||||
✓ Renting
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Cancellation Policy */}
|
||||
<div className="mb-4">
|
||||
<h5>Cancellation Policy</h5>
|
||||
<div className="small">
|
||||
<div className="mb-2">
|
||||
Full refund: Cancel 48+ hours before rental start time
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
50% refund: Cancel 24-48 hours before rental start time
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
No refund: Cancel within 24 hours of rental start time
|
||||
</div>
|
||||
<div>Replacement Cost: ${item.replacementCost}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<LocationMap
|
||||
latitude={item.latitude}
|
||||
longitude={item.longitude}
|
||||
location={item.location}
|
||||
itemName={item.name}
|
||||
/>
|
||||
{/* Right Side - Sticky Pricing Card */}
|
||||
<div className="col-md-4">
|
||||
<div
|
||||
className="card sticky-pricing-card"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "20px",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div className="card-body text-center">
|
||||
{(() => {
|
||||
const hasAnyPositivePrice =
|
||||
(item.pricePerHour !== undefined &&
|
||||
Number(item.pricePerHour) > 0) ||
|
||||
(item.pricePerDay !== undefined &&
|
||||
Number(item.pricePerDay) > 0) ||
|
||||
(item.pricePerWeek !== undefined &&
|
||||
Number(item.pricePerWeek) > 0) ||
|
||||
(item.pricePerMonth !== undefined &&
|
||||
Number(item.pricePerMonth) > 0);
|
||||
|
||||
<ItemReviews itemId={item.id} />
|
||||
const hasAnyZeroPrice =
|
||||
(item.pricePerHour !== undefined &&
|
||||
Number(item.pricePerHour) === 0) ||
|
||||
(item.pricePerDay !== undefined &&
|
||||
Number(item.pricePerDay) === 0) ||
|
||||
(item.pricePerWeek !== undefined &&
|
||||
Number(item.pricePerWeek) === 0) ||
|
||||
(item.pricePerMonth !== undefined &&
|
||||
Number(item.pricePerMonth) === 0);
|
||||
|
||||
{/* Rules */}
|
||||
{item.rules && (
|
||||
<div className="mb-4">
|
||||
<h5>Rules</h5>
|
||||
<p>{item.rules}</p>
|
||||
if (!hasAnyPositivePrice && hasAnyZeroPrice) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4>Free to Borrow</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.pricePerHour !== undefined &&
|
||||
Number(item.pricePerHour) > 0 && (
|
||||
<div className="mb-2">
|
||||
<h4>
|
||||
${Math.floor(Number(item.pricePerHour))}/Hour
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerDay !== undefined &&
|
||||
Number(item.pricePerDay) > 0 && (
|
||||
<div className="mb-2">
|
||||
<h4>
|
||||
${Math.floor(Number(item.pricePerDay))}/Day
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerWeek !== undefined &&
|
||||
Number(item.pricePerWeek) > 0 && (
|
||||
<div className="mb-2">
|
||||
<h4>
|
||||
${Math.floor(Number(item.pricePerWeek))}/Week
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerMonth !== undefined &&
|
||||
Number(item.pricePerMonth) > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4>
|
||||
${Math.floor(Number(item.pricePerMonth))}/Month
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Rental Period Selection - Only show for non-owners */}
|
||||
{!isOwner && item.availability && !isAlreadyRenting && (
|
||||
<>
|
||||
<hr />
|
||||
<div className="text-start">
|
||||
<div className="mb-2">
|
||||
<label className="form-label small mb-1">Start</label>
|
||||
<div className="input-group input-group-sm">
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={rentalDates.startDate}
|
||||
onChange={(e) =>
|
||||
handleDateTimeChange("startDate", e.target.value)
|
||||
}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
style={{ flex: '1 1 50%' }}
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
value={rentalDates.startTime}
|
||||
onChange={(e) =>
|
||||
handleDateTimeChange("startTime", e.target.value)
|
||||
}
|
||||
style={{ flex: '1 1 50%' }}
|
||||
>
|
||||
{generateTimeOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label small mb-1">End</label>
|
||||
<div className="input-group input-group-sm">
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={rentalDates.endDate}
|
||||
onChange={(e) =>
|
||||
handleDateTimeChange("endDate", e.target.value)
|
||||
}
|
||||
min={
|
||||
rentalDates.startDate ||
|
||||
new Date().toISOString().split("T")[0]
|
||||
}
|
||||
style={{ flex: '1 1 50%' }}
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
value={rentalDates.endTime}
|
||||
onChange={(e) =>
|
||||
handleDateTimeChange("endTime", e.target.value)
|
||||
}
|
||||
style={{ flex: '1 1 50%' }}
|
||||
>
|
||||
{generateTimeOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rentalDates.startDate &&
|
||||
rentalDates.endDate &&
|
||||
totalCost > 0 && (
|
||||
<div className="mb-3 p-2 bg-light rounded text-center">
|
||||
<strong>Total: ${totalCost}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isOwner && item.availability && !isAlreadyRenting && (
|
||||
<div className="d-grid">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleRent}
|
||||
disabled={
|
||||
!rentalDates.startDate || !rentalDates.endDate
|
||||
}
|
||||
>
|
||||
Rent Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isOwner && isAlreadyRenting && (
|
||||
<div className="d-grid">
|
||||
<button
|
||||
className="btn btn-success"
|
||||
disabled
|
||||
style={{ opacity: 0.8 }}
|
||||
>
|
||||
✓ Renting
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replacement Cost (under Rules) */}
|
||||
<div className="mb-4">
|
||||
<p><strong>Replacement Cost:</strong> ${item.replacementCost}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,4 +481,4 @@ const ItemDetail: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemDetail;
|
||||
export default ItemDetail;
|
||||
|
||||
Reference in New Issue
Block a user