date time validation and added ability to type in date
This commit is contained in:
@@ -207,6 +207,26 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
rentalStartDateTime = new Date(startDateTime);
|
rentalStartDateTime = new Date(startDateTime);
|
||||||
rentalEndDateTime = new Date(endDateTime);
|
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
|
// Calculate rental cost using duration calculator
|
||||||
totalAmount = RentalDurationCalculator.calculateRentalCost(
|
totalAmount = RentalDurationCalculator.calculateRentalCost(
|
||||||
rentalStartDateTime,
|
rentalStartDateTime,
|
||||||
@@ -937,10 +957,25 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
|||||||
const rentalStartDateTime = new Date(startDateTime);
|
const rentalStartDateTime = new Date(startDateTime);
|
||||||
const rentalEndDateTime = new Date(endDateTime);
|
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
|
// Validate date range
|
||||||
if (rentalEndDateTime <= rentalStartDateTime) {
|
if (rentalEndDateTime <= rentalStartDateTime) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "End must be after start",
|
error: "End date/time must be after start date/time",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
100
frontend/package-lock.json
generated
100
frontend/package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.7",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
@@ -2490,6 +2491,59 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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": {
|
"node_modules/@googlemaps/js-api-loader": {
|
||||||
"version": "1.16.10",
|
"version": "1.16.10",
|
||||||
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.10.tgz",
|
"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"
|
"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": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -6731,6 +6794,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@@ -14468,6 +14541,27 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/react-dev-utils": {
|
||||||
"version": "12.0.1",
|
"version": "12.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
|
"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==",
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.17",
|
"version": "3.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"bootstrap": "^5.3.7",
|
"bootstrap": "^5.3.7",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
|||||||
@@ -15,3 +15,82 @@ code {
|
|||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import DatePicker from "react-datepicker";
|
||||||
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
import { Item, Rental } from "../types";
|
import { Item, Rental } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
@@ -9,6 +11,60 @@ import ItemReviews from "../components/ItemReviews";
|
|||||||
import ConfirmationModal from "../components/ConfirmationModal";
|
import ConfirmationModal from "../components/ConfirmationModal";
|
||||||
import Avatar from "../components/Avatar";
|
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 ItemDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -18,12 +74,20 @@ const ItemDetail: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
const [isAlreadyRenting, setIsAlreadyRenting] = useState(false);
|
||||||
const [rentalDates, setRentalDates] = useState({
|
const [rentalDates, setRentalDates] = useState<{
|
||||||
startDate: "",
|
startDate: Date | null;
|
||||||
startTime: "14:00",
|
startTime: string;
|
||||||
endDate: "",
|
endDate: Date | null;
|
||||||
endTime: "12:00",
|
endTime: string;
|
||||||
|
}>({
|
||||||
|
startDate: null,
|
||||||
|
startTime: "",
|
||||||
|
endDate: null,
|
||||||
|
endTime: "",
|
||||||
});
|
});
|
||||||
|
const [dateValidationError, setDateValidationError] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [totalCost, setTotalCost] = useState(0);
|
const [totalCost, setTotalCost] = useState(0);
|
||||||
const [costLoading, setCostLoading] = useState(false);
|
const [costLoading, setCostLoading] = useState(false);
|
||||||
const [costError, setCostError] = useState<string | null>(null);
|
const [costError, setCostError] = useState<string | null>(null);
|
||||||
@@ -123,11 +187,18 @@ const ItemDetail: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRent = () => {
|
const handleRent = () => {
|
||||||
|
const startDateTime = combineDateTimeToISO(
|
||||||
|
rentalDates.startDate,
|
||||||
|
rentalDates.startTime
|
||||||
|
);
|
||||||
|
const endDateTime = combineDateTimeToISO(
|
||||||
|
rentalDates.endDate,
|
||||||
|
rentalDates.endTime
|
||||||
|
);
|
||||||
|
if (!startDateTime || !endDateTime) return;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDate: rentalDates.startDate,
|
startDateTime,
|
||||||
startTime: rentalDates.startTime,
|
endDateTime,
|
||||||
endDate: rentalDates.endDate,
|
|
||||||
endTime: rentalDates.endTime,
|
|
||||||
});
|
});
|
||||||
navigate(`/items/${id}/rent?${params.toString()}`);
|
navigate(`/items/${id}/rent?${params.toString()}`);
|
||||||
};
|
};
|
||||||
@@ -142,15 +213,29 @@ const ItemDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateTimeChange = (field: string, value: string) => {
|
// Helper to combine date and time into ISO string
|
||||||
setRentalDates((prev) => ({
|
const combineDateTimeToISO = (
|
||||||
...prev,
|
date: Date | null,
|
||||||
[field]: value,
|
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 () => {
|
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);
|
setTotalCost(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -159,14 +244,6 @@ const ItemDetail: React.FC = () => {
|
|||||||
setCostLoading(true);
|
setCostLoading(true);
|
||||||
setCostError(null);
|
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({
|
const response = await rentalAPI.getRentalCostPreview({
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
startDateTime,
|
startDateTime,
|
||||||
@@ -182,15 +259,17 @@ const ItemDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTimeOptions = (item: Item | null, selectedDate: string) => {
|
// Generate time options for dropdown based on item availability for a given date
|
||||||
const options = [];
|
const generateTimeOptions = (
|
||||||
let availableAfter = "00:00";
|
selectedDate: Date | null
|
||||||
let availableBefore = "23:59";
|
): { value: string; label: string }[] => {
|
||||||
|
const options: { value: string; label: string }[] = [];
|
||||||
|
|
||||||
// Determine time constraints only if we have both item and a valid selected date
|
let availableAfterHour = 0;
|
||||||
if (item && selectedDate && selectedDate.trim() !== "") {
|
let availableBeforeHour = 23;
|
||||||
const date = new Date(selectedDate);
|
|
||||||
const dayName = date
|
if (item && selectedDate) {
|
||||||
|
const dayName = selectedDate
|
||||||
.toLocaleDateString("en-US", { weekday: "long" })
|
.toLocaleDateString("en-US", { weekday: "long" })
|
||||||
.toLowerCase() as
|
.toLowerCase() as
|
||||||
| "sunday"
|
| "sunday"
|
||||||
@@ -208,42 +287,30 @@ const ItemDetail: React.FC = () => {
|
|||||||
item.weeklyTimes[dayName]
|
item.weeklyTimes[dayName]
|
||||||
) {
|
) {
|
||||||
const dayTimes = item.weeklyTimes[dayName];
|
const dayTimes = item.weeklyTimes[dayName];
|
||||||
availableAfter = dayTimes.availableAfter;
|
availableAfterHour = parseInt(
|
||||||
availableBefore = dayTimes.availableBefore;
|
dayTimes.availableAfter.split(":")[0],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
availableBeforeHour = parseInt(
|
||||||
|
dayTimes.availableBefore.split(":")[0],
|
||||||
|
10
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Otherwise use global times
|
// Otherwise use global times
|
||||||
else if (item.availableAfter && item.availableBefore) {
|
else if (item.availableAfter && item.availableBefore) {
|
||||||
availableAfter = item.availableAfter;
|
availableAfterHour = parseInt(item.availableAfter.split(":")[0], 10);
|
||||||
availableBefore = item.availableBefore;
|
availableBeforeHour = parseInt(item.availableBefore.split(":")[0], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let hour = 0; hour < 24; hour++) {
|
for (let hour = availableAfterHour; hour <= availableBeforeHour; hour++) {
|
||||||
const time24 = `${hour.toString().padStart(2, "0")}:00`;
|
const hourStr = hour.toString().padStart(2, "0");
|
||||||
|
const period = hour >= 12 ? "PM" : "AM";
|
||||||
// Ensure consistent format for comparison (normalize to HH:MM)
|
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||||
const normalizedAvailableAfter =
|
options.push({
|
||||||
availableAfter.length === 5 ? availableAfter : availableAfter + ":00";
|
value: `${hourStr}:00`,
|
||||||
const normalizedAvailableBefore =
|
label: `${displayHour}:00 ${period}`,
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
@@ -253,44 +320,16 @@ const ItemDetail: React.FC = () => {
|
|||||||
calculateTotalCost();
|
calculateTotalCost();
|
||||||
}, [rentalDates, item]);
|
}, [rentalDates, item]);
|
||||||
|
|
||||||
// Validate and adjust selected times based on item availability
|
// Validate dates whenever they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) return;
|
const validation = validateDates(
|
||||||
|
|
||||||
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(
|
|
||||||
rentalDates.startDate,
|
rentalDates.startDate,
|
||||||
rentalDates.startTime
|
rentalDates.startTime,
|
||||||
);
|
rentalDates.endDate,
|
||||||
const adjustedEndTime = validateAndAdjustTime(
|
|
||||||
rentalDates.endDate || rentalDates.startDate,
|
|
||||||
rentalDates.endTime
|
rentalDates.endTime
|
||||||
);
|
);
|
||||||
|
setDateValidationError(validation.error);
|
||||||
// Update state if times have changed
|
}, [rentalDates]);
|
||||||
if (
|
|
||||||
adjustedStartTime !== rentalDates.startTime ||
|
|
||||||
adjustedEndTime !== rentalDates.endTime
|
|
||||||
) {
|
|
||||||
setRentalDates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
startTime: adjustedStartTime,
|
|
||||||
endTime: adjustedEndTime,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [item, rentalDates.startDate, rentalDates.endDate]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -419,15 +458,21 @@ const ItemDetail: React.FC = () => {
|
|||||||
{item.imageFilenames.length > 0 ? (
|
{item.imageFilenames.length > 0 ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img
|
<img
|
||||||
src={getImageUrl(item.imageFilenames[selectedImage], 'medium')}
|
src={getImageUrl(
|
||||||
|
item.imageFilenames[selectedImage],
|
||||||
|
"medium"
|
||||||
|
)}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="img-fluid rounded mb-3"
|
className="img-fluid rounded mb-3"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
if (!target.dataset.fallback) {
|
if (!target.dataset.fallback) {
|
||||||
target.dataset.fallback = 'true';
|
target.dataset.fallback = "true";
|
||||||
target.src = getImageUrl(item.imageFilenames[selectedImage], 'original');
|
target.src = getImageUrl(
|
||||||
|
item.imageFilenames[selectedImage],
|
||||||
|
"original"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -442,7 +487,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
{item.imageFilenames.map((image, index) => (
|
{item.imageFilenames.map((image, index) => (
|
||||||
<img
|
<img
|
||||||
key={index}
|
key={index}
|
||||||
src={getImageUrl(image, 'thumbnail')}
|
src={getImageUrl(image, "thumbnail")}
|
||||||
alt={`${item.name} ${index + 1}`}
|
alt={`${item.name} ${index + 1}`}
|
||||||
className={`rounded cursor-pointer ${
|
className={`rounded cursor-pointer ${
|
||||||
selectedImage === index
|
selectedImage === index
|
||||||
@@ -453,8 +498,8 @@ const ItemDetail: React.FC = () => {
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
if (!target.dataset.fallback) {
|
if (!target.dataset.fallback) {
|
||||||
target.dataset.fallback = 'true';
|
target.dataset.fallback = "true";
|
||||||
target.src = getImageUrl(image, 'original');
|
target.src = getImageUrl(image, "original");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -605,7 +650,8 @@ const ItemDetail: React.FC = () => {
|
|||||||
Number(item.pricePerMonth) > 0 && (
|
Number(item.pricePerMonth) > 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h4>
|
<h4>
|
||||||
${Math.floor(Number(item.pricePerMonth))}/Month
|
${Math.floor(Number(item.pricePerMonth))}
|
||||||
|
/Month
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -618,105 +664,117 @@ const ItemDetail: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="text-start">
|
<div className="text-start">
|
||||||
|
{dateValidationError && (
|
||||||
|
<div
|
||||||
|
className="alert alert-danger py-2 mb-3"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<small>{dateValidationError}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label fw-medium mb-2">Start</label>
|
<label className="form-label fw-medium mb-2">
|
||||||
<div className="input-group input-group-lg">
|
Start
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<div className="d-flex gap-2">
|
||||||
className="form-control"
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
value={rentalDates.startDate}
|
<DatePicker
|
||||||
onChange={(e) =>
|
selected={rentalDates.startDate}
|
||||||
handleDateTimeChange(
|
onChange={(date: Date | null) =>
|
||||||
"startDate",
|
setRentalDates((prev) => ({
|
||||||
e.target.value
|
...prev,
|
||||||
)
|
startDate: date,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
min={new Date().toLocaleDateString()}
|
minDate={new Date()}
|
||||||
style={{ flex: "1 1 50%" }}
|
dateFormat="MM/dd/yyyy"
|
||||||
|
placeholderText="mm/dd/yyyy"
|
||||||
|
className="form-control form-control-lg w-100"
|
||||||
|
popperProps={{ strategy: "fixed" }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
<select
|
<select
|
||||||
className="form-select time-select"
|
className="form-select form-select-lg"
|
||||||
value={rentalDates.startTime}
|
value={rentalDates.startTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleDateTimeChange(
|
setRentalDates((prev) => ({
|
||||||
"startTime",
|
...prev,
|
||||||
e.target.value
|
startTime: e.target.value,
|
||||||
)
|
}))
|
||||||
}
|
|
||||||
style={{ flex: "1 1 50%" }}
|
|
||||||
disabled={
|
|
||||||
!!(
|
|
||||||
rentalDates.startDate &&
|
|
||||||
generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.startDate
|
|
||||||
).every(
|
|
||||||
(opt) => opt.label === "Not Available"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
disabled={!rentalDates.startDate}
|
||||||
>
|
>
|
||||||
|
<option value="">Pickup</option>
|
||||||
{generateTimeOptions(
|
{generateTimeOptions(
|
||||||
item,
|
|
||||||
rentalDates.startDate
|
rentalDates.startDate
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label fw-medium mb-2">End</label>
|
<label className="form-label fw-medium mb-2">
|
||||||
<div className="input-group input-group-lg">
|
End
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<div className="d-flex gap-2">
|
||||||
className="form-control"
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
value={rentalDates.endDate}
|
<DatePicker
|
||||||
onChange={(e) =>
|
selected={rentalDates.endDate}
|
||||||
handleDateTimeChange("endDate", e.target.value)
|
onChange={(date: Date | null) =>
|
||||||
|
setRentalDates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
endDate: date,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
min={
|
minDate={rentalDates.startDate || new Date()}
|
||||||
rentalDates.startDate ||
|
dateFormat="MM/dd/yyyy"
|
||||||
new Date().toLocaleDateString()
|
placeholderText="mm/dd/yyyy"
|
||||||
}
|
className="form-control form-control-lg w-100"
|
||||||
style={{ flex: "1 1 50%" }}
|
popperProps={{ strategy: "fixed" }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
<select
|
<select
|
||||||
className="form-select time-select"
|
className="form-select form-select-lg"
|
||||||
value={rentalDates.endTime}
|
value={rentalDates.endTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleDateTimeChange("endTime", e.target.value)
|
setRentalDates((prev) => ({
|
||||||
}
|
...prev,
|
||||||
style={{ flex: "1 1 50%" }}
|
endTime: e.target.value,
|
||||||
disabled={
|
}))
|
||||||
!!(
|
|
||||||
(rentalDates.endDate ||
|
|
||||||
rentalDates.startDate) &&
|
|
||||||
generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.endDate || rentalDates.startDate
|
|
||||||
).every(
|
|
||||||
(opt) => opt.label === "Not Available"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
disabled={!rentalDates.endDate}
|
||||||
|
>
|
||||||
|
<option value="">Return</option>
|
||||||
|
{generateTimeOptions(rentalDates.endDate).map(
|
||||||
|
(option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
>
|
>
|
||||||
{generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.endDate || rentalDates.startDate
|
|
||||||
).map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{rentalDates.startDate && rentalDates.endDate && (
|
{rentalDates.startDate &&
|
||||||
|
rentalDates.startTime &&
|
||||||
|
rentalDates.endDate &&
|
||||||
|
rentalDates.endTime &&
|
||||||
|
!dateValidationError && (
|
||||||
<div className="mb-3 p-2 bg-light rounded text-center">
|
<div className="mb-3 p-2 bg-light rounded text-center">
|
||||||
{costLoading ? (
|
{costLoading ? (
|
||||||
<div
|
<div
|
||||||
@@ -728,7 +786,9 @@ const ItemDetail: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : costError ? (
|
) : costError ? (
|
||||||
<small className="text-danger">{costError}</small>
|
<small className="text-danger">
|
||||||
|
{costError}
|
||||||
|
</small>
|
||||||
) : totalCost > 0 ? (
|
) : totalCost > 0 ? (
|
||||||
<strong>Total: ${totalCost}</strong>
|
<strong>Total: ${totalCost}</strong>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -745,7 +805,11 @@ const ItemDetail: React.FC = () => {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleRent}
|
onClick={handleRent}
|
||||||
disabled={
|
disabled={
|
||||||
!rentalDates.startDate || !rentalDates.endDate
|
!rentalDates.startDate ||
|
||||||
|
!rentalDates.startTime ||
|
||||||
|
!rentalDates.endDate ||
|
||||||
|
!rentalDates.endTime ||
|
||||||
|
!!dateValidationError
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Rent Now
|
Rent Now
|
||||||
@@ -759,7 +823,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
disabled
|
disabled
|
||||||
style={{ opacity: 0.8 }}
|
style={{ opacity: 0.8 }}
|
||||||
>
|
>
|
||||||
✓ Renting
|
Renting
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -852,105 +916,115 @@ const ItemDetail: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="text-start">
|
<div className="text-start">
|
||||||
|
{dateValidationError && (
|
||||||
|
<div
|
||||||
|
className="alert alert-danger py-2 mb-3"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<small>{dateValidationError}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label fw-medium mb-2">Start</label>
|
<label className="form-label fw-medium mb-2">
|
||||||
<div className="input-group input-group-lg">
|
Start
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<div className="d-flex gap-2">
|
||||||
className="form-control"
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
value={rentalDates.startDate}
|
<DatePicker
|
||||||
onChange={(e) =>
|
selected={rentalDates.startDate}
|
||||||
handleDateTimeChange(
|
onChange={(date: Date | null) =>
|
||||||
"startDate",
|
setRentalDates((prev) => ({
|
||||||
e.target.value
|
...prev,
|
||||||
)
|
startDate: date,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
min={new Date().toLocaleDateString()}
|
minDate={new Date()}
|
||||||
style={{ flex: "1 1 50%" }}
|
dateFormat="MM/dd/yyyy"
|
||||||
|
placeholderText="mm/dd/yyyy"
|
||||||
|
className="form-control w-100"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
<select
|
<select
|
||||||
className="form-select time-select"
|
className="form-select"
|
||||||
value={rentalDates.startTime}
|
value={rentalDates.startTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleDateTimeChange(
|
setRentalDates((prev) => ({
|
||||||
"startTime",
|
...prev,
|
||||||
e.target.value
|
startTime: e.target.value,
|
||||||
)
|
}))
|
||||||
}
|
|
||||||
style={{ flex: "1 1 50%" }}
|
|
||||||
disabled={
|
|
||||||
!!(
|
|
||||||
rentalDates.startDate &&
|
|
||||||
generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.startDate
|
|
||||||
).every(
|
|
||||||
(opt) => opt.label === "Not Available"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
disabled={!rentalDates.startDate}
|
||||||
|
>
|
||||||
|
<option value="">Pickup</option>
|
||||||
|
{generateTimeOptions(rentalDates.startDate).map(
|
||||||
|
(option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
>
|
>
|
||||||
{generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.startDate
|
|
||||||
).map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label fw-medium mb-2">End</label>
|
<label className="form-label fw-medium mb-2">
|
||||||
<div className="input-group input-group-lg">
|
End
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<div className="d-flex gap-2">
|
||||||
className="form-control"
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
value={rentalDates.endDate}
|
<DatePicker
|
||||||
onChange={(e) =>
|
selected={rentalDates.endDate}
|
||||||
handleDateTimeChange("endDate", e.target.value)
|
onChange={(date: Date | null) =>
|
||||||
|
setRentalDates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
endDate: date,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
min={
|
minDate={rentalDates.startDate || new Date()}
|
||||||
rentalDates.startDate ||
|
dateFormat="MM/dd/yyyy"
|
||||||
new Date().toLocaleDateString()
|
placeholderText="mm/dd/yyyy"
|
||||||
}
|
className="form-control w-100"
|
||||||
style={{ flex: "1 1 50%" }}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: "1 1 50%" }}>
|
||||||
<select
|
<select
|
||||||
className="form-select time-select"
|
className="form-select"
|
||||||
value={rentalDates.endTime}
|
value={rentalDates.endTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleDateTimeChange("endTime", e.target.value)
|
setRentalDates((prev) => ({
|
||||||
}
|
...prev,
|
||||||
style={{ flex: "1 1 50%" }}
|
endTime: e.target.value,
|
||||||
disabled={
|
}))
|
||||||
!!(
|
|
||||||
(rentalDates.endDate ||
|
|
||||||
rentalDates.startDate) &&
|
|
||||||
generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.endDate || rentalDates.startDate
|
|
||||||
).every(
|
|
||||||
(opt) => opt.label === "Not Available"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
disabled={!rentalDates.endDate}
|
||||||
|
>
|
||||||
|
<option value="">Return</option>
|
||||||
|
{generateTimeOptions(rentalDates.endDate).map(
|
||||||
|
(option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
>
|
>
|
||||||
{generateTimeOptions(
|
|
||||||
item,
|
|
||||||
rentalDates.endDate || rentalDates.startDate
|
|
||||||
).map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{rentalDates.startDate && rentalDates.endDate && (
|
{rentalDates.startDate &&
|
||||||
|
rentalDates.startTime &&
|
||||||
|
rentalDates.endDate &&
|
||||||
|
rentalDates.endTime &&
|
||||||
|
!dateValidationError && (
|
||||||
<div className="mb-3 p-2 bg-light rounded text-center">
|
<div className="mb-3 p-2 bg-light rounded text-center">
|
||||||
{costLoading ? (
|
{costLoading ? (
|
||||||
<div
|
<div
|
||||||
@@ -962,7 +1036,9 @@ const ItemDetail: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : costError ? (
|
) : costError ? (
|
||||||
<small className="text-danger">{costError}</small>
|
<small className="text-danger">
|
||||||
|
{costError}
|
||||||
|
</small>
|
||||||
) : totalCost > 0 ? (
|
) : totalCost > 0 ? (
|
||||||
<strong>Total: ${totalCost}</strong>
|
<strong>Total: ${totalCost}</strong>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -979,7 +1055,11 @@ const ItemDetail: React.FC = () => {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleRent}
|
onClick={handleRent}
|
||||||
disabled={
|
disabled={
|
||||||
!rentalDates.startDate || !rentalDates.endDate
|
!rentalDates.startDate ||
|
||||||
|
!rentalDates.startTime ||
|
||||||
|
!rentalDates.endDate ||
|
||||||
|
!rentalDates.endTime ||
|
||||||
|
!!dateValidationError
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Rent Now
|
Rent Now
|
||||||
@@ -993,7 +1073,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
disabled
|
disabled
|
||||||
style={{ opacity: 0.8 }}
|
style={{ opacity: 0.8 }}
|
||||||
>
|
>
|
||||||
✓ Renting
|
Renting
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Item } from "../types";
|
import { Item } from "../types";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
@@ -7,6 +7,37 @@ import { getImageUrl } from "../services/uploadService";
|
|||||||
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
||||||
import VerificationCodeModal from "../components/VerificationCodeModal";
|
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 RentItem: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,53 +53,34 @@ const RentItem: React.FC = () => {
|
|||||||
intendedUse: "",
|
intendedUse: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [manualSelection, setManualSelection] = useState({
|
const [rentalDates, setRentalDates] = useState<{
|
||||||
startDate: searchParams.get("startDate") || "",
|
startDateTime: Date | null;
|
||||||
startTime: searchParams.get("startTime") || "09:00",
|
endDateTime: Date | null;
|
||||||
endDate: searchParams.get("endDate") || "",
|
}>({
|
||||||
endTime: searchParams.get("endTime") || "17:00",
|
startDateTime: parseDateTime(searchParams.get("startDateTime")),
|
||||||
|
endDateTime: parseDateTime(searchParams.get("endDateTime")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [totalCost, setTotalCost] = useState(0);
|
const [totalCost, setTotalCost] = useState(0);
|
||||||
const [costLoading, setCostLoading] = useState(false);
|
const [costLoading, setCostLoading] = useState(false);
|
||||||
const [costError, setCostError] = useState<string | null>(null);
|
const [costError, setCostError] = useState<string | null>(null);
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
|
const [dateValidationError, setDateValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const convertToUTC = (dateString: string, timeString: string): string => {
|
const formatDateTime = (date: Date | null) => {
|
||||||
if (!dateString || !timeString) {
|
if (!date) return "";
|
||||||
throw new Error("Date and time are required");
|
return date.toLocaleString("en-US", {
|
||||||
}
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
// Create date in user's local timezone
|
year: "numeric",
|
||||||
const localDateTime = new Date(`${dateString}T${timeString}`);
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
// Return UTC ISO string
|
hour12: true,
|
||||||
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 calculateTotalCost = async () => {
|
const calculateTotalCost = async () => {
|
||||||
if (!item || !manualSelection.startDate || !manualSelection.endDate) {
|
if (!item || !rentalDates.startDateTime || !rentalDates.endDateTime) {
|
||||||
setTotalCost(0);
|
setTotalCost(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,18 +89,10 @@ const RentItem: React.FC = () => {
|
|||||||
setCostLoading(true);
|
setCostLoading(true);
|
||||||
setCostError(null);
|
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({
|
const response = await rentalAPI.getRentalCostPreview({
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
startDateTime,
|
startDateTime: rentalDates.startDateTime.toISOString(),
|
||||||
endDateTime,
|
endDateTime: rentalDates.endDateTime.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
setTotalCost(response.data.baseAmount);
|
setTotalCost(response.data.baseAmount);
|
||||||
@@ -106,7 +110,16 @@ const RentItem: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
calculateTotalCost();
|
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 () => {
|
const fetchItem = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -130,27 +143,17 @@ const RentItem: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getRentalData = () => {
|
const getRentalData = () => {
|
||||||
try {
|
if (!rentalDates.startDateTime || !rentalDates.endDateTime) {
|
||||||
const startDateTime = convertToUTC(
|
return null;
|
||||||
manualSelection.startDate,
|
}
|
||||||
manualSelection.startTime
|
|
||||||
);
|
|
||||||
const endDateTime = convertToUTC(
|
|
||||||
manualSelection.endDate,
|
|
||||||
manualSelection.endTime
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itemId: id,
|
itemId: id,
|
||||||
startDateTime,
|
startDateTime: rentalDates.startDateTime.toISOString(),
|
||||||
endDateTime,
|
endDateTime: rentalDates.endDateTime.toISOString(),
|
||||||
intendedUse: formData.intendedUse || undefined,
|
intendedUse: formData.intendedUse || undefined,
|
||||||
totalAmount: totalCost,
|
totalAmount: totalCost,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
|
||||||
setError(error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFreeBorrow = async () => {
|
const handleFreeBorrow = async () => {
|
||||||
@@ -306,17 +309,15 @@ const RentItem: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Dates */}
|
{/* Selected Dates */}
|
||||||
{manualSelection.startDate && manualSelection.endDate && (
|
{rentalDates.startDateTime && rentalDates.endDateTime && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="small mb-1">
|
<div className="small mb-1">
|
||||||
<strong>Check-in:</strong>{" "}
|
<strong>Check-in:</strong>{" "}
|
||||||
{formatDate(manualSelection.startDate)} at{" "}
|
{formatDateTime(rentalDates.startDateTime)}
|
||||||
{formatTime(manualSelection.startTime)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="small">
|
<div className="small">
|
||||||
<strong>Check-out:</strong>{" "}
|
<strong>Check-out:</strong>{" "}
|
||||||
{formatDate(manualSelection.endDate)} at{" "}
|
{formatDateTime(rentalDates.endDateTime)}
|
||||||
{formatTime(manualSelection.endTime)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -413,8 +414,13 @@ const RentItem: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!manualSelection.startDate ||
|
{dateValidationError ? (
|
||||||
!manualSelection.endDate ||
|
<div className="alert alert-danger">
|
||||||
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
{dateValidationError}
|
||||||
|
</div>
|
||||||
|
) : !rentalDates.startDateTime ||
|
||||||
|
!rentalDates.endDateTime ||
|
||||||
!getRentalData() ? (
|
!getRentalData() ? (
|
||||||
<div className="alert alert-info">
|
<div className="alert alert-info">
|
||||||
<i className="bi bi-info-circle me-2"></i>
|
<i className="bi bi-info-circle me-2"></i>
|
||||||
|
|||||||
Reference in New Issue
Block a user