diff --git a/backend/models/Item.js b/backend/models/Item.js index 798edfa..4e423a9 100644 --- a/backend/models/Item.js +++ b/backend/models/Item.js @@ -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; \ No newline at end of file +module.exports = Item; diff --git a/backend/routes/items.js b/backend/routes/items.js index 0364c5b..9cf981f 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -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, { diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx new file mode 100644 index 0000000..8a7a951 --- /dev/null +++ b/frontend/src/components/ItemCard.tsx @@ -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 = ({ + 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 ( + +
+ {item.images && item.images[0] ? ( + {item.name} + ) : ( +
+ +
+ )} + +
+ {isCompact ? ( +
{item.name}
+ ) : ( +
{item.name}
+ )} + +
+
+ + {getPriceDisplay()} + +
+
+ +
+ {getLocationDisplay()} +
+
+
+ + ); +}; + +export default ItemCard; \ No newline at end of file diff --git a/frontend/src/components/ItemReviews.tsx b/frontend/src/components/ItemReviews.tsx index 3758221..a39f83a 100644 --- a/frontend/src/components/ItemReviews.tsx +++ b/frontend/src/components/ItemReviews.tsx @@ -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 = ({ itemId }) => { const fetchReviews = async () => { try { - // Fetch all rentals for this item - const response = await rentalAPI.getMyListings(); - const allRentals: Rental[] = 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); - } + const response = await itemAPI.getItemReviews(itemId); + const { reviews, averageRating } = response.data; + + setReviews(reviews); + setAverageRating(averageRating); } catch (error) { console.error("Failed to fetch reviews:", error); } finally { diff --git a/frontend/src/pages/CreateItem.tsx b/frontend/src/pages/CreateItem.tsx index 2fbdb77..7641778 100644 --- a/frontend/src/pages/CreateItem.tsx +++ b/frontend/src/pages/CreateItem.tsx @@ -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: "", diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 6e078f0..bc48aa3 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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 = () => {
{featuredItems.map((item) => (
- -
- {item.images && item.images.length > 0 ? ( - {item.name} - ) : ( -
- -
- )} -
-
{item.name}
-

- {item.location} -

-
-
- {item.pricePerDay && ( -
- ${item.pricePerDay}/day -
- )} -
- - {item.owner?.firstName || 'Unknown'} - -
-
-
- +
))}
diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index f5dd669..c567a45 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -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(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 => - rental.item?.id === id && - ['pending', 'confirmed', 'active'].includes(rental.status) + const hasActiveRental = rentals.some( + (rental) => + rental.item?.id === id && + ["pending", "confirmed", "active"].includes(rental.status) ); setIsAlreadyRenting(hasActiveRental); } catch (err) { - console.error('Failed to check rental status:', err); + 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 (
@@ -76,7 +142,7 @@ const ItemDetail: React.FC = () => { return (
- {error || 'Item not found'} + {error || "Item not found"}
); @@ -96,139 +162,318 @@ const ItemDetail: React.FC = () => {
)} - - {item.images.length > 0 ? ( -
- {item.name} - {item.images.length > 1 && ( -
- {item.images.map((image, index) => ( - {`${item.name} setSelectedImage(index)} - /> - ))} + +
+
+ {/* Images */} + {item.images.length > 0 ? ( +
+ {item.name} + {item.images.length > 1 && ( +
+ {item.images.map((image, index) => ( + {`${item.name} setSelectedImage(index)} + /> + ))} +
+ )} +
+ ) : ( +
+ No image available
)} -
- ) : ( -
- No image available -
- )} - -
-
- {/* Item Name */} -

{item.name}

- - {/* Owner Info */} - {item.owner && ( -
navigate(`/users/${item.ownerId}`)} - style={{ cursor: 'pointer' }} - > - {item.owner.profileImage ? ( - {`${item.owner.firstName} - ) : ( -
- -
- )} - {item.owner.firstName} {item.owner.lastName} -
- )} - - {/* Description (no label) */} + {/* Item Name */} +

{item.name}

+ + {/* Owner Info */} + {item.owner && ( +
navigate(`/users/${item.ownerId}`)} + style={{ cursor: "pointer" }} + > + {item.owner.profileImage ? ( + {`${item.owner.firstName} + ) : ( +
+ +
+ )} + + {item.owner.firstName} {item.owner.lastName} + +
+ )} + + {/* Description (no label) */} +
+

{item.description}

+
+ + {/* Map */} + + + + + {/* Rules */} + {item.rules && (
-

{item.description}

+
Rules
+

{item.rules}

-
+ )} - {/* Right Side - Pricing Card */} -
-
-
- {item.pricePerHour && ( -
-

${Math.floor(item.pricePerHour)}/Hour

-
- )} - {item.pricePerDay && ( -
-

${Math.floor(item.pricePerDay)}/Day

-
- )} - {item.pricePerWeek && ( -
-

${Math.floor(item.pricePerWeek)}/Week

-
- )} - {item.pricePerMonth && ( -
-

${Math.floor(item.pricePerMonth)}/Month

-
- )} - - {/* Action Buttons */} - {!isOwner && item.availability && !isAlreadyRenting && ( -
- -
- )} - {!isOwner && isAlreadyRenting && ( -
- -
- )} + {/* Cancellation Policy */} +
+
Cancellation Policy
+
+
+ Full refund: Cancel 48+ hours before rental start time
+
+ 50% refund: Cancel 24-48 hours before rental start time +
+
+ No refund: Cancel within 24 hours of rental start time +
+
Replacement Cost: ${item.replacementCost}
- {/* Map */} - + {/* Right Side - Sticky Pricing Card */} +
+
+
+ {(() => { + 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); - {/* Rules */} - {item.rules && ( -
-
Rules
-

{item.rules}

+ if (!hasAnyPositivePrice && hasAnyZeroPrice) { + return ( +
+

Free to Borrow

+
+ ); + } + + return ( + <> + {item.pricePerHour !== undefined && + Number(item.pricePerHour) > 0 && ( +
+

+ ${Math.floor(Number(item.pricePerHour))}/Hour +

+
+ )} + {item.pricePerDay !== undefined && + Number(item.pricePerDay) > 0 && ( +
+

+ ${Math.floor(Number(item.pricePerDay))}/Day +

+
+ )} + {item.pricePerWeek !== undefined && + Number(item.pricePerWeek) > 0 && ( +
+

+ ${Math.floor(Number(item.pricePerWeek))}/Week +

+
+ )} + {item.pricePerMonth !== undefined && + Number(item.pricePerMonth) > 0 && ( +
+

+ ${Math.floor(Number(item.pricePerMonth))}/Month +

+
+ )} + + ); + })()} + + {/* Rental Period Selection - Only show for non-owners */} + {!isOwner && item.availability && !isAlreadyRenting && ( + <> +
+
+
+ +
+ + handleDateTimeChange("startDate", e.target.value) + } + min={new Date().toISOString().split("T")[0]} + style={{ flex: '1 1 50%' }} + /> + +
+
+ +
+ +
+ + handleDateTimeChange("endDate", e.target.value) + } + min={ + rentalDates.startDate || + new Date().toISOString().split("T")[0] + } + style={{ flex: '1 1 50%' }} + /> + +
+
+ + {rentalDates.startDate && + rentalDates.endDate && + totalCost > 0 && ( +
+ Total: ${totalCost} +
+ )} +
+ + )} + + {/* Action Buttons */} + {!isOwner && item.availability && !isAlreadyRenting && ( +
+ +
+ )} + {!isOwner && isAlreadyRenting && ( +
+ +
+ )} +
+
- )} - - {/* Replacement Cost (under Rules) */} -
-

Replacement Cost: ${item.replacementCost}

@@ -236,4 +481,4 @@ const ItemDetail: React.FC = () => { ); }; -export default ItemDetail; \ No newline at end of file +export default ItemDetail; diff --git a/frontend/src/pages/ItemList.tsx b/frontend/src/pages/ItemList.tsx index f291faf..cf3bcec 100644 --- a/frontend/src/pages/ItemList.tsx +++ b/frontend/src/pages/ItemList.tsx @@ -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(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 (
@@ -83,7 +83,7 @@ const ItemList: React.FC = () => { return (

Browse Items

- +
{items.length} items found
@@ -94,49 +94,7 @@ const ItemList: React.FC = () => {
{items.map((item) => (
- -
- {item.images && item.images[0] && ( - {item.name} - )} -
-
- {item.name} -
-

{item.description}

- - -
- {item.pricePerDay && ( -
- ${item.pricePerDay}/day -
- )} - {item.pricePerHour && ( -
- ${item.pricePerHour}/hour -
- )} -
- -
- {item.location} -
- - {item.owner && ( - by {item.owner.firstName} {item.owner.lastName} - )} -
-
- +
))}
@@ -145,4 +103,4 @@ const ItemList: React.FC = () => { ); }; -export default ItemList; \ No newline at end of file +export default ItemList; diff --git a/frontend/src/pages/RentItem.tsx b/frontend/src/pages/RentItem.tsx index 8d81c50..205ff70 100644 --- a/frontend/src/pages/RentItem.tsx +++ b/frontend/src/pages/RentItem.tsx @@ -1,158 +1,111 @@ -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(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - + const [formData, setFormData] = useState({ - deliveryMethod: 'pickup' as 'pickup' | 'delivery', - deliveryAddress: '', - cardNumber: '', - cardExpiry: '', - cardCVC: '', - cardName: '' - }); - - const [manualSelection, setManualSelection] = useState({ - startDate: '', - startTime: '09:00', - endDate: '', - endTime: '17:00' + deliveryMethod: "pickup" as "pickup" | "delivery", + deliveryAddress: "", + cardNumber: "", + cardExpiry: "", + cardCVC: "", + cardName: "", }); - const [selectedPeriods, setSelectedPeriods] = useState>([]); + const [manualSelection, setManualSelection] = useState({ + startDate: searchParams.get("startDate") || "", + startTime: searchParams.get("startTime") || "09:00", + endDate: searchParams.get("endDate") || "", + endTime: searchParams.get("endTime") || "17:00", + }); const [totalCost, setTotalCost] = useState(0); - const [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 { const response = await itemAPI.getItem(id!); setItem(response.data); - + // Check if item is available if (!response.data.availability) { - setError('This item is not available for rent'); + 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) => { + 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) => { - 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 (
- {error || 'Item not found'} + {error || "Item not found"}
+
- - {rentalDuration.days < minDays && rentalDuration.days > 0 && !showHourlyOptions && ( -
- Minimum rental period is {minDays} days -
- )} -
+
-
-
-
- -
- - {formData.deliveryMethod === 'delivery' && ( -
- - +
+
+ {item.images && item.images[0] && ( + {item.name} - {item.localDeliveryRadius && ( -
- Delivery available within {item.localDeliveryRadius} miles -
+ )} + +
{item.name}
+

+ {item.city && item.state + ? `${item.city}, ${item.state}` + : item.location} +

+ +
+ + {/* Pricing */} +
+ {totalCost === 0 ? ( +
Free to Borrow
+ ) : ( + <> + {item.pricePerHour && Number(item.pricePerHour) > 0 && ( +
${Math.floor(Number(item.pricePerHour))}/Hour
+ )} + {item.pricePerDay && Number(item.pricePerDay) > 0 && ( +
${Math.floor(Number(item.pricePerDay))}/Day
+ )} + )}
- )} -
-
-
-
-
Payment
- -
- -
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- Your payment information is secure and encrypted. You will only be charged after the owner accepts your rental request. -
-
-
- -
- - -
- -
- -
-
-
-
Rental Summary
- - {item.images && item.images[0] && ( - {item.name} - )} - -
{item.name}
-

{item.location}

- -
- -
- Pricing: - {item.pricePerHour && ( -
${item.pricePerHour}/hour
- )} - {item.pricePerDay && ( -
${item.pricePerDay}/day
- )} -
- - {rentalDuration.days > 0 || rentalDuration.hours > 0 ? ( - <> -
- Duration: -
- {rentalDuration.days > 0 && `${rentalDuration.days} days`} - {rentalDuration.hours > 0 && `${rentalDuration.hours} hours`} + {/* Selected Dates */} + {manualSelection.startDate && manualSelection.endDate && ( +
+
+ Check-in:{" "} + {formatDate(manualSelection.startDate)} at{" "} + {formatTime(manualSelection.startTime)} +
+
+ Check-out:{" "} + {formatDate(manualSelection.endDate)} at{" "} + {formatTime(manualSelection.endTime)} +
-
- -
- -
- Total Cost: - ${totalCost.toFixed(2)} -
- - ) : ( -

Select dates to see total cost

- )} - - {item.rules && ( - <> -
-
- Rules: -

{item.rules}

-
- - )} + )} + + {/* Total Cost */} + {totalCost > 0 && ( + <> +
+
+ Total: + ${totalCost} +
+ + )} +
+
@@ -644,4 +425,4 @@ const RentItem: React.FC = () => { ); }; -export default RentItem; \ No newline at end of file +export default RentItem; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9cc4870..5edef57 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 = {