From 3d0e553620964536ad0144b6753a9af3182103b0 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:50:19 -0500 Subject: [PATCH] date time validation and added ability to type in date --- backend/routes/rentals.js | 37 +- frontend/package-lock.json | 100 ++++ frontend/package.json | 1 + frontend/src/index.css | 79 ++++ frontend/src/pages/ItemDetail.tsx | 748 +++++++++++++++++------------- frontend/src/pages/RentItem.tsx | 156 ++++--- 6 files changed, 711 insertions(+), 410 deletions(-) diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 498e259..4a3c403 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -207,6 +207,26 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { rentalStartDateTime = new Date(startDateTime); rentalEndDateTime = new Date(endDateTime); + // Validate date formats + if (isNaN(rentalStartDateTime.getTime())) { + return res.status(400).json({ error: "Invalid start date format" }); + } + if (isNaN(rentalEndDateTime.getTime())) { + return res.status(400).json({ error: "Invalid end date format" }); + } + + // Validate start date is not in the past (with 5 minute grace period for form submission) + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + if (rentalStartDateTime < fiveMinutesAgo) { + return res.status(400).json({ error: "Start date cannot be in the past" }); + } + + // Validate end date/time is after start date/time + if (rentalEndDateTime <= rentalStartDateTime) { + return res.status(400).json({ error: "End date/time must be after start date/time" }); + } + // Calculate rental cost using duration calculator totalAmount = RentalDurationCalculator.calculateRentalCost( rentalStartDateTime, @@ -937,10 +957,25 @@ router.post("/cost-preview", authenticateToken, async (req, res) => { const rentalStartDateTime = new Date(startDateTime); const rentalEndDateTime = new Date(endDateTime); + // Validate date formats + if (isNaN(rentalStartDateTime.getTime())) { + return res.status(400).json({ error: "Invalid start date format" }); + } + if (isNaN(rentalEndDateTime.getTime())) { + return res.status(400).json({ error: "Invalid end date format" }); + } + + // Validate start date is not in the past (with 5 minute grace period) + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + if (rentalStartDateTime < fiveMinutesAgo) { + return res.status(400).json({ error: "Start date cannot be in the past" }); + } + // Validate date range if (rentalEndDateTime <= rentalStartDateTime) { return res.status(400).json({ - error: "End must be after start", + error: "End date/time must be after start date/time", }); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2b4349..7a01c4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "bootstrap": "^5.3.7", "browser-image-compression": "^2.0.2", "react": "^19.1.0", + "react-datepicker": "^9.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", "react-scripts": "5.0.1", @@ -2490,6 +2491,59 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@googlemaps/js-api-loader": { "version": "1.16.10", "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.10.tgz", @@ -5920,6 +5974,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6731,6 +6794,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -14468,6 +14541,27 @@ "node": ">=14" } }, + "node_modules/react-datepicker": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz", + "integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.15", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "date-fns-tz": "^3.0.0", + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "date-fns-tz": { + "optional": true + } + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", @@ -16658,6 +16752,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", diff --git a/frontend/package.json b/frontend/package.json index 28e5350..3d50121 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "bootstrap": "^5.3.7", "browser-image-compression": "^2.0.2", "react": "^19.1.0", + "react-datepicker": "^9.1.0", "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", "react-scripts": "5.0.1", diff --git a/frontend/src/index.css b/frontend/src/index.css index b6bd06d..35085f4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -15,3 +15,82 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +/* React DatePicker Customizations */ +.react-datepicker-wrapper { + width: 100%; +} + +.react-datepicker__input-container { + width: 100%; +} + +.react-datepicker__input-container input { + width: 100%; +} + +.react-datepicker { + font-family: inherit; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.react-datepicker__header { + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; +} + +.react-datepicker__day--selected, +.react-datepicker__day--keyboard-selected, +.react-datepicker__time-list-item--selected { + background-color: #0d6efd !important; +} + +.react-datepicker__day:hover { + background-color: #e9ecef; +} + +.react-datepicker__time-container { + border-left: 1px solid #dee2e6; +} + +.react-datepicker__time-list-item:hover { + background-color: #e9ecef !important; +} + +.react-datepicker__time-list-item--disabled { + color: #adb5bd !important; +} + +.react-datepicker__navigation-icon::before { + border-color: #6c757d; +} + +/* Ensure datepicker calendar is not clipped on mobile */ +.react-datepicker-popper { + z-index: 1050 !important; +} + +.card { + overflow: visible !important; +} + +.card-body { + overflow: visible !important; +} + +/* Prevent calendar from going off-screen on mobile */ +@media (max-width: 767px) { + .react-datepicker-popper { + left: 0 !important; + right: 0 !important; + margin-left: auto !important; + margin-right: auto !important; + width: fit-content; + } + + .react-datepicker { + max-width: 90vw; + } +} diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index 83b2242..724c7a1 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; import { Item, Rental } from "../types"; import { useAuth } from "../contexts/AuthContext"; import { itemAPI, rentalAPI } from "../services/api"; @@ -9,6 +11,60 @@ import ItemReviews from "../components/ItemReviews"; import ConfirmationModal from "../components/ConfirmationModal"; import Avatar from "../components/Avatar"; +// Helper function to validate date selection +const validateDates = ( + startDate: Date | null, + startTime: string, + endDate: Date | null, + endTime: string +): { isValid: boolean; error: string | null } => { + if (!startDate || !endDate || !startTime || !endTime) { + return { isValid: false, error: null }; // No error, just incomplete + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + // Check if start date is in the past + const startDateOnly = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + ); + if (startDateOnly < today) { + return { isValid: false, error: "Start date cannot be in the past" }; + } + + // Check if start datetime is in the past (for today's date) + if (startDateOnly.getTime() === today.getTime()) { + const [startHour] = startTime.split(":").map(Number); + const startDateTime = new Date(startDate); + startDateTime.setHours(startHour, 0, 0, 0); + if (startDateTime < now) { + return { isValid: false, error: "Start time cannot be in the past" }; + } + } + + // Check if end date is before start date + const endDateOnly = new Date( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate() + ); + if (endDateOnly < startDateOnly) { + return { isValid: false, error: "End date must be on or after start date" }; + } + + // Check if end time is after start time (same day scenario) + if (startDateOnly.getTime() === endDateOnly.getTime()) { + if (endTime <= startTime) { + return { isValid: false, error: "End time must be after start time" }; + } + } + + return { isValid: true, error: null }; +}; + const ItemDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -18,12 +74,20 @@ 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 [rentalDates, setRentalDates] = useState<{ + startDate: Date | null; + startTime: string; + endDate: Date | null; + endTime: string; + }>({ + startDate: null, + startTime: "", + endDate: null, + endTime: "", }); + const [dateValidationError, setDateValidationError] = useState( + null + ); const [totalCost, setTotalCost] = useState(0); const [costLoading, setCostLoading] = useState(false); const [costError, setCostError] = useState(null); @@ -123,11 +187,18 @@ const ItemDetail: React.FC = () => { }; const handleRent = () => { + const startDateTime = combineDateTimeToISO( + rentalDates.startDate, + rentalDates.startTime + ); + const endDateTime = combineDateTimeToISO( + rentalDates.endDate, + rentalDates.endTime + ); + if (!startDateTime || !endDateTime) return; const params = new URLSearchParams({ - startDate: rentalDates.startDate, - startTime: rentalDates.startTime, - endDate: rentalDates.endDate, - endTime: rentalDates.endTime, + startDateTime, + endDateTime, }); navigate(`/items/${id}/rent?${params.toString()}`); }; @@ -142,15 +213,29 @@ const ItemDetail: React.FC = () => { } }; - const handleDateTimeChange = (field: string, value: string) => { - setRentalDates((prev) => ({ - ...prev, - [field]: value, - })); + // Helper to combine date and time into ISO string + const combineDateTimeToISO = ( + date: Date | null, + time: string + ): string | null => { + if (!date || !time) return null; + const [hours] = time.split(":").map(Number); + const combined = new Date(date); + combined.setHours(hours, 0, 0, 0); + return combined.toISOString(); }; const calculateTotalCost = async () => { - if (!item || !rentalDates.startDate || !rentalDates.endDate) { + const startDateTime = combineDateTimeToISO( + rentalDates.startDate, + rentalDates.startTime + ); + const endDateTime = combineDateTimeToISO( + rentalDates.endDate, + rentalDates.endTime + ); + + if (!item || !startDateTime || !endDateTime) { setTotalCost(0); return; } @@ -159,14 +244,6 @@ const ItemDetail: React.FC = () => { setCostLoading(true); setCostError(null); - const startDateTime = new Date( - `${rentalDates.startDate}T${rentalDates.startTime}` - ).toISOString(); - - const endDateTime = new Date( - `${rentalDates.endDate}T${rentalDates.endTime}` - ).toISOString(); - const response = await rentalAPI.getRentalCostPreview({ itemId: item.id, startDateTime, @@ -182,15 +259,17 @@ const ItemDetail: React.FC = () => { } }; - const generateTimeOptions = (item: Item | null, selectedDate: string) => { - const options = []; - let availableAfter = "00:00"; - let availableBefore = "23:59"; + // Generate time options for dropdown based on item availability for a given date + const generateTimeOptions = ( + selectedDate: Date | null + ): { value: string; label: string }[] => { + const options: { value: string; label: string }[] = []; - // Determine time constraints only if we have both item and a valid selected date - if (item && selectedDate && selectedDate.trim() !== "") { - const date = new Date(selectedDate); - const dayName = date + let availableAfterHour = 0; + let availableBeforeHour = 23; + + if (item && selectedDate) { + const dayName = selectedDate .toLocaleDateString("en-US", { weekday: "long" }) .toLowerCase() as | "sunday" @@ -208,42 +287,30 @@ const ItemDetail: React.FC = () => { item.weeklyTimes[dayName] ) { const dayTimes = item.weeklyTimes[dayName]; - availableAfter = dayTimes.availableAfter; - availableBefore = dayTimes.availableBefore; + availableAfterHour = parseInt( + dayTimes.availableAfter.split(":")[0], + 10 + ); + availableBeforeHour = parseInt( + dayTimes.availableBefore.split(":")[0], + 10 + ); } // Otherwise use global times else if (item.availableAfter && item.availableBefore) { - availableAfter = item.availableAfter; - availableBefore = item.availableBefore; + availableAfterHour = parseInt(item.availableAfter.split(":")[0], 10); + availableBeforeHour = parseInt(item.availableBefore.split(":")[0], 10); } } - for (let hour = 0; hour < 24; hour++) { - const time24 = `${hour.toString().padStart(2, "0")}:00`; - - // Ensure consistent format for comparison (normalize to HH:MM) - const normalizedAvailableAfter = - availableAfter.length === 5 ? availableAfter : availableAfter + ":00"; - const normalizedAvailableBefore = - availableBefore.length === 5 - ? availableBefore - : availableBefore + ":00"; - - // Check if this time is within the available range - if ( - time24 >= normalizedAvailableAfter && - time24 <= normalizedAvailableBefore - ) { - const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - const period = hour < 12 ? "AM" : "PM"; - const time12 = `${hour12}:00 ${period}`; - options.push({ value: time24, label: time12 }); - } - } - - // If no options are available, return at least one option to prevent empty dropdown - if (options.length === 0) { - options.push({ value: "00:00", label: "Not Available" }); + for (let hour = availableAfterHour; hour <= availableBeforeHour; hour++) { + const hourStr = hour.toString().padStart(2, "0"); + const period = hour >= 12 ? "PM" : "AM"; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + options.push({ + value: `${hourStr}:00`, + label: `${displayHour}:00 ${period}`, + }); } return options; @@ -253,44 +320,16 @@ const ItemDetail: React.FC = () => { calculateTotalCost(); }, [rentalDates, item]); - // Validate and adjust selected times based on item availability + // Validate dates whenever they change useEffect(() => { - if (!item) return; - - const validateAndAdjustTime = (date: string, currentTime: string) => { - if (!date) return currentTime; - - const availableOptions = generateTimeOptions(item, date); - if (availableOptions.length === 0) return currentTime; - - // If current time is not in available options, use the first available time - const isCurrentTimeValid = availableOptions.some( - (option) => option.value === currentTime - ); - return isCurrentTimeValid ? currentTime : availableOptions[0].value; - }; - - const adjustedStartTime = validateAndAdjustTime( + const validation = validateDates( rentalDates.startDate, - rentalDates.startTime - ); - const adjustedEndTime = validateAndAdjustTime( - rentalDates.endDate || rentalDates.startDate, + rentalDates.startTime, + rentalDates.endDate, rentalDates.endTime ); - - // Update state if times have changed - if ( - adjustedStartTime !== rentalDates.startTime || - adjustedEndTime !== rentalDates.endTime - ) { - setRentalDates((prev) => ({ - ...prev, - startTime: adjustedStartTime, - endTime: adjustedEndTime, - })); - } - }, [item, rentalDates.startDate, rentalDates.endDate]); + setDateValidationError(validation.error); + }, [rentalDates]); if (loading) { return ( @@ -419,15 +458,21 @@ const ItemDetail: React.FC = () => { {item.imageFilenames.length > 0 ? (
{item.name} { const target = e.currentTarget; if (!target.dataset.fallback) { - target.dataset.fallback = 'true'; - target.src = getImageUrl(item.imageFilenames[selectedImage], 'original'); + target.dataset.fallback = "true"; + target.src = getImageUrl( + item.imageFilenames[selectedImage], + "original" + ); } }} style={{ @@ -442,7 +487,7 @@ const ItemDetail: React.FC = () => { {item.imageFilenames.map((image, index) => ( {`${item.name} { onError={(e) => { const target = e.currentTarget; if (!target.dataset.fallback) { - target.dataset.fallback = 'true'; - target.src = getImageUrl(image, 'original'); + target.dataset.fallback = "true"; + target.src = getImageUrl(image, "original"); } }} style={{ @@ -605,7 +650,8 @@ const ItemDetail: React.FC = () => { Number(item.pricePerMonth) > 0 && (

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

)} @@ -618,122 +664,136 @@ const ItemDetail: React.FC = () => { <>
-
- -
- - handleDateTimeChange( - "startDate", - e.target.value - ) - } - min={new Date().toLocaleDateString()} - style={{ flex: "1 1 50%" }} - /> - -
-
- -
- -
- - handleDateTimeChange("endDate", e.target.value) - } - min={ - rentalDates.startDate || - new Date().toLocaleDateString() - } - style={{ flex: "1 1 50%" }} - /> - -
-
- - {rentalDates.startDate && rentalDates.endDate && ( -
- {costLoading ? ( -
- - Calculating... - -
- ) : costError ? ( - {costError} - ) : totalCost > 0 ? ( - Total: ${totalCost} - ) : null} + {dateValidationError && ( +
+ {dateValidationError}
)} +
+ +
+
+ + setRentalDates((prev) => ({ + ...prev, + startDate: date, + })) + } + minDate={new Date()} + dateFormat="MM/dd/yyyy" + placeholderText="mm/dd/yyyy" + className="form-control form-control-lg w-100" + popperProps={{ strategy: "fixed" }} + /> +
+
+ +
+
+
+ +
+ +
+
+ + setRentalDates((prev) => ({ + ...prev, + endDate: date, + })) + } + minDate={rentalDates.startDate || new Date()} + dateFormat="MM/dd/yyyy" + placeholderText="mm/dd/yyyy" + className="form-control form-control-lg w-100" + popperProps={{ strategy: "fixed" }} + /> +
+
+ +
+
+
+ + {rentalDates.startDate && + rentalDates.startTime && + rentalDates.endDate && + rentalDates.endTime && + !dateValidationError && ( +
+ {costLoading ? ( +
+ + Calculating... + +
+ ) : costError ? ( + + {costError} + + ) : totalCost > 0 ? ( + Total: ${totalCost} + ) : null} +
+ )}
)} @@ -745,7 +805,11 @@ const ItemDetail: React.FC = () => { className="btn btn-primary" onClick={handleRent} disabled={ - !rentalDates.startDate || !rentalDates.endDate + !rentalDates.startDate || + !rentalDates.startTime || + !rentalDates.endDate || + !rentalDates.endTime || + !!dateValidationError } > Rent Now @@ -759,7 +823,7 @@ const ItemDetail: React.FC = () => { disabled style={{ opacity: 0.8 }} > - ✓ Renting + Renting
)} @@ -852,122 +916,134 @@ const ItemDetail: React.FC = () => { <>
-
- -
- - handleDateTimeChange( - "startDate", - e.target.value - ) - } - min={new Date().toLocaleDateString()} - style={{ flex: "1 1 50%" }} - /> - -
-
- -
- -
- - handleDateTimeChange("endDate", e.target.value) - } - min={ - rentalDates.startDate || - new Date().toLocaleDateString() - } - style={{ flex: "1 1 50%" }} - /> - -
-
- - {rentalDates.startDate && rentalDates.endDate && ( -
- {costLoading ? ( -
- - Calculating... - -
- ) : costError ? ( - {costError} - ) : totalCost > 0 ? ( - Total: ${totalCost} - ) : null} + {dateValidationError && ( +
+ {dateValidationError}
)} +
+ +
+
+ + setRentalDates((prev) => ({ + ...prev, + startDate: date, + })) + } + minDate={new Date()} + dateFormat="MM/dd/yyyy" + placeholderText="mm/dd/yyyy" + className="form-control w-100" + /> +
+
+ +
+
+
+ +
+ +
+
+ + setRentalDates((prev) => ({ + ...prev, + endDate: date, + })) + } + minDate={rentalDates.startDate || new Date()} + dateFormat="MM/dd/yyyy" + placeholderText="mm/dd/yyyy" + className="form-control w-100" + /> +
+
+ +
+
+
+ + {rentalDates.startDate && + rentalDates.startTime && + rentalDates.endDate && + rentalDates.endTime && + !dateValidationError && ( +
+ {costLoading ? ( +
+ + Calculating... + +
+ ) : costError ? ( + + {costError} + + ) : totalCost > 0 ? ( + Total: ${totalCost} + ) : null} +
+ )}
)} @@ -979,7 +1055,11 @@ const ItemDetail: React.FC = () => { className="btn btn-primary" onClick={handleRent} disabled={ - !rentalDates.startDate || !rentalDates.endDate + !rentalDates.startDate || + !rentalDates.startTime || + !rentalDates.endDate || + !rentalDates.endTime || + !!dateValidationError } > Rent Now @@ -993,7 +1073,7 @@ const ItemDetail: React.FC = () => { disabled style={{ opacity: 0.8 }} > - ✓ Renting + Renting
)} diff --git a/frontend/src/pages/RentItem.tsx b/frontend/src/pages/RentItem.tsx index 00ca42a..ff08add 100644 --- a/frontend/src/pages/RentItem.tsx +++ b/frontend/src/pages/RentItem.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import { Item } from "../types"; import { useAuth } from "../contexts/AuthContext"; @@ -7,6 +7,37 @@ import { getImageUrl } from "../services/uploadService"; import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout"; import VerificationCodeModal from "../components/VerificationCodeModal"; +// Helper function to validate date selection +const validateDates = ( + startDateTime: Date | null, + endDateTime: Date | null +): { isValid: boolean; error: string | null } => { + if (!startDateTime || !endDateTime) { + return { isValid: false, error: "Start and end date/time are required" }; + } + + const now = new Date(); + + // Check if start datetime is in the past + if (startDateTime < now) { + return { isValid: false, error: "Start date/time cannot be in the past" }; + } + + // Check if end datetime is after start datetime + if (endDateTime <= startDateTime) { + return { isValid: false, error: "End date/time must be after start date/time" }; + } + + return { isValid: true, error: null }; +}; + +// Helper to parse ISO string from URL params +const parseDateTime = (isoString: string | null): Date | null => { + if (!isoString) return null; + const date = new Date(isoString); + return isNaN(date.getTime()) ? null : date; +}; + const RentItem: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -22,53 +53,34 @@ const RentItem: React.FC = () => { intendedUse: "", }); - 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 [rentalDates, setRentalDates] = useState<{ + startDateTime: Date | null; + endDateTime: Date | null; + }>({ + startDateTime: parseDateTime(searchParams.get("startDateTime")), + endDateTime: parseDateTime(searchParams.get("endDateTime")), }); const [totalCost, setTotalCost] = useState(0); const [costLoading, setCostLoading] = useState(false); const [costError, setCostError] = useState(null); const [completed, setCompleted] = useState(false); + const [dateValidationError, setDateValidationError] = useState(null); - const convertToUTC = (dateString: string, timeString: string): string => { - if (!dateString || !timeString) { - throw new Error("Date and time are required"); - } - - // Create date in user's local timezone - const localDateTime = new Date(`${dateString}T${timeString}`); - - // Return UTC ISO string - return localDateTime.toISOString(); - }; - - const formatDate = (dateString: string) => { - if (!dateString) return ""; - // Use safe date parsing to avoid timezone issues - const [year, month, day] = dateString.split("-"); - const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); - return date.toLocaleDateString(); - }; - - const formatTime = (timeString: string) => { - if (!timeString) return ""; - const [hour, minute] = timeString.split(":"); - const hour12 = - parseInt(hour) === 0 - ? 12 - : parseInt(hour) > 12 - ? parseInt(hour) - 12 - : parseInt(hour); - const period = parseInt(hour) < 12 ? "AM" : "PM"; - return `${hour12}:${minute} ${period}`; + const formatDateTime = (date: Date | null) => { + if (!date) return ""; + return date.toLocaleString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); }; const calculateTotalCost = async () => { - if (!item || !manualSelection.startDate || !manualSelection.endDate) { + if (!item || !rentalDates.startDateTime || !rentalDates.endDateTime) { setTotalCost(0); return; } @@ -77,18 +89,10 @@ const RentItem: React.FC = () => { setCostLoading(true); setCostError(null); - const startDateTime = new Date( - `${manualSelection.startDate}T${manualSelection.startTime}` - ).toISOString(); - - const endDateTime = new Date( - `${manualSelection.endDate}T${manualSelection.endTime}` - ).toISOString(); - const response = await rentalAPI.getRentalCostPreview({ itemId: item.id, - startDateTime, - endDateTime, + startDateTime: rentalDates.startDateTime.toISOString(), + endDateTime: rentalDates.endDateTime.toISOString(), }); setTotalCost(response.data.baseAmount); @@ -106,7 +110,16 @@ const RentItem: React.FC = () => { useEffect(() => { calculateTotalCost(); - }, [item, manualSelection]); + }, [item, rentalDates]); + + // Validate dates whenever they change + useEffect(() => { + const validation = validateDates( + rentalDates.startDateTime, + rentalDates.endDateTime + ); + setDateValidationError(validation.error); + }, [rentalDates]); const fetchItem = async () => { try { @@ -130,27 +143,17 @@ const RentItem: React.FC = () => { }; const getRentalData = () => { - try { - const startDateTime = convertToUTC( - manualSelection.startDate, - manualSelection.startTime - ); - const endDateTime = convertToUTC( - manualSelection.endDate, - manualSelection.endTime - ); - - return { - itemId: id, - startDateTime, - endDateTime, - intendedUse: formData.intendedUse || undefined, - totalAmount: totalCost, - }; - } catch (error: any) { - setError(error.message); + if (!rentalDates.startDateTime || !rentalDates.endDateTime) { return null; } + + return { + itemId: id, + startDateTime: rentalDates.startDateTime.toISOString(), + endDateTime: rentalDates.endDateTime.toISOString(), + intendedUse: formData.intendedUse || undefined, + totalAmount: totalCost, + }; }; const handleFreeBorrow = async () => { @@ -306,17 +309,15 @@ const RentItem: React.FC = () => {
{/* Selected Dates */} - {manualSelection.startDate && manualSelection.endDate && ( + {rentalDates.startDateTime && rentalDates.endDateTime && (
Check-in:{" "} - {formatDate(manualSelection.startDate)} at{" "} - {formatTime(manualSelection.startTime)} + {formatDateTime(rentalDates.startDateTime)}
Check-out:{" "} - {formatDate(manualSelection.endDate)} at{" "} - {formatTime(manualSelection.endTime)} + {formatDateTime(rentalDates.endDateTime)}
)} @@ -413,8 +414,13 @@ const RentItem: React.FC = () => { - {!manualSelection.startDate || - !manualSelection.endDate || + {dateValidationError ? ( +
+ + {dateValidationError} +
+ ) : !rentalDates.startDateTime || + !rentalDates.endDateTime || !getRentalData() ? (