389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import React, { useState, useEffect, useRef } 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";
|
|
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
|
|
|
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 [error, setError] = useState<string | null>(null);
|
|
|
|
const [formData, setFormData] = useState({
|
|
deliveryMethod: "pickup" as "pickup" | "delivery",
|
|
deliveryAddress: "",
|
|
});
|
|
|
|
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 [completed, setCompleted] = useState(false);
|
|
|
|
const convertToUTC = (dateString: string, timeString: string): string => {
|
|
if (!dateString || !timeString) {
|
|
throw new Error("Date and time are required");
|
|
}
|
|
|
|
// Create date in user's local timezone
|
|
const localDateTime = new Date(`${dateString}T${timeString}`);
|
|
|
|
// Return UTC ISO string
|
|
return localDateTime.toISOString();
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return "";
|
|
// Use safe date parsing to avoid timezone issues
|
|
const [year, month, day] = dateString.split("-");
|
|
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
|
return date.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(() => {
|
|
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");
|
|
}
|
|
|
|
// 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 getRentalData = () => {
|
|
try {
|
|
const startDateTime = convertToUTC(
|
|
manualSelection.startDate,
|
|
manualSelection.startTime
|
|
);
|
|
const endDateTime = convertToUTC(
|
|
manualSelection.endDate,
|
|
manualSelection.endTime
|
|
);
|
|
|
|
return {
|
|
itemId: id,
|
|
startDateTime,
|
|
endDateTime,
|
|
deliveryMethod: formData.deliveryMethod,
|
|
deliveryAddress: formData.deliveryAddress,
|
|
totalAmount: totalCost,
|
|
};
|
|
} catch (error: any) {
|
|
setError(error.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const handleFreeBorrow = async () => {
|
|
const rentalData = getRentalData();
|
|
if (!rentalData) return;
|
|
|
|
try {
|
|
setError(null);
|
|
await rentalAPI.createRental(rentalData);
|
|
setCompleted(true);
|
|
} catch (error: any) {
|
|
setError(
|
|
error.response?.data?.error || "Failed to create rental request"
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleChange = (
|
|
e: React.ChangeEvent<
|
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="row">
|
|
<div className="col-md-8">
|
|
<h1>Renting {item.name}</h1>
|
|
|
|
{error && (
|
|
<div className="alert alert-danger" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="row">
|
|
<div className="col-md-8">
|
|
{completed ? (
|
|
<div className="card mb-4">
|
|
<div className="card-body text-center">
|
|
<div className="alert alert-success">
|
|
<i className="bi bi-check-circle-fill display-1 text-success mb-3"></i>
|
|
<h3>Rental Request Sent!</h3>
|
|
<p className="mb-3">
|
|
Your rental request has been submitted to the owner.
|
|
You'll only be charged if they approve your request.
|
|
</p>
|
|
<div className="d-grid gap-2 d-md-block">
|
|
<button
|
|
className="btn btn-primary me-2"
|
|
onClick={() => navigate("/renting")}
|
|
>
|
|
View My Rentals
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => navigate("/")}
|
|
>
|
|
Continue Browsing
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="card mb-4">
|
|
<div className="card-body">
|
|
<h5 className="card-title">
|
|
{totalCost === 0
|
|
? "Complete Your Borrow Request"
|
|
: "Complete Your Rental Request"}
|
|
</h5>
|
|
{totalCost > 0 && (
|
|
<p className="text-muted small mb-3">
|
|
Add your payment method to complete your rental request.
|
|
You'll only be charged if the owner approves your
|
|
request.
|
|
</p>
|
|
)}
|
|
|
|
{!manualSelection.startDate ||
|
|
!manualSelection.endDate ||
|
|
!getRentalData() ? (
|
|
<div className="alert alert-info">
|
|
<i className="bi bi-info-circle me-2"></i>
|
|
Please complete the rental dates and details above to
|
|
proceed with{" "}
|
|
{totalCost === 0
|
|
? "your borrow request"
|
|
: "payment setup"}
|
|
.
|
|
</div>
|
|
) : totalCost === 0 ? (
|
|
<>
|
|
<div className="d-grid gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={handleFreeBorrow}
|
|
>
|
|
Confirm Borrow Request
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => navigate(`/items/${id}`)}
|
|
>
|
|
Cancel Request
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<EmbeddedStripeCheckout
|
|
rentalData={getRentalData()}
|
|
onSuccess={() => setCompleted(true)}
|
|
onError={(error) => setError(error)}
|
|
/>
|
|
|
|
<div className="text-center mt-3">
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => navigate(`/items/${id}`)}
|
|
>
|
|
Cancel Request
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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",
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<h6>{item.name}</h6>
|
|
<p className="text-muted small">
|
|
{item.city && item.state
|
|
? `${item.city}, ${item.state}`
|
|
: ""}
|
|
</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>
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Total Cost */}
|
|
{totalCost > 0 && (
|
|
<>
|
|
<hr />
|
|
<div className="d-flex justify-content-between">
|
|
<strong>Total:</strong>
|
|
<strong>${totalCost}</strong>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RentItem;
|