getting to payment screen. Bug fixes and formatting changes for item detail
This commit is contained in:
@@ -1,127 +1,123 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/database');
|
||||
const { DataTypes } = require("sequelize");
|
||||
const sequelize = require("../config/database");
|
||||
|
||||
const Item = sequelize.define('Item', {
|
||||
const Item = sequelize.define("Item", {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
allowNull: false,
|
||||
},
|
||||
pickUpAvailable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
localDeliveryAvailable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
localDeliveryRadius: {
|
||||
type: DataTypes.INTEGER,
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 100
|
||||
}
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
shippingAvailable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
inPlaceUseAvailable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
pricePerHour: {
|
||||
type: DataTypes.DECIMAL(10, 2)
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
},
|
||||
pricePerDay: {
|
||||
type: DataTypes.DECIMAL(10, 2)
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
},
|
||||
replacementCost: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false
|
||||
},
|
||||
location: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
allowNull: false,
|
||||
},
|
||||
address1: {
|
||||
type: DataTypes.STRING
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
address2: {
|
||||
type: DataTypes.STRING
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
city: {
|
||||
type: DataTypes.STRING
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.STRING
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
zipCode: {
|
||||
type: DataTypes.STRING
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
country: {
|
||||
type: DataTypes.STRING
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
latitude: {
|
||||
type: DataTypes.DECIMAL(10, 8)
|
||||
type: DataTypes.DECIMAL(10, 8),
|
||||
},
|
||||
longitude: {
|
||||
type: DataTypes.DECIMAL(11, 8)
|
||||
type: DataTypes.DECIMAL(11, 8),
|
||||
},
|
||||
images: {
|
||||
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||
defaultValue: []
|
||||
defaultValue: [],
|
||||
},
|
||||
availability: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
defaultValue: true,
|
||||
},
|
||||
specifications: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
defaultValue: {},
|
||||
},
|
||||
rules: {
|
||||
type: DataTypes.TEXT
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
minimumRentalDays: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
defaultValue: 1,
|
||||
},
|
||||
maximumRentalDays: {
|
||||
type: DataTypes.INTEGER
|
||||
type: DataTypes.INTEGER,
|
||||
},
|
||||
needsTraining: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
unavailablePeriods: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: []
|
||||
defaultValue: [],
|
||||
},
|
||||
availableAfter: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: '09:00'
|
||||
defaultValue: "09:00",
|
||||
},
|
||||
availableBefore: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: '17:00'
|
||||
defaultValue: "17:00",
|
||||
},
|
||||
specifyTimesPerDay: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
weeklyTimes: {
|
||||
type: DataTypes.JSONB,
|
||||
@@ -132,17 +128,17 @@ const Item = sequelize.define('Item', {
|
||||
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
thursday: { 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: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
model: "Users",
|
||||
key: "id",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = Item;
|
||||
@@ -9,7 +9,6 @@ router.get("/", async (req, res) => {
|
||||
const {
|
||||
minPrice,
|
||||
maxPrice,
|
||||
location,
|
||||
city,
|
||||
zipCode,
|
||||
search,
|
||||
@@ -24,7 +23,6 @@ router.get("/", async (req, res) => {
|
||||
if (minPrice) where.pricePerDay[Op.gte] = minPrice;
|
||||
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
|
||||
}
|
||||
if (location) where.location = { [Op.iLike]: `%${location}%` };
|
||||
if (city) where.city = { [Op.iLike]: `%${city}%` };
|
||||
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
|
||||
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) => {
|
||||
try {
|
||||
const item = await Item.findByPk(req.params.id, {
|
||||
|
||||
81
frontend/src/components/ItemCard.tsx
Normal file
81
frontend/src/components/ItemCard.tsx
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Rental } from "../types";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { itemAPI } from "../services/api";
|
||||
|
||||
interface ItemReviewsProps {
|
||||
itemId: string;
|
||||
@@ -17,26 +17,11 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
|
||||
|
||||
const fetchReviews = async () => {
|
||||
try {
|
||||
// Fetch all rentals for this item
|
||||
const response = await rentalAPI.getMyListings();
|
||||
const allRentals: Rental[] = response.data;
|
||||
const response = await itemAPI.getItemReviews(itemId);
|
||||
const { reviews, averageRating } = response.data;
|
||||
|
||||
// Filter for completed rentals with reviews for this specific item
|
||||
const itemReviews = allRentals.filter(
|
||||
(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);
|
||||
}
|
||||
setReviews(reviews);
|
||||
setAverageRating(averageRating);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch reviews:", error);
|
||||
} finally {
|
||||
|
||||
@@ -19,7 +19,6 @@ interface ItemFormData {
|
||||
pricePerHour?: number | string;
|
||||
pricePerDay?: number | string;
|
||||
replacementCost: number | string;
|
||||
location: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
city: string;
|
||||
@@ -57,7 +56,6 @@ const CreateItem: React.FC = () => {
|
||||
inPlaceUseAvailable: false,
|
||||
pricePerDay: "",
|
||||
replacementCost: "",
|
||||
location: "",
|
||||
address1: "",
|
||||
address2: "",
|
||||
city: "",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI } from '../services/api';
|
||||
import { Item } from '../types';
|
||||
import ItemCard from '../components/ItemCard';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
@@ -80,43 +81,7 @@ const Home: React.FC = () => {
|
||||
<div className="row g-4">
|
||||
{featuredItems.map((item) => (
|
||||
<div key={item.id} className="col-md-6 col-lg-4 col-xl-3">
|
||||
<Link to={`/items/${item.id}`} className="text-decoration-none">
|
||||
<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>
|
||||
<ItemCard item={item} variant="compact" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item, Rental } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
import LocationMap from '../components/LocationMap';
|
||||
import ItemReviews from '../components/ItemReviews';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Item, Rental } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemAPI, rentalAPI } from "../services/api";
|
||||
import LocationMap from "../components/LocationMap";
|
||||
import ItemReviews from "../components/ItemReviews";
|
||||
|
||||
const ItemDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -15,6 +15,13 @@ const ItemDetail: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
||||
const [rentalDates, setRentalDates] = useState({
|
||||
startDate: "",
|
||||
startTime: "14:00",
|
||||
endDate: "",
|
||||
endTime: "12:00",
|
||||
});
|
||||
const [totalCost, setTotalCost] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
@@ -23,6 +30,8 @@ const ItemDetail: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
checkIfAlreadyRenting();
|
||||
} else {
|
||||
setIsAlreadyRenting(false);
|
||||
}
|
||||
}, [id, user]);
|
||||
|
||||
@@ -31,7 +40,7 @@ const ItemDetail: React.FC = () => {
|
||||
const response = await itemAPI.getItem(id!);
|
||||
setItem(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
setError(err.response?.data?.message || "Failed to fetch item");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -42,13 +51,14 @@ const ItemDetail: React.FC = () => {
|
||||
const response = await rentalAPI.getMyRentals();
|
||||
const rentals: Rental[] = response.data;
|
||||
// Check if user has an active rental for this item
|
||||
const hasActiveRental = rentals.some(rental =>
|
||||
const hasActiveRental = rentals.some(
|
||||
(rental) =>
|
||||
rental.item?.id === id &&
|
||||
['pending', 'confirmed', 'active'].includes(rental.status)
|
||||
["pending", "confirmed", "active"].includes(rental.status)
|
||||
);
|
||||
setIsAlreadyRenting(hasActiveRental);
|
||||
} catch (err) {
|
||||
console.error('Failed to check rental status:', err);
|
||||
console.error("Failed to check rental status:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,9 +67,65 @@ const ItemDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRent = () => {
|
||||
navigate(`/items/${id}/rent`);
|
||||
const params = new URLSearchParams({
|
||||
startDate: rentalDates.startDate,
|
||||
startTime: rentalDates.startTime,
|
||||
endDate: rentalDates.endDate,
|
||||
endTime: rentalDates.endTime
|
||||
});
|
||||
navigate(`/items/${id}/rent?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleDateTimeChange = (field: string, value: string) => {
|
||||
setRentalDates((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const calculateTotalCost = () => {
|
||||
if (!item || !rentalDates.startDate || !rentalDates.endDate) {
|
||||
setTotalCost(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateTime = new Date(
|
||||
`${rentalDates.startDate}T${rentalDates.startTime}`
|
||||
);
|
||||
const endDateTime = new Date(
|
||||
`${rentalDates.endDate}T${rentalDates.endTime}`
|
||||
);
|
||||
|
||||
const diffMs = endDateTime.getTime() - startDateTime.getTime();
|
||||
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
let cost = 0;
|
||||
if (item.pricePerHour && diffHours <= 24) {
|
||||
cost = diffHours * Number(item.pricePerHour);
|
||||
} else if (item.pricePerDay) {
|
||||
cost = diffDays * Number(item.pricePerDay);
|
||||
}
|
||||
|
||||
setTotalCost(cost);
|
||||
};
|
||||
|
||||
const generateTimeOptions = () => {
|
||||
const options = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const time24 = `${hour.toString().padStart(2, "0")}:00`;
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
const period = hour < 12 ? "AM" : "PM";
|
||||
const time12 = `${hour12}:00 ${period}`;
|
||||
options.push({ value: time24, label: time12 });
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
calculateTotalCost();
|
||||
}, [rentalDates, item]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
@@ -76,7 +142,7 @@ const ItemDetail: React.FC = () => {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
{error || "Item not found"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -97,13 +163,20 @@ const ItemDetail: React.FC = () => {
|
||||
</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' }}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxHeight: "500px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
{item.images.length > 1 && (
|
||||
<div className="d-flex gap-2 overflow-auto justify-content-center">
|
||||
@@ -112,8 +185,17 @@ const ItemDetail: React.FC = () => {
|
||||
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' }}
|
||||
className={`rounded cursor-pointer ${
|
||||
selectedImage === index
|
||||
? "border border-primary"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
/>
|
||||
))}
|
||||
@@ -121,13 +203,13 @@ const ItemDetail: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-light rounded d-flex align-items-center justify-content-center mb-4" style={{ height: '400px' }}>
|
||||
<div
|
||||
className="bg-light rounded d-flex align-items-center justify-content-center mb-4"
|
||||
style={{ height: "400px" }}
|
||||
>
|
||||
<span className="text-muted">No image available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
{/* Item Name */}
|
||||
<h1 className="mb-3">{item.name}</h1>
|
||||
|
||||
@@ -136,24 +218,33 @@ const ItemDetail: React.FC = () => {
|
||||
<div
|
||||
className="d-flex align-items-center mb-4"
|
||||
onClick={() => navigate(`/users/${item.ownerId}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
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' }}
|
||||
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' }}
|
||||
style={{ width: "30px", height: "30px" }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||
<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>
|
||||
<span className="text-muted">
|
||||
{item.owner.firstName} {item.owner.lastName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -161,52 +252,6 @@ const ItemDetail: React.FC = () => {
|
||||
<div className="mb-4">
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Pricing Card */}
|
||||
<div className="col-md-4">
|
||||
<div className="card">
|
||||
<div className="card-body text-center">
|
||||
{item.pricePerHour && (
|
||||
<div className="mb-2">
|
||||
<h4>${Math.floor(item.pricePerHour)}/Hour</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerDay && (
|
||||
<div className="mb-2">
|
||||
<h4>${Math.floor(item.pricePerDay)}/Day</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerWeek && (
|
||||
<div className="mb-2">
|
||||
<h4>${Math.floor(item.pricePerWeek)}/Week</h4>
|
||||
</div>
|
||||
)}
|
||||
{item.pricePerMonth && (
|
||||
<div className="mb-4">
|
||||
<h4>${Math.floor(item.pricePerMonth)}/Month</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isOwner && item.availability && !isAlreadyRenting && (
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-primary" onClick={handleRent}>
|
||||
Rent This Item
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isOwner && isAlreadyRenting && (
|
||||
<div className="d-grid">
|
||||
<button className="btn btn-success" disabled style={{ opacity: 0.8 }}>
|
||||
✓ Renting
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<LocationMap
|
||||
@@ -226,9 +271,209 @@ const ItemDetail: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replacement Cost (under Rules) */}
|
||||
{/* Cancellation Policy */}
|
||||
<div className="mb-4">
|
||||
<p><strong>Replacement Cost:</strong> ${item.replacementCost}</p>
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { itemAPI } from '../services/api';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Item } from "../types";
|
||||
import { itemAPI } from "../services/api";
|
||||
import ItemCard from "../components/ItemCard";
|
||||
|
||||
const ItemList: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -9,9 +10,9 @@ const ItemList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: searchParams.get('search') || '',
|
||||
city: searchParams.get('city') || '',
|
||||
zipCode: searchParams.get('zipCode') || ''
|
||||
search: searchParams.get("search") || "",
|
||||
city: searchParams.get("city") || "",
|
||||
zipCode: searchParams.get("zipCode") || "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,9 +22,9 @@ const ItemList: React.FC = () => {
|
||||
// Update filters when URL params change (e.g., from navbar search)
|
||||
useEffect(() => {
|
||||
setFilters({
|
||||
search: searchParams.get('search') || '',
|
||||
city: searchParams.get('city') || '',
|
||||
zipCode: searchParams.get('zipCode') || ''
|
||||
search: searchParams.get("search") || "",
|
||||
city: searchParams.get("city") || "",
|
||||
zipCode: searchParams.get("zipCode") || "",
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -31,33 +32,32 @@ const ItemList: React.FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = {
|
||||
...filters
|
||||
...filters,
|
||||
};
|
||||
// Remove empty filters
|
||||
Object.keys(params).forEach(key => {
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof typeof params]) {
|
||||
delete params[key as keyof typeof params];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await itemAPI.getItems(params);
|
||||
console.log('API Response:', response);
|
||||
// Access the items array from response.data.items
|
||||
const allItems = response.data.items || response.data || [];
|
||||
// Filter only available items
|
||||
const availableItems = allItems.filter((item: Item) => item.availability);
|
||||
setItems(availableItems);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching items:', err);
|
||||
console.error('Error response:', err.response);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to fetch items');
|
||||
console.error("Error fetching items:", err);
|
||||
console.error("Error response:", err.response);
|
||||
setError(
|
||||
err.response?.data?.message || err.message || "Failed to fetch items"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
@@ -94,49 +94,7 @@ const ItemList: React.FC = () => {
|
||||
<div className="row">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="col-md-6 col-lg-4 mb-4">
|
||||
<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: '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>
|
||||
<ItemCard item={item} variant="standard" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,95 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Item } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemAPI, rentalAPI } from '../services/api';
|
||||
import AvailabilityCalendar from '../components/AvailabilityCalendar';
|
||||
import React, { useState, useEffect } 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";
|
||||
|
||||
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 [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: ''
|
||||
deliveryMethod: "pickup" as "pickup" | "delivery",
|
||||
deliveryAddress: "",
|
||||
cardNumber: "",
|
||||
cardExpiry: "",
|
||||
cardCVC: "",
|
||||
cardName: "",
|
||||
});
|
||||
|
||||
const [manualSelection, setManualSelection] = useState({
|
||||
startDate: '',
|
||||
startTime: '09:00',
|
||||
endDate: '',
|
||||
endTime: '17:00'
|
||||
startDate: searchParams.get("startDate") || "",
|
||||
startTime: searchParams.get("startTime") || "09:00",
|
||||
endDate: searchParams.get("endDate") || "",
|
||||
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 [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(() => {
|
||||
fetchItem();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateTotal();
|
||||
}, [selectedPeriods, item]);
|
||||
|
||||
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]);
|
||||
calculateTotalCost();
|
||||
}, [item, manualSelection]);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
@@ -98,61 +92,20 @@ const RentItem: React.FC = () => {
|
||||
|
||||
// Check if item is available
|
||||
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
|
||||
if (response.data.ownerId === user?.id) {
|
||||
setError('You cannot rent your own item');
|
||||
setError("You cannot rent your own item");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to fetch item');
|
||||
setError(err.response?.data?.message || "Failed to fetch item");
|
||||
} finally {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!user || !item) return;
|
||||
@@ -161,121 +114,70 @@ const RentItem: React.FC = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (selectedPeriods.length === 0) {
|
||||
setError('Please select a rental period');
|
||||
if (!manualSelection.startDate || !manualSelection.endDate) {
|
||||
setError("Please select a rental period");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const period = selectedPeriods[0];
|
||||
const rentalData = {
|
||||
itemId: item.id,
|
||||
startDate: period.startDate.toISOString().split('T')[0],
|
||||
endDate: period.endDate.toISOString().split('T')[0],
|
||||
startTime: period.startTime || undefined,
|
||||
endTime: period.endTime || undefined,
|
||||
startDate: manualSelection.startDate,
|
||||
endDate: manualSelection.endDate,
|
||||
startTime: manualSelection.startTime,
|
||||
endTime: manualSelection.endTime,
|
||||
totalAmount: totalCost,
|
||||
deliveryMethod: formData.deliveryMethod,
|
||||
deliveryAddress: formData.deliveryMethod === 'delivery' ? formData.deliveryAddress : undefined
|
||||
deliveryMethod: "pickup",
|
||||
};
|
||||
|
||||
await rentalAPI.createRental(rentalData);
|
||||
navigate('/my-rentals');
|
||||
navigate("/my-rentals");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to create rental');
|
||||
setError(err.response?.data?.message || "Failed to create rental");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'cardNumber') {
|
||||
if (name === "cardNumber") {
|
||||
// Remove all non-digits
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
const cleaned = value.replace(/\D/g, "");
|
||||
|
||||
// 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)
|
||||
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
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
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);
|
||||
formatted = cleaned.slice(0, 2) + "/" + cleaned.slice(2, 4);
|
||||
}
|
||||
|
||||
// Limit to 4 digits
|
||||
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
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
const cleaned = value.replace(/\D/g, "");
|
||||
if (cleaned.length <= 4) {
|
||||
setFormData(prev => ({ ...prev, [name]: cleaned }));
|
||||
setFormData((prev) => ({ ...prev, [name]: cleaned }));
|
||||
}
|
||||
} else {
|
||||
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
|
||||
}]);
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Item not found'}
|
||||
{error || "Item not found"}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Go Back
|
||||
@@ -304,14 +210,11 @@ const RentItem: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const showHourlyOptions = !!item.pricePerHour;
|
||||
const minDays = item.minimumRentalDays || 1;
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<h1>Rent: {item.name}</h1>
|
||||
<h1>Renting {item.name}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
@@ -319,158 +222,9 @@ const RentItem: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Select Rental Period</h5>
|
||||
|
||||
<AvailabilityCalendar
|
||||
unavailablePeriods={item.unavailablePeriods || []}
|
||||
onPeriodsChange={() => {}} // Read-only for renters
|
||||
mode="renter"
|
||||
selectedRentalPeriod={selectedPeriods.length > 0 ? {
|
||||
id: selectedPeriods[0].id,
|
||||
startDate: selectedPeriods[0].startDate,
|
||||
endDate: selectedPeriods[0].endDate,
|
||||
startTime: selectedPeriods[0].startTime,
|
||||
endTime: selectedPeriods[0].endTime,
|
||||
isRentalSelection: true
|
||||
} : undefined}
|
||||
onRentalSelect={(period) => {
|
||||
// Update selected periods
|
||||
setSelectedPeriods([{
|
||||
id: Date.now().toString(),
|
||||
startDate: period.startDate,
|
||||
endDate: period.endDate,
|
||||
startTime: period.startTime,
|
||||
endTime: period.endTime
|
||||
}]);
|
||||
|
||||
// Update manual selection to match calendar
|
||||
setManualSelection({
|
||||
startDate: period.startDate.toISOString().split('T')[0],
|
||||
startTime: period.startTime || '09:00',
|
||||
endDate: period.endDate.toISOString().split('T')[0],
|
||||
endTime: period.endTime || '17:00'
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-3">
|
||||
<label htmlFor="startDate" className="form-label">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
value={manualSelection.startDate}
|
||||
onChange={handleManualSelectionChange}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label htmlFor="startTime" className="form-label">Start Time</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="startTime"
|
||||
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 className="col-md-3">
|
||||
<label htmlFor="endDate" className="form-label">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
id="endDate"
|
||||
name="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) => {
|
||||
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>
|
||||
|
||||
{rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && (
|
||||
<div className="alert alert-warning mt-3">
|
||||
Minimum rental period is {minDays} days
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="my-3">
|
||||
<select
|
||||
className="form-select"
|
||||
id="deliveryMethod"
|
||||
name="deliveryMethod"
|
||||
value={formData.deliveryMethod}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{item.pickUpAvailable && <option value="pickup">Pick-up</option>}
|
||||
{item.localDeliveryAvailable && <option value="delivery">Delivery</option>}
|
||||
</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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Payment</h5>
|
||||
@@ -487,7 +241,10 @@ const RentItem: React.FC = () => {
|
||||
checked
|
||||
readOnly
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="creditCard">
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="creditCard"
|
||||
>
|
||||
Credit/Debit Card
|
||||
</label>
|
||||
</div>
|
||||
@@ -495,7 +252,9 @@ const RentItem: React.FC = () => {
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-12">
|
||||
<label htmlFor="cardNumber" className="form-label">Card Number *</label>
|
||||
<label htmlFor="cardNumber" className="form-label">
|
||||
Card Number *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
@@ -511,7 +270,9 @@ const RentItem: React.FC = () => {
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardExpiry" className="form-label">Expiry Date *</label>
|
||||
<label htmlFor="cardExpiry" className="form-label">
|
||||
Expiry Date *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
@@ -524,7 +285,9 @@ const RentItem: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardCVC" className="form-label">CVC *</label>
|
||||
<label htmlFor="cardCVC" className="form-label">
|
||||
CVC *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
@@ -539,7 +302,9 @@ const RentItem: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="cardName" className="form-label">Name on Card *</label>
|
||||
<label htmlFor="cardName" className="form-label">
|
||||
Name on Card *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
@@ -553,18 +318,22 @@ const RentItem: React.FC = () => {
|
||||
</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>
|
||||
<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={submitting || selectedPeriods.length === 0 || totalCost === 0 || (rentalDuration.days < minDays && !showHourlyOptions)}
|
||||
disabled={
|
||||
!manualSelection.startDate || !manualSelection.endDate
|
||||
}
|
||||
>
|
||||
{submitting ? 'Processing...' : `Confirm Rental - $${totalCost.toFixed(2)}`}
|
||||
{submitting
|
||||
? "Processing..."
|
||||
: `Confirm Rental - $${totalCost}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -574,65 +343,75 @@ const RentItem: React.FC = () => {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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' }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h6>{item.name}</h6>
|
||||
<p className="text-muted small">{item.location}</p>
|
||||
<p className="text-muted small">
|
||||
{item.city && item.state
|
||||
? `${item.city}, ${item.state}`
|
||||
: 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>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong>Total Cost:</strong>
|
||||
<strong>${totalCost.toFixed(2)}</strong>
|
||||
</div>
|
||||
</>
|
||||
{/* Pricing */}
|
||||
<div className="mb-3 text-center">
|
||||
{totalCost === 0 ? (
|
||||
<h6>Free to Borrow</h6>
|
||||
) : (
|
||||
<p className="text-muted">Select dates to see total cost</p>
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{item.rules && (
|
||||
{/* Total Cost */}
|
||||
{totalCost > 0 && (
|
||||
<>
|
||||
<hr />
|
||||
<div>
|
||||
<strong>Rules:</strong>
|
||||
<p className="small">{item.rules}</p>
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong>Total:</strong>
|
||||
<strong>${totalCost}</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -641,6 +420,8 @@ const RentItem: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const itemAPI = {
|
||||
updateItem: (id: string, data: any) => api.put(`/items/${id}`, data),
|
||||
deleteItem: (id: string) => api.delete(`/items/${id}`),
|
||||
getRecommendations: () => api.get("/items/recommendations"),
|
||||
getItemReviews: (id: string) => api.get(`/items/${id}/reviews`),
|
||||
};
|
||||
|
||||
export const rentalAPI = {
|
||||
|
||||
Reference in New Issue
Block a user