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,127 +1,123 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require("sequelize");
const sequelize = require('../config/database'); const sequelize = require("../config/database");
const Item = sequelize.define('Item', { const Item = sequelize.define("Item", {
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true primaryKey: true,
}, },
name: { name: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false allowNull: false,
}, },
description: { description: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false allowNull: false,
}, },
pickUpAvailable: { pickUpAvailable: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false defaultValue: false,
}, },
localDeliveryAvailable: { localDeliveryAvailable: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false defaultValue: false,
}, },
localDeliveryRadius: { localDeliveryRadius: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
validate: { validate: {
min: 1, min: 1,
max: 100 max: 100,
} },
}, },
shippingAvailable: { shippingAvailable: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false defaultValue: false,
}, },
inPlaceUseAvailable: { inPlaceUseAvailable: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false defaultValue: false,
}, },
pricePerHour: { pricePerHour: {
type: DataTypes.DECIMAL(10, 2) type: DataTypes.DECIMAL(10, 2),
}, },
pricePerDay: { pricePerDay: {
type: DataTypes.DECIMAL(10, 2) type: DataTypes.DECIMAL(10, 2),
}, },
replacementCost: { replacementCost: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false allowNull: false,
},
location: {
type: DataTypes.STRING,
allowNull: false
}, },
address1: { address1: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
address2: { address2: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
city: { city: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
state: { state: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
zipCode: { zipCode: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
country: { country: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
latitude: { latitude: {
type: DataTypes.DECIMAL(10, 8) type: DataTypes.DECIMAL(10, 8),
}, },
longitude: { longitude: {
type: DataTypes.DECIMAL(11, 8) type: DataTypes.DECIMAL(11, 8),
}, },
images: { images: {
type: DataTypes.ARRAY(DataTypes.STRING), type: DataTypes.ARRAY(DataTypes.STRING),
defaultValue: [] defaultValue: [],
}, },
availability: { availability: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: true defaultValue: true,
}, },
specifications: { specifications: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
defaultValue: {} defaultValue: {},
}, },
rules: { rules: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
minimumRentalDays: { minimumRentalDays: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 1 defaultValue: 1,
}, },
maximumRentalDays: { maximumRentalDays: {
type: DataTypes.INTEGER type: DataTypes.INTEGER,
}, },
needsTraining: { needsTraining: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false defaultValue: false,
}, },
unavailablePeriods: { unavailablePeriods: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
defaultValue: [] defaultValue: [],
}, },
availableAfter: { availableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: '09:00' defaultValue: "09:00",
}, },
availableBefore: { availableBefore: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: '17:00' defaultValue: "17:00",
}, },
specifyTimesPerDay: { specifyTimesPerDay: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false defaultValue: false,
}, },
weeklyTimes: { weeklyTimes: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
@@ -132,17 +128,17 @@ const Item = sequelize.define('Item', {
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "09:00", availableBefore: "17:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "09:00", availableBefore: "17:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" } saturday: { availableAfter: "09:00", availableBefore: "17:00" },
} },
}, },
ownerId: { ownerId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: false,
references: { references: {
model: 'Users', model: "Users",
key: 'id' key: "id",
} },
} },
}); });
module.exports = Item; module.exports = Item;

View File

@@ -9,7 +9,6 @@ router.get("/", async (req, res) => {
const { const {
minPrice, minPrice,
maxPrice, maxPrice,
location,
city, city,
zipCode, zipCode,
search, search,
@@ -24,7 +23,6 @@ router.get("/", async (req, res) => {
if (minPrice) where.pricePerDay[Op.gte] = minPrice; if (minPrice) where.pricePerDay[Op.gte] = minPrice;
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice; if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
} }
if (location) where.location = { [Op.iLike]: `%${location}%` };
if (city) where.city = { [Op.iLike]: `%${city}%` }; if (city) where.city = { [Op.iLike]: `%${city}%` };
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` }; if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
if (search) { if (search) {
@@ -83,6 +81,42 @@ router.get("/recommendations", authenticateToken, async (req, res) => {
} }
}); });
// Public endpoint to get reviews for a specific item (must come before /:id route)
router.get('/:id/reviews', async (req, res) => {
try {
const { Rental, User } = require('../models');
const reviews = await Rental.findAll({
where: {
itemId: req.params.id,
status: 'completed',
rating: { [Op.not]: null },
review: { [Op.not]: null }
},
include: [
{
model: User,
as: 'renter',
attributes: ['id', 'firstName', 'lastName']
}
],
order: [['createdAt', 'DESC']]
});
const averageRating = reviews.length > 0
? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
: 0;
res.json({
reviews,
averageRating,
totalReviews: reviews.length
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
try { try {
const item = await Item.findByPk(req.params.id, { const item = await Item.findByPk(req.params.id, {

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Item } from '../types';
interface ItemCardProps {
item: Item;
variant?: 'compact' | 'standard';
}
const ItemCard: React.FC<ItemCardProps> = ({
item,
variant = 'standard'
}) => {
const isCompact = variant === 'compact';
const getPriceDisplay = () => {
if (item.pricePerDay !== undefined) {
return Number(item.pricePerDay) === 0
? "Free to Borrow"
: `$${Math.floor(Number(item.pricePerDay))}/Day`;
} else if (item.pricePerHour !== undefined) {
return Number(item.pricePerHour) === 0
? "Free to Borrow"
: `$${Math.floor(Number(item.pricePerHour))}/Hour`;
}
return null;
};
const getLocationDisplay = () => {
return item.city && item.state
? `${item.city}, ${item.state}`
: item.location;
};
return (
<Link to={`/items/${item.id}`} className="text-decoration-none">
<div className="card h-100" style={{ cursor: 'pointer' }}>
{item.images && item.images[0] ? (
<img
src={item.images[0]}
className="card-img-top"
alt={item.name}
style={{
height: isCompact ? '150px' : '200px',
objectFit: 'cover'
}}
/>
) : (
<div
className="card-img-top bg-light d-flex align-items-center justify-content-center"
style={{ height: isCompact ? '150px' : '200px' }}
>
<i className="bi bi-image text-muted" style={{ fontSize: '2rem' }}></i>
</div>
)}
<div className={`card-body ${isCompact ? 'p-2' : ''}`}>
{isCompact ? (
<h6 className="card-title text-truncate mb-1 text-dark">{item.name}</h6>
) : (
<h5 className="card-title text-dark">{item.name}</h5>
)}
<div className={isCompact ? "mb-1" : "mb-3"}>
<div className="text-primary">
<strong className={isCompact ? "small" : ""}>
{getPriceDisplay()}
</strong>
</div>
</div>
<div className={`text-muted small ${isCompact ? 'mb-1' : 'mb-2'}`}>
<i className="bi bi-geo-alt"></i> {getLocationDisplay()}
</div>
</div>
</div>
</Link>
);
};
export default ItemCard;

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Rental } from "../types"; import { Rental } from "../types";
import { rentalAPI } from "../services/api"; import { itemAPI } from "../services/api";
interface ItemReviewsProps { interface ItemReviewsProps {
itemId: string; itemId: string;
@@ -17,26 +17,11 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
const fetchReviews = async () => { const fetchReviews = async () => {
try { try {
// Fetch all rentals for this item const response = await itemAPI.getItemReviews(itemId);
const response = await rentalAPI.getMyListings(); const { reviews, averageRating } = response.data;
const allRentals: Rental[] = response.data;
// Filter for completed rentals with reviews for this specific item setReviews(reviews);
const itemReviews = allRentals.filter( setAverageRating(averageRating);
(rental) =>
rental.itemId === itemId &&
rental.status === "completed" &&
rental.rating &&
rental.review
);
setReviews(itemReviews);
// Calculate average rating
if (itemReviews.length > 0) {
const sum = itemReviews.reduce((acc, r) => acc + (r.rating || 0), 0);
setAverageRating(sum / itemReviews.length);
}
} catch (error) { } catch (error) {
console.error("Failed to fetch reviews:", error); console.error("Failed to fetch reviews:", error);
} finally { } finally {

View File

@@ -19,7 +19,6 @@ interface ItemFormData {
pricePerHour?: number | string; pricePerHour?: number | string;
pricePerDay?: number | string; pricePerDay?: number | string;
replacementCost: number | string; replacementCost: number | string;
location: string;
address1: string; address1: string;
address2: string; address2: string;
city: string; city: string;
@@ -57,7 +56,6 @@ const CreateItem: React.FC = () => {
inPlaceUseAvailable: false, inPlaceUseAvailable: false,
pricePerDay: "", pricePerDay: "",
replacementCost: "", replacementCost: "",
location: "",
address1: "", address1: "",
address2: "", address2: "",
city: "", city: "",

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { itemAPI } from '../services/api'; import { itemAPI } from '../services/api';
import { Item } from '../types'; import { Item } from '../types';
import ItemCard from '../components/ItemCard';
const Home: React.FC = () => { const Home: React.FC = () => {
const { user } = useAuth(); const { user } = useAuth();
@@ -80,43 +81,7 @@ const Home: React.FC = () => {
<div className="row g-4"> <div className="row g-4">
{featuredItems.map((item) => ( {featuredItems.map((item) => (
<div key={item.id} className="col-md-6 col-lg-4 col-xl-3"> <div key={item.id} className="col-md-6 col-lg-4 col-xl-3">
<Link to={`/items/${item.id}`} className="text-decoration-none"> <ItemCard item={item} variant="compact" />
<div className="card h-100 shadow-sm hover-shadow">
{item.images && item.images.length > 0 ? (
<img
src={item.images[0]}
className="card-img-top"
alt={item.name}
style={{ height: '150px', objectFit: 'cover' }}
/>
) : (
<div
className="card-img-top bg-light d-flex align-items-center justify-content-center"
style={{ height: '150px' }}
>
<i className="bi bi-image text-muted" style={{ fontSize: '2rem' }}></i>
</div>
)}
<div className="card-body p-2">
<h6 className="card-title text-truncate mb-1">{item.name}</h6>
<p className="card-text text-muted small text-truncate mb-1">
{item.location}
</p>
<div className="d-flex justify-content-between align-items-center">
<div>
{item.pricePerDay && (
<div className="fw-bold small">
${item.pricePerDay}/day
</div>
)}
</div>
<small className="text-muted" style={{ fontSize: '0.75rem' }}>
{item.owner?.firstName || 'Unknown'}
</small>
</div>
</div>
</div>
</Link>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from "react-router-dom";
import { Item, Rental } from '../types'; import { Item, Rental } from "../types";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from '../services/api'; import { itemAPI, rentalAPI } from "../services/api";
import LocationMap from '../components/LocationMap'; import LocationMap from "../components/LocationMap";
import ItemReviews from '../components/ItemReviews'; import ItemReviews from "../components/ItemReviews";
const ItemDetail: React.FC = () => { const ItemDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -15,6 +15,13 @@ const ItemDetail: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedImage, setSelectedImage] = useState(0); const [selectedImage, setSelectedImage] = useState(0);
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false); const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
const [rentalDates, setRentalDates] = useState({
startDate: "",
startTime: "14:00",
endDate: "",
endTime: "12:00",
});
const [totalCost, setTotalCost] = useState(0);
useEffect(() => { useEffect(() => {
fetchItem(); fetchItem();
@@ -23,6 +30,8 @@ const ItemDetail: React.FC = () => {
useEffect(() => { useEffect(() => {
if (user) { if (user) {
checkIfAlreadyRenting(); checkIfAlreadyRenting();
} else {
setIsAlreadyRenting(false);
} }
}, [id, user]); }, [id, user]);
@@ -31,7 +40,7 @@ const ItemDetail: React.FC = () => {
const response = await itemAPI.getItem(id!); const response = await itemAPI.getItem(id!);
setItem(response.data); setItem(response.data);
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Failed to fetch item'); setError(err.response?.data?.message || "Failed to fetch item");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -42,13 +51,14 @@ const ItemDetail: React.FC = () => {
const response = await rentalAPI.getMyRentals(); const response = await rentalAPI.getMyRentals();
const rentals: Rental[] = response.data; const rentals: Rental[] = response.data;
// Check if user has an active rental for this item // Check if user has an active rental for this item
const hasActiveRental = rentals.some(rental => const hasActiveRental = rentals.some(
rental.item?.id === id && (rental) =>
['pending', 'confirmed', 'active'].includes(rental.status) rental.item?.id === id &&
["pending", "confirmed", "active"].includes(rental.status)
); );
setIsAlreadyRenting(hasActiveRental); setIsAlreadyRenting(hasActiveRental);
} catch (err) { } 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 = () => { 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) { if (loading) {
return ( return (
<div className="container mt-5"> <div className="container mt-5">
@@ -76,7 +142,7 @@ const ItemDetail: React.FC = () => {
return ( return (
<div className="container mt-5"> <div className="container mt-5">
<div className="alert alert-danger" role="alert"> <div className="alert alert-danger" role="alert">
{error || 'Item not found'} {error || "Item not found"}
</div> </div>
</div> </div>
); );
@@ -97,138 +163,317 @@ const ItemDetail: React.FC = () => {
</div> </div>
)} )}
{item.images.length > 0 ? ( <div className="row">
<div className="mb-4"> <div className="col-md-8">
<img {/* Images */}
src={item.images[selectedImage]} {item.images.length > 0 ? (
alt={item.name} <div className="mb-4">
className="img-fluid rounded mb-3" <img
style={{ width: '100%', maxHeight: '500px', objectFit: 'cover' }} src={item.images[selectedImage]}
/> alt={item.name}
{item.images.length > 1 && ( className="img-fluid rounded mb-3"
<div className="d-flex gap-2 overflow-auto justify-content-center"> style={{
{item.images.map((image, index) => ( width: "100%",
<img maxHeight: "500px",
key={index} objectFit: "cover",
src={image} }}
alt={`${item.name} ${index + 1}`} />
className={`rounded cursor-pointer ${selectedImage === index ? 'border border-primary' : ''}`} {item.images.length > 1 && (
style={{ width: '80px', height: '80px', objectFit: 'cover', cursor: 'pointer' }} <div className="d-flex gap-2 overflow-auto justify-content-center">
onClick={() => setSelectedImage(index)} {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> {/* Item Name */}
) : ( <h1 className="mb-3">{item.name}</h1>
<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"> {/* Owner Info */}
<div className="col-md-8"> {item.owner && (
{/* Item Name */} <div
<h1 className="mb-3">{item.name}</h1> 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>
)}
{/* Owner Info */} {/* Description (no label) */}
{item.owner && ( <div className="mb-4">
<div <p>{item.description}</p>
className="d-flex align-items-center mb-4" </div>
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) */} {/* 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"> <div className="mb-4">
<p>{item.description}</p> <h5>Rules</h5>
<p>{item.rules}</p>
</div> </div>
</div> )}
{/* Right Side - Pricing Card */} {/* Cancellation Policy */}
<div className="col-md-4"> <div className="mb-4">
<div className="card"> <h5>Cancellation Policy</h5>
<div className="card-body text-center"> <div className="small">
{item.pricePerHour && ( <div className="mb-2">
<div className="mb-2"> Full refund: Cancel 48+ hours before rental start time
<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>
)}
</div> </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> </div>
</div> </div>
{/* Map */} {/* Right Side - Sticky Pricing Card */}
<LocationMap <div className="col-md-4">
latitude={item.latitude} <div
longitude={item.longitude} className="card sticky-pricing-card"
location={item.location} style={{
itemName={item.name} 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 */} if (!hasAnyPositivePrice && hasAnyZeroPrice) {
{item.rules && ( return (
<div className="mb-4"> <div className="mb-4">
<h5>Rules</h5> <h4>Free to Borrow</h4>
<p>{item.rules}</p> </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> </div>
)}
{/* Replacement Cost (under Rules) */}
<div className="mb-4">
<p><strong>Replacement Cost:</strong> ${item.replacementCost}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { Link, useSearchParams } from 'react-router-dom'; import { useSearchParams } from "react-router-dom";
import { Item } from '../types'; import { Item } from "../types";
import { itemAPI } from '../services/api'; import { itemAPI } from "../services/api";
import ItemCard from "../components/ItemCard";
const ItemList: React.FC = () => { const ItemList: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -9,9 +10,9 @@ const ItemList: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
search: searchParams.get('search') || '', search: searchParams.get("search") || "",
city: searchParams.get('city') || '', city: searchParams.get("city") || "",
zipCode: searchParams.get('zipCode') || '' zipCode: searchParams.get("zipCode") || "",
}); });
useEffect(() => { useEffect(() => {
@@ -21,9 +22,9 @@ const ItemList: React.FC = () => {
// Update filters when URL params change (e.g., from navbar search) // Update filters when URL params change (e.g., from navbar search)
useEffect(() => { useEffect(() => {
setFilters({ setFilters({
search: searchParams.get('search') || '', search: searchParams.get("search") || "",
city: searchParams.get('city') || '', city: searchParams.get("city") || "",
zipCode: searchParams.get('zipCode') || '' zipCode: searchParams.get("zipCode") || "",
}); });
}, [searchParams]); }, [searchParams]);
@@ -31,33 +32,32 @@ const ItemList: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const params = { const params = {
...filters ...filters,
}; };
// Remove empty filters // Remove empty filters
Object.keys(params).forEach(key => { Object.keys(params).forEach((key) => {
if (!params[key as keyof typeof params]) { if (!params[key as keyof typeof params]) {
delete params[key as keyof typeof params]; delete params[key as keyof typeof params];
} }
}); });
const response = await itemAPI.getItems(params); const response = await itemAPI.getItems(params);
console.log('API Response:', response);
// Access the items array from response.data.items // Access the items array from response.data.items
const allItems = response.data.items || response.data || []; const allItems = response.data.items || response.data || [];
// Filter only available items // Filter only available items
const availableItems = allItems.filter((item: Item) => item.availability); const availableItems = allItems.filter((item: Item) => item.availability);
setItems(availableItems); setItems(availableItems);
} catch (err: any) { } catch (err: any) {
console.error('Error fetching items:', err); console.error("Error fetching items:", err);
console.error('Error response:', err.response); console.error("Error response:", err.response);
setError(err.response?.data?.message || err.message || 'Failed to fetch items'); setError(
err.response?.data?.message || err.message || "Failed to fetch items"
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (loading) { if (loading) {
return ( return (
<div className="container mt-5"> <div className="container mt-5">
@@ -94,49 +94,7 @@ const ItemList: React.FC = () => {
<div className="row"> <div className="row">
{items.map((item) => ( {items.map((item) => (
<div key={item.id} className="col-md-6 col-lg-4 mb-4"> <div key={item.id} className="col-md-6 col-lg-4 mb-4">
<Link <ItemCard item={item} variant="standard" />
to={`/items/${item.id}`}
className="text-decoration-none"
>
<div className="card h-100" style={{ cursor: 'pointer' }}>
{item.images && item.images[0] && (
<img
src={item.images[0]}
className="card-img-top"
alt={item.name}
style={{ height: '200px', objectFit: 'cover' }}
/>
)}
<div className="card-body">
<h5 className="card-title text-dark">
{item.name}
</h5>
<p className="card-text text-truncate text-dark">{item.description}</p>
<div className="mb-3">
{item.pricePerDay && (
<div className="text-primary">
<strong>${item.pricePerDay}/day</strong>
</div>
)}
{item.pricePerHour && (
<div className="text-primary">
<strong>${item.pricePerHour}/hour</strong>
</div>
)}
</div>
<div className="mb-2 text-muted small">
<i className="bi bi-geo-alt"></i> {item.location}
</div>
{item.owner && (
<small className="text-muted">by {item.owner.firstName} {item.owner.lastName}</small>
)}
</div>
</div>
</Link>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,95 +1,89 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { Item } from '../types'; import { Item } from "../types";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from '../services/api'; import { itemAPI, rentalAPI } from "../services/api";
import AvailabilityCalendar from '../components/AvailabilityCalendar';
const RentItem: React.FC = () => { const RentItem: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const [searchParams] = useSearchParams();
const [item, setItem] = useState<Item | null>(null); const [item, setItem] = useState<Item | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
deliveryMethod: 'pickup' as 'pickup' | 'delivery', deliveryMethod: "pickup" as "pickup" | "delivery",
deliveryAddress: '', deliveryAddress: "",
cardNumber: '', cardNumber: "",
cardExpiry: '', cardExpiry: "",
cardCVC: '', cardCVC: "",
cardName: '' cardName: "",
}); });
const [manualSelection, setManualSelection] = useState({ const [manualSelection, setManualSelection] = useState({
startDate: '', startDate: searchParams.get("startDate") || "",
startTime: '09:00', startTime: searchParams.get("startTime") || "09:00",
endDate: '', endDate: searchParams.get("endDate") || "",
endTime: '17:00' endTime: searchParams.get("endTime") || "17:00",
}); });
const [selectedPeriods, setSelectedPeriods] = useState<Array<{
id: string;
startDate: Date;
endDate: Date;
startTime?: string;
endTime?: string;
}>>([]);
const [totalCost, setTotalCost] = useState(0); 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(() => { useEffect(() => {
fetchItem(); fetchItem();
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
calculateTotal(); calculateTotalCost();
}, [selectedPeriods, item]); }, [item, manualSelection]);
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]);
const fetchItem = async () => { const fetchItem = async () => {
try { try {
@@ -98,61 +92,20 @@ const RentItem: React.FC = () => {
// Check if item is available // Check if item is available
if (!response.data.availability) { 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 // Check if user is trying to rent their own item
if (response.data.ownerId === user?.id) { if (response.data.ownerId === user?.id) {
setError('You cannot rent your own item'); setError("You cannot rent your own item");
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Failed to fetch item'); setError(err.response?.data?.message || "Failed to fetch item");
} finally { } finally {
setLoading(false); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!user || !item) return; if (!user || !item) return;
@@ -161,121 +114,70 @@ const RentItem: React.FC = () => {
setError(null); setError(null);
try { try {
if (selectedPeriods.length === 0) { if (!manualSelection.startDate || !manualSelection.endDate) {
setError('Please select a rental period'); setError("Please select a rental period");
setSubmitting(false); setSubmitting(false);
return; return;
} }
const period = selectedPeriods[0];
const rentalData = { const rentalData = {
itemId: item.id, itemId: item.id,
startDate: period.startDate.toISOString().split('T')[0], startDate: manualSelection.startDate,
endDate: period.endDate.toISOString().split('T')[0], endDate: manualSelection.endDate,
startTime: period.startTime || undefined, startTime: manualSelection.startTime,
endTime: period.endTime || undefined, endTime: manualSelection.endTime,
totalAmount: totalCost, totalAmount: totalCost,
deliveryMethod: formData.deliveryMethod, deliveryMethod: "pickup",
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
}; };
await rentalAPI.createRental(rentalData); await rentalAPI.createRental(rentalData);
navigate('/my-rentals'); navigate("/my-rentals");
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Failed to create rental'); setError(err.response?.data?.message || "Failed to create rental");
setSubmitting(false); setSubmitting(false);
} }
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const { name, value } = e.target; const { name, value } = e.target;
if (name === 'cardNumber') { if (name === "cardNumber") {
// Remove all non-digits // Remove all non-digits
const cleaned = value.replace(/\D/g, ''); const cleaned = value.replace(/\D/g, "");
// Add spaces every 4 digits // 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) // Limit to 16 digits (19 characters with spaces)
if (cleaned.length <= 16) { 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 // Remove all non-digits
const cleaned = value.replace(/\D/g, ''); const cleaned = value.replace(/\D/g, "");
// Add slash after 2 digits // Add slash after 2 digits
let formatted = cleaned; let formatted = cleaned;
if (cleaned.length >= 3) { 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 // Limit to 4 digits
if (cleaned.length <= 4) { 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 // Only allow digits and limit to 4
const cleaned = value.replace(/\D/g, ''); const cleaned = value.replace(/\D/g, "");
if (cleaned.length <= 4) { if (cleaned.length <= 4) {
setFormData(prev => ({ ...prev, [name]: cleaned })); setFormData((prev) => ({ ...prev, [name]: cleaned }));
} }
} else { } else {
setFormData(prev => ({ ...prev, [name]: value })); 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
}]);
} }
}; };
@@ -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 ( return (
<div className="container mt-5"> <div className="container mt-5">
<div className="alert alert-danger" role="alert"> <div className="alert alert-danger" role="alert">
{error || 'Item not found'} {error || "Item not found"}
</div> </div>
<button className="btn btn-secondary" onClick={() => navigate(-1)}> <button className="btn btn-secondary" onClick={() => navigate(-1)}>
Go Back Go Back
@@ -304,14 +210,11 @@ const RentItem: React.FC = () => {
); );
} }
const showHourlyOptions = !!item.pricePerHour;
const minDays = item.minimumRentalDays || 1;
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<div className="row"> <div className="row">
<div className="col-md-8"> <div className="col-md-8">
<h1>Rent: {item.name}</h1> <h1>Renting {item.name}</h1>
{error && ( {error && (
<div className="alert alert-danger" role="alert"> <div className="alert alert-danger" role="alert">
@@ -319,323 +222,201 @@ const RentItem: React.FC = () => {
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <div className="row">
<div className="card mb-4"> <div className="col-md-8">
<div className="card-body"> <form onSubmit={handleSubmit}>
<h5 className="card-title">Select Rental Period</h5> <div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Payment</h5>
<AvailabilityCalendar <div className="mb-3">
unavailablePeriods={item.unavailablePeriods || []} <label className="form-label">Payment Method *</label>
onPeriodsChange={() => {}} // Read-only for renters <div className="form-check">
mode="renter" <input
selectedRentalPeriod={selectedPeriods.length > 0 ? { className="form-check-input"
id: selectedPeriods[0].id, type="radio"
startDate: selectedPeriods[0].startDate, name="paymentMethod"
endDate: selectedPeriods[0].endDate, id="creditCard"
startTime: selectedPeriods[0].startTime, value="creditCard"
endTime: selectedPeriods[0].endTime, checked
isRentalSelection: true readOnly
} : undefined} />
onRentalSelect={(period) => { <label
// Update selected periods className="form-check-label"
setSelectedPeriods([{ htmlFor="creditCard"
id: Date.now().toString(), >
startDate: period.startDate, Credit/Debit Card
endDate: period.endDate, </label>
startTime: period.startTime, </div>
endTime: period.endTime </div>
}]);
// Update manual selection to match calendar <div className="row mb-3">
setManualSelection({ <div className="col-12">
startDate: period.startDate.toISOString().split('T')[0], <label htmlFor="cardNumber" className="form-label">
startTime: period.startTime || '09:00', Card Number *
endDate: period.endDate.toISOString().split('T')[0], </label>
endTime: period.endTime || '17:00' <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="mt-4"> <div className="row mb-3">
<div className="row g-3"> <div className="col-md-6">
<div className="col-md-3"> <label htmlFor="cardExpiry" className="form-label">
<label htmlFor="startDate" className="form-label">Start Date</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 <input
type="date" type="text"
className="form-control" className="form-control"
id="startDate" id="cardName"
name="startDate" name="cardName"
value={manualSelection.startDate} value={formData.cardName}
onChange={handleManualSelectionChange} onChange={handleChange}
min={new Date().toISOString().split('T')[0]} placeholder="John Doe"
required
/> />
</div> </div>
<div className="col-md-3">
<label htmlFor="startTime" className="form-label">Start Time</label> <div className="alert alert-info small">
<select <i className="bi bi-info-circle"></i> Your payment
className="form-select" information is secure and encrypted. You will only be
id="startTime" charged after the owner accepts your rental request.
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> </div>
<div className="col-md-3">
<label htmlFor="endDate" className="form-label">End Date</label> <div className="d-grid gap-2">
<input <button
type="date" type="submit"
className="form-control" className="btn btn-primary"
id="endDate" disabled={
name="endDate" !manualSelection.startDate || !manualSelection.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}
> >
{Array.from({ length: 24 }, (_, i) => { {submitting
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i; ? "Processing..."
const period = i < 12 ? 'AM' : 'PM'; : `Confirm Rental - $${totalCost}`}
return ( </button>
<option key={i} value={`${i.toString().padStart(2, '0')}:00`}> <button
{hour}:00 {period} type="button"
</option> className="btn btn-secondary"
); onClick={() => navigate(`/items/${id}`)}
})} >
</select> Cancel
</button>
</div> </div>
</div> </div>
</div> </div>
</form>
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
<div className="alert alert-warning mt-3">
Minimum rental period is {minDays} days
</div>
)}
</div>
</div> </div>
<div className="card mb-4"> <div className="col-md-4">
<div className="card-body"> <div className="card">
<div className="my-3"> <div className="card-body">
<select {item.images && item.images[0] && (
className="form-select" <img
id="deliveryMethod" src={item.images[0]}
name="deliveryMethod" alt={item.name}
value={formData.deliveryMethod} className="img-fluid rounded mb-3"
onChange={handleChange} style={{
> width: "100%",
{item.pickUpAvailable && <option value="pickup">Pick-up</option>} height: "150px",
{item.localDeliveryAvailable && <option value="delivery">Delivery</option>} objectFit: "cover",
</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'}
/> />
{item.localDeliveryRadius && ( )}
<div className="form-text">
Delivery available within {item.localDeliveryRadius} miles <h6>{item.name}</h6>
</div> <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>
<div className="card mb-4"> {/* Selected Dates */}
<div className="card-body"> {manualSelection.startDate && manualSelection.endDate && (
<h5 className="card-title">Payment</h5> <div className="mb-3">
<div className="small mb-1">
<div className="mb-3"> <strong>Check-in:</strong>{" "}
<label className="form-label">Payment Method *</label> {formatDate(manualSelection.startDate)} at{" "}
<div className="form-check"> {formatTime(manualSelection.startTime)}
<input </div>
className="form-check-input" <div className="small">
type="radio" <strong>Check-out:</strong>{" "}
name="paymentMethod" {formatDate(manualSelection.endDate)} at{" "}
id="creditCard" {formatTime(manualSelection.endTime)}
value="creditCard" </div>
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`}
</div> </div>
</div> )}
<hr /> {/* Total Cost */}
{totalCost > 0 && (
<div className="d-flex justify-content-between"> <>
<strong>Total Cost:</strong> <hr />
<strong>${totalCost.toFixed(2)}</strong> <div className="d-flex justify-content-between">
</div> <strong>Total:</strong>
</> <strong>${totalCost}</strong>
) : ( </div>
<p className="text-muted">Select dates to see total cost</p> </>
)} )}
</div>
{item.rules && ( </div>
<>
<hr />
<div>
<strong>Rules:</strong>
<p className="small">{item.rules}</p>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -69,6 +69,7 @@ export const itemAPI = {
updateItem: (id: string, data: any) => api.put(`/items/${id}`, data), updateItem: (id: string, data: any) => api.put(`/items/${id}`, data),
deleteItem: (id: string) => api.delete(`/items/${id}`), deleteItem: (id: string) => api.delete(`/items/${id}`),
getRecommendations: () => api.get("/items/recommendations"), getRecommendations: () => api.get("/items/recommendations"),
getItemReviews: (id: string) => api.get(`/items/${id}/reviews`),
}; };
export const rentalAPI = { export const rentalAPI = {