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"}
+
+
+
+
+
+
+ );
+}
diff --git a/components/listings/listing-form.module.css b/components/listings/listing-form.module.css
new file mode 100644
index 0000000..e481702
--- /dev/null
+++ b/components/listings/listing-form.module.css
@@ -0,0 +1,316 @@
+.formPage {
+ min-height: 100vh;
+ background: #1d597e;
+ padding: 0;
+}
+
+.topBanner {
+ width: 100%;
+ min-height: 151px;
+ background: #fffef3;
+ border: 1px solid #000;
+}
+
+.topBannerInner {
+ max-width: 1512px;
+ margin: 0 auto;
+ min-height: 151px;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 2rem;
+ box-sizing: border-box;
+}
+
+.bannerArrowButton {
+ position: relative;
+ width: 36px;
+ height: 36px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+}
+
+.bannerArrow {
+ position: absolute;
+ width: 36px;
+ height: 36px;
+ left: -6px;
+ top: 11px;
+}
+
+.bannerArrowVector {
+ fill: none;
+ stroke: #1d597e;
+ stroke-width: 3.25;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.bannerCopy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.bannerTitle {
+ margin: 0;
+ color: #1d597e;
+ font-size: 3rem;
+ line-height: 1.1;
+ font-weight: 600;
+}
+
+.bannerSubtitle {
+ margin: 0;
+ color: #1d597e;
+ font-size: 1.5rem;
+ line-height: 1.2;
+ font-weight: 400;
+}
+
+.formCard {
+ max-width: 1416px;
+ margin: 2rem auto;
+ border: 1px solid #000;
+ border-radius: 15px;
+ background: #1d597e;
+ padding: 1.5rem;
+}
+
+.headingRow {
+ border-bottom: 1px solid #e5e7eb;
+ margin-bottom: 1.5rem;
+ padding-bottom: 0.75rem;
+}
+
+.headingText {
+ margin: 0;
+ color: #ffffff;
+ font-size: 2rem;
+ line-height: 1.2;
+ font-weight: 700;
+}
+
+.formLayout {
+ display: flex;
+ flex-direction: column;
+ gap: 1.75rem;
+}
+
+.photoBlock {
+ display: flex;
+ gap: 2rem;
+ background: #f9fafb;
+ border: 1px solid #000;
+ border-radius: 20px;
+ box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
+ padding: 1.25rem;
+}
+
+.photoUploader {
+ width: 220px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.photoTitle {
+ font-size: 1.4rem;
+ color: #0a0a0a;
+ font-weight: 600;
+}
+
+.photoDropzone {
+ height: 153px;
+ border: 3px solid #d1d5dc;
+ border-radius: 16px;
+ display: grid;
+ place-items: center;
+ background: #f9fafb;
+}
+
+.uploadText {
+ color: #6a7282;
+ font-size: 1.125rem;
+}
+
+.fileInput {
+ width: 100%;
+}
+
+.photoHelp {
+ flex: 1;
+ align-self: center;
+}
+
+.photoHelp p {
+ margin: 0.25rem 0;
+ color: #1d597e;
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+.fieldsPanel {
+ background: #ffffff;
+ border: 1px solid #000;
+ border-radius: 20px;
+ box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.fieldRow {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.fieldLabel {
+ font-size: 1.25rem;
+ font-weight: 500;
+ color: #000;
+}
+
+.textInput {
+ width: 100%;
+ min-height: 48px;
+ border: 1px solid #000;
+ border-radius: 5px;
+ padding: 0.625rem 0.75rem;
+ font-size: 1rem;
+ font-family: inherit;
+ box-sizing: border-box;
+}
+
+.twoColumnGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.hazardPanel {
+ background: #ffffff;
+ border: 1px solid #000;
+ border-radius: 20px;
+ box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
+ padding: 1.5rem;
+}
+
+.hazardHeading {
+ margin: 0;
+ font-size: 1.5rem;
+}
+
+.hazardDescription {
+ margin: 0.75rem 0 1rem;
+ color: #4a5565;
+ font-size: 1.1rem;
+}
+
+.hazardGrid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.hazardChip {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: #f3f3f5;
+ border: 1px solid #000;
+ border-radius: 15px;
+ min-height: 48px;
+ padding: 0.5rem 0.75rem;
+}
+
+.hazardChip input {
+ width: 1rem;
+ height: 1rem;
+}
+
+.errorText {
+ margin: 0;
+ background: #ffe4e6;
+ color: #9f1239;
+ border: 1px solid #fb7185;
+ border-radius: 8px;
+ padding: 0.75rem 1rem;
+ font-weight: 500;
+}
+
+.buttonRow {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.cancelButton,
+.submitButton {
+ min-height: 60px;
+ border-radius: 13px;
+ font-size: 1.125rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.cancelButton {
+ background: #ffffff;
+ color: #1d597e;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+.submitButton {
+ background: #ff6900;
+ color: #ffffff;
+ border: none;
+}
+
+.cancelButton:disabled,
+.submitButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+@media (max-width: 980px) {
+ .formPage {
+ padding: 0;
+ }
+
+ .topBannerInner {
+ padding: 0.875rem 1rem;
+ min-height: 130px;
+ }
+
+ .bannerTitle {
+ font-size: 2rem;
+ }
+
+ .bannerSubtitle {
+ font-size: 1rem;
+ }
+
+ .formCard {
+ margin: 1rem;
+ }
+
+ .photoBlock {
+ flex-direction: column;
+ }
+
+ .twoColumnGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .hazardGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .buttonRow {
+ grid-template-columns: 1fr;
+ }
+}