From ce54ca0ab756b88d5eae97d739bb3ba7b4a68fa3 Mon Sep 17 00:00:00 2001 From: Amormio Velasquez III Date: Sat, 9 May 2026 08:50:54 -0700 Subject: [PATCH 1/2] feat: connecting copurchase modal to listing view page --- .gitignore | 1 + .prettierrc | 2 +- app/api/listings/route.ts | 373 ++++++++++--------- app/listings/[id]/page.tsx | 86 +---- components/copurchase/Copurchase.module.css | 24 +- components/copurchase/CopurchaseInvite.tsx | 81 ++-- components/copurchase/CopurchaseLabInput.tsx | 72 ++-- components/listings/ListingDetails.tsx | 46 ++- models/Listing.ts | 90 ++--- 9 files changed, 415 insertions(+), 360 deletions(-) diff --git a/.gitignore b/.gitignore index 65030e9..91e6ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ coverage/ .next/ playwright-report/ +.vscode/ diff --git a/.prettierrc b/.prettierrc index 430559f..c16934b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "tabWidth": 4, + "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": false, diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index 556b070..09e030d 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -7,33 +7,33 @@ import { uploadImage } from "@/lib/googleCloud"; import { getSession } from "@/lib/rbac"; const listingValidationSchema = z.object({ - // handle defaults here for the optional fields - itemName: z.string(), - itemId: z.string(), - labName: z.string().optional().default(""), - labLocation: z.string().optional().default(""), - labId: z.string(), - imageUrls: z.array(z.string()).optional().default([]), - quantityAvailable: z.number(), - expiryDate: z.date().optional(), - description: z.string().optional().default(""), - price: z.number().optional().default(0), - status: z.enum(["ACTIVE", "INACTIVE"]), - condition: z.enum(["New", "Good", "Fair", "Poor"]), - hazardTags: z - .array(z.enum(["Physical", "Chemical", "Biological", "Other"])) - .optional() - .default([]), + // handle defaults here for the optional fields + itemName: z.string(), + itemId: z.string(), + labName: z.string().optional().default(""), + labLocation: z.string().optional().default(""), + labId: z.string(), + imageUrls: z.array(z.string()).optional().default([]), + quantityAvailable: z.number(), + expiryDate: z.date().optional(), + description: z.string().optional().default(""), + price: z.number().optional().default(0), + status: z.enum(["ACTIVE", "INACTIVE"]), + condition: z.enum(["New", "Good", "Fair", "Poor"]), + hazardTags: z + .array(z.enum(["Physical", "Chemical", "Biological", "Other"])) + .optional() + .default([]), }); const paramsValidationSchema = z - .object({ - labId: z.string().optional(), - itemId: z.string().optional(), - page: z.number().int().default(1), - limit: z.number().int().default(10), - }) - .strict(); + .object({ + labId: z.string().optional(), + itemId: z.string().optional(), + page: z.number().int().default(1), + limit: z.number().int().default(10), + }) + .strict(); /** * Get filtered listings stored in db @@ -42,64 +42,67 @@ const paramsValidationSchema = z * @returns JSON response with the filtered listings as JS objects */ async function GET(request: Request) { - const { allowed, reason } = await getSession("inventory:view"); - if (!allowed) { - return NextResponse.json( - { - success: false, - message: reason, - }, - { status: 403 } - ); - } - - try { - await connectToDatabase(); - } catch { - return NextResponse.json( - { success: false, message: "Error connecting to database." }, - { status: 500 } - ); - } - - const { searchParams } = new URL(request.url); - - const parsedParams = paramsValidationSchema.safeParse({ - labId: searchParams.get("labId") ?? undefined, - itemId: searchParams.get("itemId") ?? undefined, - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }); - - if (!parsedParams.success) { - return NextResponse.json( - { - success: false, - message: "Invalid request params.", - }, - { status: 400 } - ); - } - - try { - const { listings, pagination } = await getFilteredListings( - parsedParams.data - ); - - return NextResponse.json( - { - success: true, - data: listings, - pagination, - }, - { status: 200 } - ); - } catch { - return NextResponse.json( - { success: false, message: "Error occurred while retrieving listings." }, - { status: 500 } - ); - } + const { allowed, reason } = await getSession("inventory:view"); + if (!allowed) { + return NextResponse.json( + { + success: false, + message: reason, + }, + { status: 403 } + ); + } + + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + + const { searchParams } = new URL(request.url); + + const parsedParams = paramsValidationSchema.safeParse({ + labId: searchParams.get("labId") ?? undefined, + itemId: searchParams.get("itemId") ?? undefined, + page: searchParams.get("page") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + }); + + if (!parsedParams.success) { + return NextResponse.json( + { + success: false, + message: "Invalid request params.", + }, + { status: 400 } + ); + } + + try { + const { listings, pagination } = await getFilteredListings( + parsedParams.data + ); + + return NextResponse.json( + { + success: true, + data: listings, + pagination, + }, + { status: 200 } + ); + } catch { + return NextResponse.json( + { + success: false, + message: "Error occurred while retrieving listings.", + }, + { status: 500 } + ); + } } /** @@ -108,109 +111,115 @@ async function GET(request: Request) { * @returns JSON response with success message and req body echoed */ async function POST(request: Request) { - let { allowed, reason } = await getSession("listing:create"); - if (!allowed) { - return NextResponse.json( - { success: false, message: reason }, - { status: 403 } - ); - } - - ({ allowed, reason } = await getSession("listing:create")); - if (!allowed) { - return NextResponse.json( - { success: false, message: reason }, - { status: 403 } - ); - } - - try { - await connectToDatabase(); - } catch { - return NextResponse.json( - { success: false, message: "Error connecting to database." }, - { status: 500 } - ); - } - const formData = await request.formData(); - const entries = Array.from(formData.entries()); - - // separate image and hazardTags from other fields - const textEntries: [string, FormDataEntryValue][] = []; - const hazardTags: string[] = []; - - for (const [key, value] of entries) { - if (key === "image") continue; - if (key === "hazardTags") { - hazardTags.push(value as string); - } else { - textEntries.push([key, value]); + let { allowed, reason } = await getSession("listing:create"); + if (!allowed) { + return NextResponse.json( + { success: false, message: reason }, + { status: 403 } + ); + } + + ({ allowed, reason } = await getSession("listing:create")); + if (!allowed) { + return NextResponse.json( + { success: false, message: reason }, + { status: 403 } + ); + } + + try { + await connectToDatabase(); + } catch { + return NextResponse.json( + { success: false, message: "Error connecting to database." }, + { status: 500 } + ); + } + const formData = await request.formData(); + const entries = Array.from(formData.entries()); + + // separate image and hazardTags from other fields + const textEntries: [string, FormDataEntryValue][] = []; + const hazardTags: string[] = []; + + for (const [key, value] of entries) { + if (key === "image") continue; + if (key === "hazardTags") { + hazardTags.push(value as string); + } else { + textEntries.push([key, value]); + } + } + + // create plain JS object + const result = Object.fromEntries(textEntries) as Partial; + result.hazardTags = hazardTags as typeof result.hazardTags; + + // convert types (since formData changed to string) + result.quantityAvailable = Number(result.quantityAvailable); + if (result.price) { + result.price = Number(result.price); } - } - - // create plain JS object - const result = Object.fromEntries(textEntries) as Partial; - result.hazardTags = hazardTags as typeof result.hazardTags; - - // convert types (since formData changed to string) - result.quantityAvailable = Number(result.quantityAvailable); - if (result.price) { - result.price = Number(result.price); - } - if (result.expiryDate) { - result.expiryDate = new Date(result.expiryDate as unknown as string); - } - - const imageFiles = formData.getAll("images") as File[]; - - const parsedBody = listingValidationSchema.safeParse(result); - if (!parsedBody.success) { - return NextResponse.json( - { - success: false, - message: "Invalid request body.", - }, - { status: 400 } - ); - } - - if (imageFiles.length > 0) { - const imageUrls: string[] = []; - for (const imageFile of imageFiles) { - const buffer = Buffer.from(await imageFile.arrayBuffer()); - const imageUrl = await uploadImage(buffer, imageFile.name); - imageUrls.push(imageUrl); + if (result.expiryDate) { + result.expiryDate = new Date(result.expiryDate as unknown as string); } - result.imageUrls = imageUrls; - } - - try { - const listingData = { - ...parsedBody.data, - createdAt: new Date(), - } as ListingInput; - const listing = await addListing(listingData); - return NextResponse.json( - { - success: true, - message: "Successfully created new listing.", - data: listing, - }, - { status: 201, headers: { Location: `/app/listings/${listing.id}` } } - ); - } catch (error: any) { - if (error.code === 11000) { - return NextResponse.json( - // don't send mongo's error - exposes design info - { success: false, message: "This listing already exists." }, - { status: 409 } - ); + + const imageFiles = formData.getAll("images") as File[]; + + const parsedBody = listingValidationSchema.safeParse(result); + if (!parsedBody.success) { + return NextResponse.json( + { + success: false, + message: "Invalid request body.", + }, + { status: 400 } + ); + } + + if (imageFiles.length > 0) { + const imageUrls: string[] = []; + for (const imageFile of imageFiles) { + const buffer = Buffer.from(await imageFile.arrayBuffer()); + const imageUrl = await uploadImage(buffer, imageFile.name); + imageUrls.push(imageUrl); + } + result.imageUrls = imageUrls; + } + + try { + const listingData = { + ...parsedBody.data, + createdAt: new Date(), + } as ListingInput; + const listing = await addListing(listingData); + return NextResponse.json( + { + success: true, + message: "Successfully created new listing.", + data: listing, + }, + { + status: 201, + headers: { Location: `/app/listings/${listing.id}` }, + } + ); + } catch (error: any) { + if (error.code === 11000) { + return NextResponse.json( + // don't send mongo's error - exposes design info + { success: false, message: "This listing already exists." }, + { status: 409 } + ); + } + return NextResponse.json( + { + success: false, + message: "Error occurred while creating new listing.", + }, + { status: 500 } + ); } - return NextResponse.json( - { success: false, message: "Error occurred while creating new listing." }, - { status: 500 } - ); - } } export { GET, POST }; diff --git a/app/listings/[id]/page.tsx b/app/listings/[id]/page.tsx index 3f92e40..d9cdeee 100644 --- a/app/listings/[id]/page.tsx +++ b/app/listings/[id]/page.tsx @@ -4,25 +4,6 @@ import { getListing } from "@/services/listings/listings"; import { ListingDetails } from "@/components/listings/ListingDetails"; import { notFound } from "next/navigation"; -const mockListing: Listing = { - id: "demo-listing-id", - itemName: "Digital Microscope", - itemId: "MISC-0123", - labName: "Bioimaging Core Lab", - labLocation: "Torrey Pines", - labId: "lab-1", - imageUrls: [], - quantityAvailable: 3, - createdAt: new Date(), - expiryDate: new Date("2026-05-15"), - description: - "High-resolution digital microscope available from the lab inventory marketplace.", - price: 1200, - status: "ACTIVE", - condition: "Good", - hazardTags: ["Physical", "Chemical", "Biological"], -}; - interface ListingPageProps { params: Promise<{ id: string; @@ -31,7 +12,7 @@ interface ListingPageProps { /** * Resolve a contact email for the listing until proper transaction - * functionality exists + * functionality exists. */ function getListingContactEmail() { return ( @@ -42,30 +23,28 @@ function getListingContactEmail() { } /** - * Listing page for a single listing - * @param params receives props including listing id - * @returns listing page as entry point + * Listing page for a single listing. */ export default async function ListingPage({ params }: ListingPageProps) { const { id } = await params; const fallbackListing: Listing = { - id, - itemName: "Listing Preview", - itemId: id, - labName: "Marketplace", - labLocation: "Unavailable in offline dev mode", - labId: "lab-dev-fallback", + id: "demo-listing-id", + itemName: "Digital Microscope", + itemId: "MISC-0123", + labName: "Bioimaging Core Lab", + labLocation: "Torrey Pines", + labId: "lab-1", imageUrls: [], - quantityAvailable: 1, + quantityAvailable: 3, createdAt: new Date(), - expiryDate: undefined, + expiryDate: new Date("2026-05-15"), description: - "Database is currently unavailable. This fallback is only shown in development.", - price: 0, + "High-resolution digital microscope available from the lab inventory marketplace.", + price: 1200, status: "ACTIVE", condition: "Good", - hazardTags: [], + hazardTags: ["Physical", "Chemical", "Biological"], }; try { @@ -83,42 +62,13 @@ export default async function ListingPage({ params }: ListingPageProps) { /> ); } catch (error) { - console.error("Error loading listing page: ", error); + console.error("Error loading listing page:", error); return ( -
-
-

- Listing unavailable -

-

- The listing could not be loaded right now. Please try refreshing the - page in a moment. -

-
-
+ ); } } diff --git a/components/copurchase/Copurchase.module.css b/components/copurchase/Copurchase.module.css index 28a41af..e4c178e 100644 --- a/components/copurchase/Copurchase.module.css +++ b/components/copurchase/Copurchase.module.css @@ -63,7 +63,9 @@ outline: none; margin-bottom: 14px; box-sizing: border-box; - transition: border-color 0.15s, box-shadow 0.15s; + transition: + border-color 0.15s, + box-shadow 0.15s; } .input:focus { @@ -87,7 +89,9 @@ padding: 0 20px; background: #2e6b8a; color: #fff; - transition: opacity 0.15s, transform 0.1s; + transition: + opacity 0.15s, + transform 0.1s; } .btnPrimary:hover { @@ -108,7 +112,9 @@ padding: 0 20px; background: #f0f0f0; color: #444; - transition: opacity 0.15s, transform 0.1s; + transition: + opacity 0.15s, + transform 0.1s; } .btnSecondary:hover { @@ -117,4 +123,14 @@ .btnSecondary:active { transform: scale(0.97); -} \ No newline at end of file +} + +.overlay { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 1.5rem; + background: rgba(0, 0, 0, 0.45); +} diff --git a/components/copurchase/CopurchaseInvite.tsx b/components/copurchase/CopurchaseInvite.tsx index c996e70..3f31e1d 100644 --- a/components/copurchase/CopurchaseInvite.tsx +++ b/components/copurchase/CopurchaseInvite.tsx @@ -1,12 +1,15 @@ -import { useState } from "react"; -import styles from "./CoPurchase.module.css"; +import { useEffect, useState } from "react"; +import styles from "./Copurchase.module.css"; interface InviteModalProps { onClose: () => void; onSend: (email: string) => void; } -export default function CopurchaseInvite({ onClose, onSend }: InviteModalProps) { +export default function CopurchaseInvite({ + onClose, + onSend, +}: InviteModalProps) { const [email, setEmail] = useState(""); const handleSend = () => { @@ -14,35 +17,55 @@ export default function CopurchaseInvite({ onClose, onSend }: InviteModalProps) onClose(); }; + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + return ( -
- - -
-
Invite to Co-purchase
-
- Invite other labs or colleagues to split the cost of this item. +
+
event.stopPropagation()}> + + +
+
Invite to Co-purchase
+
+ Invite other labs or colleagues to split the cost of this item. +
-
- setEmail(e.target.value)} - /> - -
- - + setEmail(e.target.value)} + /> + +
+ + +
); -} \ No newline at end of file +} diff --git a/components/copurchase/CopurchaseLabInput.tsx b/components/copurchase/CopurchaseLabInput.tsx index a98b39a..82b1f51 100644 --- a/components/copurchase/CopurchaseLabInput.tsx +++ b/components/copurchase/CopurchaseLabInput.tsx @@ -1,45 +1,65 @@ -import { useState } from "react"; -import styles from "./CoPurchase.module.css"; +import { useEffect, useState } from "react"; +import styles from "./Copurchase.module.css"; interface InviteModalProps { onClose: () => void; onSend: (email: string) => void; } -export default function CopurchaseInvite({ onClose, onSend }: InviteModalProps) { +export default function CopurchaseInvite({ + onClose, + onSend, +}: InviteModalProps) { const [email, setEmail] = useState(""); const handleSend = () => { onSend(email); - onClose(); + // onClose(); }; - return ( -
- + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; -
-
Enter your lab
-
- Enter the name of your desired lab -
-
+ document.addEventListener("keydown", handleKeyDown); - setEmail(e.target.value)} - /> + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); -
- +
+
Enter your lab
+
+ Enter the name of your desired lab +
+
+ setEmail(e.target.value)} + /> +
+ +
); -} \ No newline at end of file +} diff --git a/components/listings/ListingDetails.tsx b/components/listings/ListingDetails.tsx index a6d65a0..62ea5eb 100644 --- a/components/listings/ListingDetails.tsx +++ b/components/listings/ListingDetails.tsx @@ -5,6 +5,8 @@ import { Listing } from "@/models/Listing"; import { ListingHeader } from "./ListingHeader"; import { ContactModal } from "./ContactModal"; import styles from "./listing-view.module.css"; +import CopurchaseLabInput from "@/components/copurchase/CopurchaseLabInput"; +import CopurchaseInvite from "@/components/copurchase/CopurchaseInvite"; interface ListingDetailsProps { contactEmail: string; @@ -98,14 +100,17 @@ export function ListingDetails({ contactEmail, listing }: ListingDetailsProps) { const [activeImage, setActiveImage] = useState(imageUrls[0]); const [quantity, setQuantity] = useState(1); + const [copurchaseStep, setCopurchaseStep] = useState< + "closed" | "lab" | "invite" + >("closed"); + const [selectedLab, setSelectedLab] = useState(""); + function increaseQuantity() { - setQuantity((quantity) => - Math.min(listing.quantityAvailable, quantity + 1) - ); + setQuantity(quantity => Math.min(listing.quantityAvailable, quantity + 1)); } function decreaseQuantity() { - setQuantity((quantity) => Math.max(1, quantity - 1)); + setQuantity(quantity => Math.max(1, quantity - 1)); } const listingMeta = [ @@ -230,9 +235,14 @@ export function ListingDetails({ contactEmail, listing }: ListingDetailsProps) {
- +