Files
rentall-app/frontend/src/pages/ItemDetail.tsx
2025-11-06 15:54:27 -05:00

635 lines
23 KiB
TypeScript

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 GoogleMapWithRadius from "../components/GoogleMapWithRadius";
import ItemReviews from "../components/ItemReviews";
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);
const [rentalDates, setRentalDates] = useState({
startDate: "",
startTime: "14:00",
endDate: "",
endTime: "12:00",
});
const [totalCost, setTotalCost] = useState(0);
const [costLoading, setCostLoading] = useState(false);
const [costError, setCostError] = useState<string | null>(null);
useEffect(() => {
fetchItem();
}, [id]);
useEffect(() => {
if (user) {
checkIfAlreadyRenting();
} else {
setIsAlreadyRenting(false);
}
}, [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.getRentals();
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 = () => {
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 = async () => {
if (!item || !rentalDates.startDate || !rentalDates.endDate) {
setTotalCost(0);
return;
}
try {
setCostLoading(true);
setCostError(null);
const startDateTime = new Date(
`${rentalDates.startDate}T${rentalDates.startTime}`
).toISOString();
const endDateTime = new Date(
`${rentalDates.endDate}T${rentalDates.endTime}`
).toISOString();
const response = await rentalAPI.getRentalCostPreview({
itemId: item.id,
startDateTime,
endDateTime,
});
setTotalCost(response.data.baseAmount);
} catch (err: any) {
setCostError(err.response?.data?.error || "Failed to calculate cost");
setTotalCost(0);
} finally {
setCostLoading(false);
}
};
const generateTimeOptions = (item: Item | null, selectedDate: string) => {
const options = [];
let availableAfter = "00:00";
let availableBefore = "23:59";
// Determine time constraints only if we have both item and a valid selected date
if (item && selectedDate && selectedDate.trim() !== "") {
const date = new Date(selectedDate);
const dayName = date
.toLocaleDateString("en-US", { weekday: "long" })
.toLowerCase() as
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";
// Use day-specific times if available
if (
item.specifyTimesPerDay &&
item.weeklyTimes &&
item.weeklyTimes[dayName]
) {
const dayTimes = item.weeklyTimes[dayName];
availableAfter = dayTimes.availableAfter;
availableBefore = dayTimes.availableBefore;
console.log("Using day-specific times:", {
availableAfter,
availableBefore,
});
}
// Otherwise use global times
else if (item.availableAfter && item.availableBefore) {
availableAfter = item.availableAfter;
availableBefore = item.availableBefore;
} else {
console.log(
"No time constraints found, using default 24-hour availability"
);
}
}
for (let hour = 0; hour < 24; hour++) {
const time24 = `${hour.toString().padStart(2, "0")}:00`;
// Ensure consistent format for comparison (normalize to HH:MM)
const normalizedAvailableAfter =
availableAfter.length === 5 ? availableAfter : availableAfter + ":00";
const normalizedAvailableBefore =
availableBefore.length === 5
? availableBefore
: availableBefore + ":00";
// Check if this time is within the available range
if (
time24 >= normalizedAvailableAfter &&
time24 <= normalizedAvailableBefore
) {
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 });
}
}
// If no options are available, return at least one option to prevent empty dropdown
if (options.length === 0) {
console.log("No valid time options found, showing Not Available");
options.push({ value: "00:00", label: "Not Available" });
}
return options;
};
useEffect(() => {
calculateTotalCost();
}, [rentalDates, item]);
// Validate and adjust selected times based on item availability
useEffect(() => {
if (!item) return;
const validateAndAdjustTime = (date: string, currentTime: string) => {
if (!date) return currentTime;
const availableOptions = generateTimeOptions(item, date);
if (availableOptions.length === 0) return currentTime;
// If current time is not in available options, use the first available time
const isCurrentTimeValid = availableOptions.some(
(option) => option.value === currentTime
);
return isCurrentTimeValid ? currentTime : availableOptions[0].value;
};
const adjustedStartTime = validateAndAdjustTime(
rentalDates.startDate,
rentalDates.startTime
);
const adjustedEndTime = validateAndAdjustTime(
rentalDates.endDate || rentalDates.startDate,
rentalDates.endTime
);
// Update state if times have changed
if (
adjustedStartTime !== rentalDates.startTime ||
adjustedEndTime !== rentalDates.endTime
) {
setRentalDates((prev) => ({
...prev,
startTime: adjustedStartTime,
endTime: adjustedEndTime,
}));
}
}, [item, rentalDates.startDate, rentalDates.endDate]);
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">
{isOwner && (
<div className="d-flex justify-content-end mb-3">
<button className="btn btn-outline-primary" onClick={handleEdit}>
<i className="bi bi-pencil me-2"></i>
Edit Listing
</button>
</div>
)}
<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>
)}
{/* 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 */}
<GoogleMapWithRadius
latitude={item.latitude}
longitude={item.longitude}
/>
<ItemReviews itemId={item.id} />
{/* Rules */}
{item.rules && (
<div className="mb-4">
<h5>Rules</h5>
<p>{item.rules}</p>
</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>
{/* 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);
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);
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-2">
<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().toLocaleDateString()}
style={{ flex: "1 1 50%" }}
/>
<select
className="form-select"
value={rentalDates.startTime}
onChange={(e) =>
handleDateTimeChange(
"startTime",
e.target.value
)
}
style={{ flex: "1 1 50%" }}
disabled={
!!(
rentalDates.startDate &&
generateTimeOptions(
item,
rentalDates.startDate
).every(
(opt) => opt.label === "Not Available"
)
)
}
>
{generateTimeOptions(
item,
rentalDates.startDate
).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().toLocaleDateString()
}
style={{ flex: "1 1 50%" }}
/>
<select
className="form-select"
value={rentalDates.endTime}
onChange={(e) =>
handleDateTimeChange("endTime", e.target.value)
}
style={{ flex: "1 1 50%" }}
disabled={
!!(
(rentalDates.endDate ||
rentalDates.startDate) &&
generateTimeOptions(
item,
rentalDates.endDate || rentalDates.startDate
).every(
(opt) => opt.label === "Not Available"
)
)
}
>
{generateTimeOptions(
item,
rentalDates.endDate || rentalDates.startDate
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{rentalDates.startDate && rentalDates.endDate && (
<div className="mb-3 p-2 bg-light rounded text-center">
{costLoading ? (
<div className="spinner-border spinner-border-sm" role="status">
<span className="visually-hidden">Calculating...</span>
</div>
) : costError ? (
<small className="text-danger">{costError}</small>
) : totalCost > 0 ? (
<strong>Total: ${totalCost}</strong>
) : null}
</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>
</div>
</div>
</div>
</div>
);
};
export default ItemDetail;