payment for rental from renter stripe integration

This commit is contained in:
jackiettran
2025-08-27 19:46:27 -04:00
parent 601e11b7e8
commit 38346bec27
13 changed files with 1090 additions and 421 deletions

View File

@@ -71,7 +71,7 @@ const ItemDetail: React.FC = () => {
startDate: rentalDates.startDate,
startTime: rentalDates.startTime,
endDate: rentalDates.endDate,
endTime: rentalDates.endTime
endTime: rentalDates.endTime,
});
navigate(`/items/${id}/rent?${params.toString()}`);
};
@@ -115,75 +115,74 @@ const ItemDetail: React.FC = () => {
let availableAfter = "00:00";
let availableBefore = "23:59";
console.log('generateTimeOptions called with:', {
itemId: item?.id,
selectedDate,
hasItem: !!item
});
// 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';
console.log('Date analysis:', {
selectedDate,
dayName,
specifyTimesPerDay: item.specifyTimesPerDay,
hasWeeklyTimes: !!item.weeklyTimes,
globalAvailableAfter: item.availableAfter,
globalAvailableBefore: item.availableBefore
});
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]) {
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 });
console.log("Using day-specific times:", {
availableAfter,
availableBefore,
});
}
// Otherwise use global times
else if (item.availableAfter && item.availableBefore) {
availableAfter = item.availableAfter;
availableBefore = item.availableBefore;
console.log('Using global times:', { availableAfter, availableBefore });
} else {
console.log('No time constraints found, using default 24-hour availability');
console.log(
"No time constraints found, using default 24-hour availability"
);
}
} else {
console.log('Missing item or selectedDate, 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";
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) {
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 });
}
}
console.log('Time filtering results:', {
availableAfter,
availableBefore,
optionsGenerated: options.length,
firstFewOptions: options.slice(0, 3)
});
// 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');
console.log("No valid time options found, showing Not Available");
options.push({ value: "00:00", label: "Not Available" });
}
return options;
};
@@ -197,24 +196,35 @@ const ItemDetail: React.FC = () => {
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);
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);
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 => ({
if (
adjustedStartTime !== rentalDates.startTime ||
adjustedEndTime !== rentalDates.endTime
) {
setRentalDates((prev) => ({
...prev,
startTime: adjustedStartTime,
endTime: adjustedEndTime
endTime: adjustedEndTime,
}));
}
}, [item, rentalDates.startDate, rentalDates.endDate]);
@@ -473,21 +483,40 @@ const ItemDetail: React.FC = () => {
className="form-control"
value={rentalDates.startDate}
onChange={(e) =>
handleDateTimeChange("startDate", e.target.value)
handleDateTimeChange(
"startDate",
e.target.value
)
}
min={new Date().toISOString().split("T")[0]}
style={{ flex: '1 1 50%' }}
style={{ flex: "1 1 50%" }}
/>
<select
className="form-select"
value={rentalDates.startTime}
onChange={(e) =>
handleDateTimeChange("startTime", e.target.value)
handleDateTimeChange(
"startTime",
e.target.value
)
}
style={{ flex: "1 1 50%" }}
disabled={
!!(
rentalDates.startDate &&
generateTimeOptions(
item,
rentalDates.startDate
).every(
(opt) => opt.label === "Not Available"
)
)
}
style={{ flex: '1 1 50%' }}
disabled={!!(rentalDates.startDate && generateTimeOptions(item, rentalDates.startDate).every(opt => opt.label === "Not Available"))}
>
{generateTimeOptions(item, rentalDates.startDate).map((option) => (
{generateTimeOptions(
item,
rentalDates.startDate
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
@@ -510,7 +539,7 @@ const ItemDetail: React.FC = () => {
rentalDates.startDate ||
new Date().toISOString().split("T")[0]
}
style={{ flex: '1 1 50%' }}
style={{ flex: "1 1 50%" }}
/>
<select
className="form-select"
@@ -518,10 +547,24 @@ const ItemDetail: React.FC = () => {
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"))}
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) => (
{generateTimeOptions(
item,
rentalDates.endDate || rentalDates.startDate
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>

View File

@@ -45,10 +45,6 @@ const MyRentals: React.FC = () => {
const fetchRentals = async () => {
try {
const response = await rentalAPI.getMyRentals();
console.log("MyRentals data from backend:", response.data);
if (response.data.length > 0) {
console.log("First rental object:", response.data[0]);
}
setRentals(response.data);
} catch (err: any) {
setError(err.response?.data?.message || "Failed to fetch rentals");
@@ -122,7 +118,9 @@ const MyRentals: React.FC = () => {
{renterActiveRentals.length === 0 ? (
<div className="text-center py-5">
<h5 className="text-muted">No Active Rental Requests</h5>
<p className="text-muted">You don't have any rental requests at the moment.</p>
<p className="text-muted">
You don't have any rental requests at the moment.
</p>
<Link to="/items" className="btn btn-primary">
Browse Items to Rent
</Link>
@@ -130,86 +128,107 @@ const MyRentals: React.FC = () => {
) : (
<div className="row">
{renterActiveRentals.map((rental) => (
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
<Link
to={rental.item ? `/items/${rental.item.id}` : "#"}
className="text-decoration-none"
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
const target = e.target as HTMLElement;
if (!rental.item || target.closest("button")) {
e.preventDefault();
}
}}
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
<Link
to={rental.item ? `/items/${rental.item.id}` : "#"}
className="text-decoration-none"
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
const target = e.target as HTMLElement;
if (!rental.item || target.closest("button")) {
e.preventDefault();
}
}}
>
<div
className="card h-100"
style={{ cursor: rental.item ? "pointer" : "default" }}
>
<div className="card h-100" style={{ cursor: rental.item ? "pointer" : "default" }}>
{rental.item?.images && rental.item.images[0] && (
<img
src={rental.item.images[0]}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "cover" }}
/>
)}
<div className="card-body">
<h5 className="card-title text-dark">
{rental.item ? rental.item.name : "Item Unavailable"}
</h5>
{rental.item?.images && rental.item.images[0] && (
<img
src={rental.item.images[0]}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "cover" }}
/>
)}
<div className="card-body">
<h5 className="card-title text-dark">
{rental.item ? rental.item.name : "Item Unavailable"}
</h5>
<div className="mb-2">
<span className={`badge ${
rental.status === "active" ? "bg-success" :
rental.status === "pending" ? "bg-warning" :
rental.status === "confirmed" ? "bg-info" : "bg-danger"
}`}>
{rental.status.charAt(0).toUpperCase() + rental.status.slice(1)}
</span>
{rental.paymentStatus === "paid" && (
<span className="badge bg-success ms-2">Paid</span>
)}
</div>
<p className="mb-1 text-dark">
<strong>Rental Period:</strong>
<br />
<strong>Start:</strong> {formatDateTime(rental.startDate, rental.startTime)}
<br />
<strong>End:</strong> {formatDateTime(rental.endDate, rental.endTime)}
</p>
<p className="mb-1 text-dark">
<strong>Total:</strong> ${rental.totalAmount}
</p>
{rental.owner && (
<p className="mb-1 text-dark">
<strong>Owner:</strong> {rental.owner.firstName} {rental.owner.lastName}
</p>
<div className="mb-2">
<span
className={`badge ${
rental.status === "active"
? "bg-success"
: rental.status === "pending"
? "bg-warning"
: rental.status === "confirmed"
? "bg-info"
: "bg-danger"
}`}
>
{rental.status.charAt(0).toUpperCase() +
rental.status.slice(1)}
</span>
{rental.paymentStatus === "paid" && (
<span className="badge bg-success ms-2">Paid</span>
)}
</div>
{rental.renterPrivateMessage && rental.renterReviewVisible && (
<p className="mb-1 text-dark">
<strong>Rental Period:</strong>
<br />
<strong>Start:</strong>{" "}
{formatDateTime(rental.startDate, rental.startTime)}
<br />
<strong>End:</strong>{" "}
{formatDateTime(rental.endDate, rental.endTime)}
</p>
<p className="mb-1 text-dark">
<strong>Total:</strong> ${rental.totalAmount}
</p>
{rental.owner && (
<p className="mb-1 text-dark">
<strong>Owner:</strong> {rental.owner.firstName}{" "}
{rental.owner.lastName}
</p>
)}
{rental.renterPrivateMessage &&
rental.renterReviewVisible && (
<div className="alert alert-info mt-2 mb-2 p-2 small">
<strong><i className="bi bi-envelope-fill me-1"></i>Private Note from Owner:</strong>
<strong>
<i className="bi bi-envelope-fill me-1"></i>Private
Note from Owner:
</strong>
<br />
{rental.renterPrivateMessage}
</div>
)}
{rental.status === "cancelled" && rental.rejectionReason && (
{rental.status === "cancelled" &&
rental.rejectionReason && (
<div className="alert alert-warning mt-2 mb-1 p-2 small">
<strong>Rejection reason:</strong> {rental.rejectionReason}
<strong>Rejection reason:</strong>{" "}
{rental.rejectionReason}
</div>
)}
<div className="d-flex gap-2 mt-3">
{rental.status === "pending" && (
<button
className="btn btn-sm btn-danger"
onClick={() => handleCancelClick(rental.id)}
>
Cancel
</button>
)}
{rental.status === "active" && !rental.itemRating && !rental.itemReviewSubmittedAt && (
<div className="d-flex gap-2 mt-3">
{rental.status === "pending" && (
<button
className="btn btn-sm btn-danger"
onClick={() => handleCancelClick(rental.id)}
>
Cancel
</button>
)}
{rental.status === "active" &&
!rental.itemRating &&
!rental.itemReviewSubmittedAt && (
<button
className="btn btn-sm btn-primary"
onClick={() => handleReviewClick(rental)}
@@ -217,30 +236,33 @@ const MyRentals: React.FC = () => {
Review
</button>
)}
{rental.itemReviewSubmittedAt && !rental.itemReviewVisible && (
{rental.itemReviewSubmittedAt &&
!rental.itemReviewVisible && (
<div className="text-info small">
<i className="bi bi-clock me-1"></i>
Review Submitted
</div>
)}
{rental.itemReviewVisible && rental.itemRating && (
<div className="text-success small">
<i className="bi bi-check-circle-fill me-1"></i>
Review Published ({rental.itemRating}/5)
</div>
)}
{rental.status === "completed" && rental.rating && !rental.itemRating && (
{rental.itemReviewVisible && rental.itemRating && (
<div className="text-success small">
<i className="bi bi-check-circle-fill me-1"></i>
Review Published ({rental.itemRating}/5)
</div>
)}
{rental.status === "completed" &&
rental.rating &&
!rental.itemRating && (
<div className="text-success small">
<i className="bi bi-check-circle-fill me-1"></i>
Reviewed ({rental.rating}/5)
</div>
)}
</div>
</div>
</div>
</Link>
</div>
))}
</div>
</Link>
</div>
))}
</div>
)}
@@ -275,4 +297,4 @@ const MyRentals: React.FC = () => {
);
};
export default MyRentals;
export default MyRentals;

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { Item } from "../types";
import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from "../services/api";
import StripePaymentForm from "../components/StripePaymentForm";
const RentItem: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -11,16 +12,11 @@ const RentItem: React.FC = () => {
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({
@@ -85,6 +81,29 @@ const RentItem: React.FC = () => {
calculateTotalCost();
}, [item, manualSelection]);
// Save rental data to localStorage whenever the form is ready
useEffect(() => {
if (
item &&
manualSelection.startDate &&
manualSelection.endDate &&
totalCost > 0
) {
const rentalData = {
itemId: item.id,
startDate: manualSelection.startDate,
endDate: manualSelection.endDate,
startTime: manualSelection.startTime,
endTime: manualSelection.endTime,
totalAmount: totalCost,
deliveryMethod: "pickup",
};
localStorage.setItem("pendingRental", JSON.stringify(rentalData));
localStorage.setItem("lastItemId", item.id);
}
}, [item, manualSelection, totalCost]);
const fetchItem = async () => {
try {
const response = await itemAPI.getItem(id!);
@@ -106,36 +125,11 @@ const RentItem: React.FC = () => {
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user || !item) return;
setSubmitting(true);
setError(null);
try {
if (!manualSelection.startDate || !manualSelection.endDate) {
setError("Please select a rental period");
setSubmitting(false);
return;
}
const rentalData = {
itemId: item.id,
startDate: manualSelection.startDate,
endDate: manualSelection.endDate,
startTime: manualSelection.startTime,
endTime: manualSelection.endTime,
totalAmount: totalCost,
deliveryMethod: "pickup",
};
await rentalAPI.createRental(rentalData);
navigate("/my-rentals");
} catch (err: any) {
setError(err.response?.data?.message || "Failed to create rental");
setSubmitting(false);
}
const handlePaymentSuccess = () => {
// This is called when Stripe checkout session is created successfully
// The rental data is already saved to localStorage via useEffect
// The actual rental creation happens in CheckoutReturn component after payment
console.log("Stripe checkout session created successfully");
};
const handleChange = (
@@ -144,41 +138,7 @@ const RentItem: React.FC = () => {
>
) => {
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 }));
}
setFormData((prev) => ({ ...prev, [name]: value }));
};
if (loading) {
@@ -224,128 +184,29 @@ const RentItem: React.FC = () => {
<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="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>
<StripePaymentForm
total={totalCost}
itemName={item.name}
onSuccess={handlePaymentSuccess}
onError={(error) => setError(error)}
disabled={
!manualSelection.startDate || !manualSelection.endDate
}
/>
<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=""
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 className="d-grid gap-2">
<button
type="submit"
className="btn btn-primary"
disabled={
!manualSelection.startDate || !manualSelection.endDate
}
>
{submitting
? "Processing..."
: `Confirm Rental - $${totalCost}`}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate(`/items/${id}`)}
>
Cancel
</button>
</div>
</div>
<button
type="button"
className="btn btn-secondary mt-2"
onClick={() => navigate(`/items/${id}`)}
>
Cancel
</button>
</div>
</form>
</div>
</div>
<div className="col-md-4">