diff --git a/app/listings/[id]/edit/page.tsx b/app/listings/[id]/edit/page.tsx new file mode 100644 index 0000000..36e58f9 --- /dev/null +++ b/app/listings/[id]/edit/page.tsx @@ -0,0 +1,96 @@ +import { connectToDatabase } from "@/lib/mongoose"; +import { getListing } from "@/services/listings/listings"; +import { ListingForm } from "@/components/listings/ListingForm"; +import { notFound } from "next/navigation"; + +interface EditListingPageProps { + params: Promise<{ id: string }>; +} + +function isDatabaseConnectionError(error: unknown) { + if (!(error instanceof Error)) { + return false; + } + + return ( + error.name === "MongooseServerSelectionError" || + error.message.includes("ECONNREFUSED") || + error.message.includes("Server selection timed out") + ); +} + +export const metadata = { + title: "Edit listing", +}; + +export default async function EditListingPage({ params }: EditListingPageProps) { + const { id } = await params; + + const fallbackValues = { + itemName: "", + itemId: id, + labId: "", + labName: "", + labLocation: "", + quantityAvailable: 1, + expiryDate: undefined, + description: "", + price: 0, + status: "ACTIVE" as const, + condition: "Good" as const, + hazardTags: [], + imageUrls: [], + }; + + try { + await connectToDatabase(); + const listing = await getListing(id); + + if (!listing) { + return notFound(); + } + + return ; + } catch (error) { + const isDev = process.env.NODE_ENV !== "production"; + if (isDev && isDatabaseConnectionError(error)) { + return ; + } + + return ( +
+
+

+ Listing unavailable +

+

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

+
+
+ ); + } +} diff --git a/app/listings/[id]/page.tsx b/app/listings/[id]/page.tsx index d681693..3f92e40 100644 --- a/app/listings/[id]/page.tsx +++ b/app/listings/[id]/page.tsx @@ -1,6 +1,6 @@ import { connectToDatabase } from "@/lib/mongoose"; +import type { Listing } from "@/models/Listing"; import { getListing } from "@/services/listings/listings"; -import { Listing } from "@/models/Listing"; import { ListingDetails } from "@/components/listings/ListingDetails"; import { notFound } from "next/navigation"; @@ -47,17 +47,39 @@ function getListingContactEmail() { * @returns listing page as entry point */ export default async function ListingPage({ params }: ListingPageProps) { - // const { id } = await params; to test styling page + 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", + imageUrls: [], + quantityAvailable: 1, + createdAt: new Date(), + expiryDate: undefined, + description: + "Database is currently unavailable. This fallback is only shown in development.", + price: 0, + status: "ACTIVE", + condition: "Good", + hazardTags: [], + }; + try { - // await connectToDatabase(); - // const listing = await getListing(id); + await connectToDatabase(); + const listing = await getListing(id); - // if (!listing) notFound(); + if (!listing) { + notFound(); + } return ( ); } catch (error) { diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx new file mode 100644 index 0000000..89351fa --- /dev/null +++ b/app/listings/new/page.tsx @@ -0,0 +1,9 @@ +import { ListingForm } from "@/components/listings/ListingForm"; + +export const metadata = { + title: "Add listing", +}; + +export default function NewListingPage() { + return ; +} diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index 0d94b53..705b257 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; import { useInventory } from "@/app/hooks/useInventory"; import ItemGrid from "@/components/marketplace/ItemGrid"; import MarketplaceFilters, { @@ -29,6 +30,7 @@ const MOCK_USER: User = { // ───────────────────────────────────────────────────────────────────────────── export default function MarketplacePage() { + const router = useRouter(); const currentUser = MOCK_USER; // swap with real auth const { items, isLoading, error } = useInventory(); @@ -76,13 +78,11 @@ export default function MarketplacePage() { // ── Handlers (stubs — wire up to your form/modal later) ────────────────── function handleEditItem(item: Item) { - console.log("Edit item:", item.id); - // TODO: open edit modal/drawer + router.push(`/listings/${item.id}/edit`); } function handleListNewItem() { - console.log("List new item"); - // TODO: open create item form + router.push("/listings/new"); } function handleEditProfile() { diff --git a/components/listings/ListingForm.tsx b/components/listings/ListingForm.tsx new file mode 100644 index 0000000..aee23b7 --- /dev/null +++ b/components/listings/ListingForm.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Listing } from "@/models/Listing"; +import styles from "./listing-form.module.css"; + +interface ListingFormProps { + initialValues?: Partial; + listingId?: string; +} + +const CONDITION_OPTIONS = ["New", "Good", "Fair", "Poor"] as const; +const STATUS_OPTIONS = ["ACTIVE", "INACTIVE"] as const; +const HAZARD_OPTIONS = ["Physical", "Chemical", "Biological", "Other"] as const; + +export function ListingForm({ initialValues, listingId }: ListingFormProps) { + const router = useRouter(); + const isEditMode = Boolean(listingId); + + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [hazards, setHazards] = useState(initialValues?.hazardTags ?? []); + + const expiryDefault = useMemo(() => { + if (!initialValues?.expiryDate) return ""; + const parsed = new Date(initialValues.expiryDate); + if (Number.isNaN(parsed.getTime())) return ""; + return parsed.toISOString().slice(0, 10); + }, [initialValues?.expiryDate]); + + function toggleHazard(tag: string) { + setHazards((previous) => + previous.includes(tag) + ? previous.filter((value) => value !== tag) + : [...previous, tag] + ); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setErrorMessage(null); + + const formData = new FormData(event.currentTarget); + + formData.delete("hazardTags"); + hazards.forEach((tag) => formData.append("hazardTags", tag)); + + const endpoint = isEditMode ? `/api/listings/${listingId}` : "/api/listings"; + const method = isEditMode ? "PUT" : "POST"; + + try { + const response = await fetch(endpoint, { + method, + body: formData, + }); + + const payload = await response.json(); + + if (!response.ok) { + setErrorMessage(payload.message ?? "Unable to save listing."); + setSubmitting(false); + return; + } + + router.push(`/listings/${payload.data.id}`); + } catch { + setErrorMessage("Unable to save listing. Please try again."); + setSubmitting(false); + } + } + + return ( +
+
+
+ +
+

Item details:

+

+ Share resources with other researchers. Fill the details in below: +

+
+
+
+ +
+
+

+ {isEditMode ? "Update Listing" : "Add New Listing"} +

+
+ +
+
+
+ +
+ Upload Photo +
+ +
+
+

Upload a clear photo of the item.

+

Supported formats: JPG, PNG. Max size: 5MB.

+
+
+ +
+ + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +