From 27a7b641dda948fd14644b1af3a5ac5d43092336 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:50:23 -0500 Subject: [PATCH] condition check gallery --- backend/routes/upload.js | 11 +- .../src/components/ConditionCheckModal.tsx | 22 +- .../components/ConditionCheckViewerModal.tsx | 232 ++++++++++++++++++ frontend/src/components/ReturnStatusModal.tsx | 25 +- frontend/src/pages/EditItem.tsx | 8 +- frontend/src/pages/Owning.tsx | 117 ++++++++- frontend/src/pages/Renting.tsx | 62 +++-- frontend/src/services/api.ts | 8 +- 8 files changed, 421 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/ConditionCheckViewerModal.tsx diff --git a/backend/routes/upload.js b/backend/routes/upload.js index d9497a3..e23ec66 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -177,7 +177,16 @@ router.get( requireS3Enabled, async (req, res, next) => { try { - const { key } = req.params; + // Express wildcard params may be string or array - handle both + let key = req.params.key; + if (Array.isArray(key)) { + key = key.join("/"); + } + if (!key || typeof key !== "string") { + return res.status(400).json({ error: "Invalid key parameter" }); + } + // Decode URL-encoded characters (e.g., %2F -> /) + key = decodeURIComponent(key); // Only allow private folders to use signed URLs const isPrivate = diff --git a/frontend/src/components/ConditionCheckModal.tsx b/frontend/src/components/ConditionCheckModal.tsx index 91a1cc8..1c33541 100644 --- a/frontend/src/components/ConditionCheckModal.tsx +++ b/frontend/src/components/ConditionCheckModal.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { conditionCheckAPI } from "../services/api"; +import { uploadFiles } from "../services/uploadService"; import { IMAGE_LIMITS } from "../config/imageLimits"; interface ConditionCheckModalProps { @@ -83,18 +84,17 @@ const ConditionCheckModal: React.FC = ({ setSubmitting(true); setError(null); - const formData = new FormData(); - formData.append("checkType", checkType); - if (notes.trim()) { - formData.append("notes", notes.trim()); - } + // Upload photos to S3 first + const uploadResults = await uploadFiles("condition-check", photos); + const imageFilenames = uploadResults.map((result) => result.key); - photos.forEach((photo, index) => { - formData.append("photos", photo); + // Submit condition check with S3 keys + await conditionCheckAPI.submitConditionCheck(rentalId, { + checkType, + imageFilenames, + notes: notes.trim() || undefined, }); - await conditionCheckAPI.submitConditionCheck(rentalId, formData); - // Reset form setPhotos([]); setNotes(""); @@ -180,8 +180,8 @@ const ConditionCheckModal: React.FC = ({ alt={`Photo ${index + 1}`} className="img-fluid rounded" style={{ - height: "100px", - objectFit: "cover", + maxHeight: "120px", + objectFit: "contain", width: "100%", }} /> diff --git a/frontend/src/components/ConditionCheckViewerModal.tsx b/frontend/src/components/ConditionCheckViewerModal.tsx new file mode 100644 index 0000000..a003687 --- /dev/null +++ b/frontend/src/components/ConditionCheckViewerModal.tsx @@ -0,0 +1,232 @@ +import React, { useState, useEffect } from "react"; +import { ConditionCheck } from "../types"; +import { getSignedUrl } from "../services/uploadService"; + +interface ConditionCheckViewerModalProps { + show: boolean; + onHide: () => void; + conditionCheck: ConditionCheck | null; +} + +const checkTypeLabels: Record = { + pre_rental_owner: "Pre-Rental Check (Owner)", + rental_start_renter: "Rental Start Check (Renter)", + rental_end_renter: "Rental End Check (Renter)", + post_rental_owner: "Post-Rental Check (Owner)", +}; + +const ConditionCheckViewerModal: React.FC = ({ + show, + onHide, + conditionCheck, +}) => { + const [imageUrls, setImageUrls] = useState>(new Map()); + const [selectedImage, setSelectedImage] = useState(0); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const fetchImageUrls = async () => { + if (!conditionCheck?.imageFilenames?.length) return; + + // Filter to only valid string keys + const validKeys = conditionCheck.imageFilenames.filter( + (key): key is string => typeof key === "string" && key.length > 0 + ); + + if (validKeys.length === 0) return; + + setLoading(true); + const newUrls = new Map(); + + try { + await Promise.all( + validKeys.map(async (key) => { + const url = await getSignedUrl(key); + newUrls.set(key, url); + }) + ); + setImageUrls(newUrls); + } catch (error) { + console.error("Failed to fetch image URLs:", error); + } finally { + setLoading(false); + } + }; + + if (show && conditionCheck) { + setSelectedImage(0); + fetchImageUrls(); + } + }, [show, conditionCheck]); + + if (!show || !conditionCheck) return null; + + const submitterName = conditionCheck.submittedByUser + ? `${conditionCheck.submittedByUser.firstName} ${conditionCheck.submittedByUser.lastName}` + : "Unknown"; + + const submittedDate = new Date(conditionCheck.submittedAt).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "short", + }); + + // Filter to only valid string keys for display + const validImageKeys = (conditionCheck.imageFilenames || []).filter( + (key): key is string => typeof key === "string" && key.length > 0 + ); + + const currentImageKey = validImageKeys[selectedImage]; + const currentImageUrl = currentImageKey ? imageUrls.get(currentImageKey) : undefined; + + return ( +
{ + if (e.target === e.currentTarget) onHide(); + }} + > +
+
+
+
+ + {checkTypeLabels[conditionCheck.checkType] || "Condition Check"} +
+
+ +
+ {loading ? ( +
+
+ Loading... +
+

Loading images...

+
+ ) : ( + <> + {/* Main Image */} +
+ {currentImageUrl ? ( + {`Condition + ) : ( +
+ +

Image not available

+
+ )} +
+ + {/* Thumbnail Strip */} + {validImageKeys.length > 1 && ( +
+ {validImageKeys.map((key, index) => { + const thumbUrl = imageUrls.get(key); + return ( +
setSelectedImage(index)} + style={{ + cursor: "pointer", + border: + selectedImage === index + ? "2px solid #0d6efd" + : "2px solid transparent", + borderRadius: "4px", + padding: "2px", + }} + > + {thumbUrl ? ( + {`Thumbnail + ) : ( +
+ +
+ )} +
+ ); + })} +
+ )} + + {/* Image Counter */} +

+ Photo {selectedImage + 1} of {validImageKeys.length} +

+ + {/* Notes */} + {conditionCheck.notes && ( +
+
+ + Notes +
+

{conditionCheck.notes}

+
+ )} + + {/* Metadata */} +
+ + + Submitted by {submitterName} + | + + {submittedDate} + +
+ + )} +
+ +
+ +
+
+
+
+ ); +}; + +export default ConditionCheckViewerModal; diff --git a/frontend/src/components/ReturnStatusModal.tsx b/frontend/src/components/ReturnStatusModal.tsx index f6c5717..53c88be 100644 --- a/frontend/src/components/ReturnStatusModal.tsx +++ b/frontend/src/components/ReturnStatusModal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { rentalAPI, conditionCheckAPI } from "../services/api"; +import { uploadFiles } from "../services/uploadService"; import { Rental } from "../types"; interface ReturnStatusModalProps { @@ -289,19 +290,15 @@ const ReturnStatusModal: React.FC = ({ // Submit post-rental condition check if photos are provided if (photos.length > 0) { - const conditionCheckFormData = new FormData(); - conditionCheckFormData.append("checkType", "post_rental_owner"); - if (conditionNotes.trim()) { - conditionCheckFormData.append("notes", conditionNotes.trim()); - } - photos.forEach((photo) => { - conditionCheckFormData.append("photos", photo); - }); + // Upload photos to S3 first + const uploadResults = await uploadFiles("condition-check", photos); + const imageFilenames = uploadResults.map((result) => result.key); - await conditionCheckAPI.submitConditionCheck( - rental.id, - conditionCheckFormData - ); + await conditionCheckAPI.submitConditionCheck(rental.id, { + checkType: "post_rental_owner", + imageFilenames, + notes: conditionNotes.trim() || undefined, + }); } // Determine primary status for API call @@ -643,8 +640,8 @@ const ReturnStatusModal: React.FC = ({ alt={`Photo ${index + 1}`} className="img-fluid rounded" style={{ - height: "100px", - objectFit: "cover", + maxHeight: "120px", + objectFit: "contain", width: "100%", }} /> diff --git a/frontend/src/pages/EditItem.tsx b/frontend/src/pages/EditItem.tsx index ad84a49..b168041 100644 --- a/frontend/src/pages/EditItem.tsx +++ b/frontend/src/pages/EditItem.tsx @@ -51,6 +51,7 @@ const EditItem: React.FC = () => { const navigate = useNavigate(); const { user } = useAuth(); const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [imageFiles, setImageFiles] = useState([]); @@ -258,6 +259,7 @@ const EditItem: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + setSubmitting(true); // Try to geocode the address before submitting let geocodedCoordinates = null; @@ -341,6 +343,8 @@ const EditItem: React.FC = () => { }, 1500); } catch (err: any) { setError(err.response?.data?.message || err.message || "Failed to update item"); + } finally { + setSubmitting(false); } }; @@ -573,9 +577,9 @@ const EditItem: React.FC = () => { + ) + )} + + )} + {/* Condition Check Buttons */} {getAvailableChecksForRental(rental.id).map((check) => ( ))} @@ -533,7 +610,11 @@ const Owning: React.FC = () => { src={getPublicImageUrl(item.imageFilenames[0])} className="card-img-top" alt={item.name} - style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }} + style={{ + height: "200px", + objectFit: "contain", + backgroundColor: "#f8f9fa", + }} /> )}
@@ -712,6 +793,16 @@ const Owning: React.FC = () => { onSubmitSuccess={handleReturnStatusMarked} /> )} + + {/* Condition Check Viewer Modal */} + { + setShowConditionCheckViewer(false); + setSelectedConditionCheck(null); + }} + conditionCheck={selectedConditionCheck} + />
); }; diff --git a/frontend/src/pages/Renting.tsx b/frontend/src/pages/Renting.tsx index 224c263..1053b7f 100644 --- a/frontend/src/pages/Renting.tsx +++ b/frontend/src/pages/Renting.tsx @@ -3,10 +3,11 @@ import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { rentalAPI, conditionCheckAPI } from "../services/api"; import { getPublicImageUrl } from "../services/uploadService"; -import { Rental } from "../types"; +import { Rental, ConditionCheck } from "../types"; import ReviewItemModal from "../components/ReviewModal"; import RentalCancellationModal from "../components/RentalCancellationModal"; import ConditionCheckModal from "../components/ConditionCheckModal"; +import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal"; const Renting: React.FC = () => { // Helper function to format time @@ -53,7 +54,11 @@ const Renting: React.FC = () => { checkType: string; } | null>(null); const [availableChecks, setAvailableChecks] = useState([]); - const [conditionChecks, setConditionChecks] = useState([]); + const [conditionChecks, setConditionChecks] = useState([]); + const [showConditionCheckViewer, setShowConditionCheckViewer] = + useState(false); + const [selectedConditionCheck, setSelectedConditionCheck] = + useState(null); useEffect(() => { fetchRentals(); @@ -149,7 +154,11 @@ const Renting: React.FC = () => { const handleConditionCheckSuccess = () => { fetchAvailableChecks(); fetchConditionChecks(); - alert("Condition check submitted successfully!"); + }; + + const handleViewConditionCheck = (check: ConditionCheck) => { + setSelectedConditionCheck(check); + setShowConditionCheckViewer(true); }; const getAvailableChecksForRental = (rentalId: string) => { @@ -231,14 +240,19 @@ const Renting: React.FC = () => { className="card h-100" style={{ cursor: rental.item ? "pointer" : "default" }} > - {rental.item?.imageFilenames && rental.item.imageFilenames[0] && ( - {rental.item.name} - )} + {rental.item?.imageFilenames && + rental.item.imageFilenames[0] && ( + {rental.item.name} + )}
{rental.item ? rental.item.name : "Item Unavailable"} @@ -367,20 +381,20 @@ const Renting: React.FC = () => {
{getCompletedChecksForRental(rental.id).map( (check) => ( -
handleViewConditionCheck(check)} > - {check.checkType === "rental_start_renter" - ? "Start Check Completed" - : "End Check Completed"} + ? "Rental Start Condition Noted" + : "Rental End Condition Noted"} {new Date( check.createdAt ).toLocaleDateString()} -
+ ) )}
@@ -397,8 +411,8 @@ const Renting: React.FC = () => { > {check.checkType === "rental_start_renter" - ? "Submit Start Check" - : "Submit End Check"} + ? "Submit Rental Start Condition" + : "Submit Rental End Condition"} ))} @@ -472,6 +486,16 @@ const Renting: React.FC = () => { onSuccess={handleConditionCheckSuccess} /> )} + + {/* Condition Check Viewer Modal */} + { + setShowConditionCheckViewer(false); + setSelectedConditionCheck(null); + }} + conditionCheck={selectedConditionCheck} + />
); }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0584333..636c099 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -332,10 +332,10 @@ export const mapsAPI = { }; export const conditionCheckAPI = { - submitConditionCheck: (rentalId: string, formData: FormData) => - api.post(`/condition-checks/${rentalId}`, formData, { - headers: { "Content-Type": "multipart/form-data" }, - }), + submitConditionCheck: ( + rentalId: string, + data: { checkType: string; imageFilenames: string[]; notes?: string } + ) => api.post(`/condition-checks/${rentalId}`, data), getConditionChecks: (rentalId: string) => api.get(`/condition-checks/${rentalId}`), getConditionCheckTimeline: (rentalId: string) =>