date time validation and added ability to type in date

This commit is contained in:
jackiettran
2026-01-01 00:50:19 -05:00
parent f66dccdfa3
commit 3d0e553620
6 changed files with 711 additions and 410 deletions

View File

@@ -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",
}); });
} }

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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>
)} )}

View File

@@ -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>