diff --git a/backend/market/management/commands/generate_listings.py b/backend/market/management/commands/generate_listings.py
index 91be3e5..c39b55f 100644
--- a/backend/market/management/commands/generate_listings.py
+++ b/backend/market/management/commands/generate_listings.py
@@ -180,19 +180,6 @@ def handle(self, *args, **options):
"House",
]
- neighborhoods = [
- "University City",
- "Center City",
- "Rittenhouse Square",
- "Fairmount",
- "Graduate Hospital",
- "Old City",
- "Northern Liberties",
- "Fishtown",
- "Queen Village",
- "South Philadelphia",
- ]
-
street_names = [
"Walnut",
"Chestnut",
@@ -234,8 +221,8 @@ def handle(self, *args, **options):
# generate random address
street_number = random.randint(100, 4999)
street = random.choice(street_names)
- neighborhood = random.choice(neighborhoods)
- address = f"{street_number} {street} St, {neighborhood}, Philadelphia, PA"
+
+ street_address = f"{street_number} {street} St, Philadelphia, PA"
# random beds and baths
beds = random.randint(0, 4) # 0 for studio
@@ -271,7 +258,7 @@ def handle(self, *args, **options):
price=price,
negotiable=negotiable,
expires_at=expires_at,
- address=address,
+ street_address=street_address,
beds=beds,
baths=baths,
start_date=start_date,
diff --git a/backend/market/migrations/0004_rename_address_sublet_street_address_and_more.py b/backend/market/migrations/0004_rename_address_sublet_street_address_and_more.py
new file mode 100644
index 0000000..ebcdf42
--- /dev/null
+++ b/backend/market/migrations/0004_rename_address_sublet_street_address_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.0.2 on 2026-02-02 20:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("market", "0003_delete_phoneverification"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="sublet",
+ old_name="address",
+ new_name="street_address",
+ ),
+ migrations.AlterField(
+ model_name="listing",
+ name="expires_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/backend/market/models.py b/backend/market/models.py
index 741ae34..f0ad0c9 100644
--- a/backend/market/models.py
+++ b/backend/market/models.py
@@ -89,7 +89,7 @@ class Meta:
)
negotiable = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
- expires_at = models.DateTimeField()
+ expires_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.title} by {self.seller}"
@@ -125,7 +125,7 @@ class Condition(models.TextChoices):
class Sublet(Listing):
- address = models.CharField(max_length=255)
+ street_address = models.CharField(max_length=255)
beds = models.PositiveIntegerField()
baths = models.PositiveIntegerField()
start_date = models.DateField()
diff --git a/backend/market/serializers.py b/backend/market/serializers.py
index 257af71..91604b8 100644
--- a/backend/market/serializers.py
+++ b/backend/market/serializers.py
@@ -2,10 +2,13 @@
from django.core.exceptions import ValidationError as ModelValidationError
from profanity_check import predict
from rest_framework.serializers import (
+ BooleanField,
+ DateTimeField,
ImageField,
ModelSerializer,
SerializerMethodField,
SlugRelatedField,
+ URLField,
ValidationError,
)
@@ -97,7 +100,7 @@ def get_condition(self, obj):
class SubletDataSerializer(ModelSerializer):
class Meta:
model = Sublet
- fields = ["address", "beds", "baths", "start_date", "end_date"]
+ fields = ["street_address", "beds", "baths", "start_date", "end_date"]
# Unified serializer for all listing types (Items and Sublets); used for CRUD operations
@@ -108,16 +111,31 @@ class ListingSerializer(ListingTypeMixin, ModelSerializer):
"model": Item,
},
"sublet": {
- "required_fields": ["address", "beds", "baths", "start_date", "end_date"],
+ "required_fields": [
+ "street_address",
+ "beds",
+ "baths",
+ "start_date",
+ "end_date",
+ ],
"model": Sublet,
},
}
images = ListingImageSerializer(many=True, required=False, read_only=True)
- tags = SlugRelatedField(many=True, slug_field="name", queryset=Tag.objects.all())
+ tags = SlugRelatedField(
+ many=True,
+ slug_field="name",
+ queryset=Tag.objects.all(),
+ required=False,
+ allow_empty=True,
+ )
seller = UserSerializer(read_only=True)
listing_type = SerializerMethodField()
additional_data = SerializerMethodField()
+ external_link = URLField(required=False, allow_blank=True, allow_null=True)
+ negotiable = BooleanField(required=False, default=True)
+ expires_at = DateTimeField(required=False, allow_null=True)
class Meta:
model = Listing
@@ -240,7 +258,7 @@ def _create_sublet(self, validated_data, additional_data):
tags = validated_data.pop("tags", None)
sublet = Sublet.objects.create(
- address=additional_data.get("address"),
+ street_address=additional_data.get("street_address"),
beds=additional_data.get("beds"),
baths=additional_data.get("baths"),
start_date=additional_data.get("start_date"),
@@ -297,7 +315,7 @@ def _update_item(self, instance, additional_data):
def _update_sublet(self, instance, additional_data):
sublet = instance.sublet
- sublet_fields = ["address", "beds", "baths", "start_date", "end_date"]
+ sublet_fields = ["street_address", "beds", "baths", "start_date", "end_date"]
for field in sublet_fields:
if field in additional_data:
setattr(sublet, field, additional_data[field])
diff --git a/backend/market/views.py b/backend/market/views.py
index 965469d..697a022 100644
--- a/backend/market/views.py
+++ b/backend/market/views.py
@@ -1,6 +1,7 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
+from django.db.models import Q
from django.utils import timezone
from rest_framework import exceptions, mixins, status, viewsets
from rest_framework.decorators import api_view, permission_classes
@@ -128,7 +129,7 @@ def get_filter_dict(listing_type):
sublet_filters = {
"beds": "sublet__beds",
"baths": "sublet__baths",
- "address": "sublet__address__icontains",
+ "address": "sublet__street_address__icontains",
}
if listing_type == "item":
@@ -168,7 +169,11 @@ def list(self, request, *args, **kwargs):
if request.query_params.get("seller", "false").lower() == "true":
queryset = queryset.filter(seller=request.user)
else:
- queryset = queryset.filter(expires_at__gte=timezone.now())
+ # Show listings that are not expired, or have no expiration
+ now = timezone.now()
+ queryset = queryset.filter(
+ Q(expires_at__gte=now) | Q(expires_at__isnull=True)
+ )
page = self.paginate_queryset(queryset)
if page is not None:
diff --git a/frontend/app/create/item/page.tsx b/frontend/app/create/item/page.tsx
index d7f055e..8d6283e 100644
--- a/frontend/app/create/item/page.tsx
+++ b/frontend/app/create/item/page.tsx
@@ -1,17 +1,17 @@
import Link from "next/link";
import { BackButton } from "@/components/listings/detail/BackButton";
-import { ListingForm } from "@/components/listings/ListingForm";
+import { ItemForm } from "@/components/listings/form/ItemForm";
export default function CreateItemPage() {
return (
-
+
-
New Item
+ New Item
-
+
);
}
diff --git a/frontend/app/create/sublet/page.tsx b/frontend/app/create/sublet/page.tsx
index 91abbb0..d249c02 100644
--- a/frontend/app/create/sublet/page.tsx
+++ b/frontend/app/create/sublet/page.tsx
@@ -1,17 +1,17 @@
import Link from "next/link";
import { BackButton } from "@/components/listings/detail/BackButton";
-import { ListingForm } from "@/components/listings/ListingForm";
+import { SubletForm } from "@/components/listings/form/SubletForm";
export default function CreateSubletPage() {
return (
-
+
-
New Sublet
+ New Sublet
-
+
);
}
diff --git a/frontend/components/common/FormField.tsx b/frontend/components/common/FormField.tsx
index 1ba5aa3..dcf6dc9 100644
--- a/frontend/components/common/FormField.tsx
+++ b/frontend/components/common/FormField.tsx
@@ -6,26 +6,32 @@ interface FormFieldProps {
touched?: boolean;
optional?: boolean;
helperText?: string;
+ /** Rendered next to the label (e.g. info icon with hover message) */
+ labelSupplement?: ReactNode;
children: ReactNode;
}
-export function FormField({
+export const FormField = ({
label,
error,
touched = false,
optional = false,
helperText,
+ labelSupplement,
children,
-}: FormFieldProps) {
+}: FormFieldProps) => {
return (
-
);
-}
+};
diff --git a/frontend/components/common/FormSelect.tsx b/frontend/components/common/FormSelect.tsx
index 64fcab6..858c1f9 100644
--- a/frontend/components/common/FormSelect.tsx
+++ b/frontend/components/common/FormSelect.tsx
@@ -1,3 +1,4 @@
+import { FormField } from "@/components/common/FormField";
import {
Select,
SelectContent,
@@ -5,59 +6,55 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { FormField } from "@/components/common/FormField";
interface Option {
value: string;
label: string;
}
-type FormSelectPropsBase = {
+interface FormSelectPropsBase {
label: string;
options: Option[];
placeholder?: string;
error?: string;
touched?: boolean;
- required?: boolean;
optional?: boolean;
helperText?: string;
-};
+}
type StringFormSelectProps = FormSelectPropsBase & {
+ valueType?: "string";
value: string | undefined;
onChange: (value: string) => void;
- asNumber?: false;
};
type NumberFormSelectProps = FormSelectPropsBase & {
+ valueType: "number";
value: number | undefined;
onChange: (value: number) => void;
- asNumber: true;
};
type FormSelectProps = StringFormSelectProps | NumberFormSelectProps;
-export function FormSelect(props: FormSelectProps) {
- const {
- label,
- options,
- placeholder = "Select an option",
- error,
- touched,
- required,
- optional,
- helperText,
- } = props;
-
- const stringValue = props.asNumber
- ? props.value !== undefined ? String(props.value) : ""
- : props.value ?? "";
+export const FormSelect = ({
+ label,
+ options,
+ placeholder = "Select an option",
+ error,
+ touched,
+ optional,
+ helperText,
+ valueType,
+ value,
+ onChange,
+}: FormSelectProps) => {
+ const stringValue = value !== undefined ? String(value) : "";
const handleChange = (val: string) => {
- if (props.asNumber) {
- props.onChange(Number(val));
+ if (valueType === "number") {
+ onChange(Number(val));
} else {
- props.onChange(val);
+ onChange(val);
}
};
@@ -83,4 +80,4 @@ export function FormSelect(props: FormSelectProps) {
);
-}
+};
diff --git a/frontend/components/common/ImageDropzone.tsx b/frontend/components/common/ImageDropzone.tsx
new file mode 100644
index 0000000..84551ed
--- /dev/null
+++ b/frontend/components/common/ImageDropzone.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useRef, useId } from "react";
+import { ImagePlus, AlertCircle } from "lucide-react";
+import { ImagePreview } from "@/components/common/ImagePreview";
+import type { ImageFile } from "@/hooks/useImageUpload";
+import { cn } from "@/lib/utils";
+
+interface ImageDropzoneProps {
+ images: ImageFile[];
+ isDragging: boolean;
+ canAddMore: boolean;
+ remainingSlots: number;
+ maxFiles: number;
+ label?: string;
+ error?: string;
+ acceptedFormats?: string;
+ onDragEnter: (e: React.DragEvent) => void;
+ onDragLeave: (e: React.DragEvent) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onDrop: (e: React.DragEvent) => void;
+ onFileSelect: (e: React.ChangeEvent
) => void;
+ onRemove: (id: string) => void;
+}
+
+export const ImageDropzone = ({
+ images,
+ isDragging,
+ canAddMore,
+ remainingSlots,
+ maxFiles,
+ label = "Images",
+ error,
+ acceptedFormats = "image/png,image/jpeg,image/heic,image/webp",
+ onDragEnter,
+ onDragLeave,
+ onDragOver,
+ onDrop,
+ onFileSelect,
+ onRemove,
+}: ImageDropzoneProps) => {
+ const inputRef = useRef(null);
+ const dropzoneId = useId();
+ const errorId = useId();
+ const instructionsId = useId();
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ inputRef.current?.click();
+ }
+ };
+
+ return (
+
+
+
+ {label}
+
+
+ {images.length}/{maxFiles} images
+
+
+
+
canAddMore && inputRef.current?.click()}
+ className={cn(
+ "cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-all",
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
+ isDragging && "scale-[1.02] border-blue-500 bg-blue-50",
+ !isDragging && !error && "border-gray-300 hover:border-blue-400 hover:bg-blue-50/30",
+ error && "border-red-300 bg-red-50/30",
+ !canAddMore && "cursor-not-allowed opacity-50"
+ )}
+ >
+
+
+ {images.length === 0 ? (
+
+
+
+
+
+ {isDragging ? "Drop images here" : "Drop images here or click to browse"}
+
+
+ Up to {maxFiles} images. PNG, JPG, HEIC supported.
+
+
+ ) : (
+
+
+ {images.map((image, index) => (
+ onRemove(image.id)}
+ />
+ ))}
+
+
+ {canAddMore && (
+
+ {remainingSlots === 1
+ ? "You can add 1 more image"
+ : `You can add ${remainingSlots} more images`}
+
+ )}
+
+ )}
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ );
+};
diff --git a/frontend/components/common/ImagePreview.tsx b/frontend/components/common/ImagePreview.tsx
new file mode 100644
index 0000000..677fda3
--- /dev/null
+++ b/frontend/components/common/ImagePreview.tsx
@@ -0,0 +1,75 @@
+import Image from "next/image";
+import { X, AlertCircle } from "lucide-react";
+import type { ImageFile } from "@/hooks/useImageUpload";
+import { cn } from "@/lib/utils";
+
+interface ImagePreviewProps {
+ image: ImageFile;
+ index: number;
+ onRemove: () => void;
+}
+
+export const ImagePreview = ({ image, index, onRemove }: ImagePreviewProps) => {
+ const handleRemoveClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onRemove();
+ };
+
+ const handleRemoveKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ onRemove();
+ }
+ };
+
+ return (
+
+
+
+ {/* Overlay with filename on hover */}
+
+ {image.file.name}
+
+
+ {/* Remove button */}
+
+
+ {/* Status indicator */}
+ {image.status === "uploading" && (
+
+ )}
+
+ {image.status === "error" && (
+
+ )}
+
+ );
+};
diff --git a/frontend/components/listings/ListingForm.tsx b/frontend/components/listings/ListingForm.tsx
deleted file mode 100644
index ab73ffe..0000000
--- a/frontend/components/listings/ListingForm.tsx
+++ /dev/null
@@ -1,498 +0,0 @@
-"use client";
-
-import { useState, useCallback } from "react";
-import { useRouter } from "next/navigation";
-import { useForm, Controller } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import { ImagePlus, X } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { FormField } from "@/components/common/FormField";
-import { FormSelect } from "@/components/common/FormSelect";
-import {
- CATEGORY_OPTIONS,
- CONDITION_OPTIONS,
- BEDS_OPTIONS,
- BATHS_OPTIONS,
-} from "@/lib/constants";
-import { createListing } from "@/lib/actions";
-import { createItemSchema, createSubletSchema } from "@/lib/validations";
-import type { CreateItemPayload, CreateSubletPayload } from "@/lib/types";
-
-type ListingType = "item" | "sublet";
-
-type ListingFormProps = {
- listingType: ListingType;
-};
-
-const config = {
- item: {
- schema: createItemSchema,
- labels: {
- title: "Product Name",
- titlePlaceholder: "Enter Product Name",
- price: "Price",
- description: "Item Description",
- descriptionPlaceholder: "Enter Description (size, details, etc.)",
- media: "Product Media",
- },
- successMessage: "Listing created successfully!",
- queryKey: "items",
- redirectPath: "/items",
- },
- sublet: {
- schema: createSubletSchema,
- labels: {
- title: "Listing Title",
- titlePlaceholder: "e.g., Spacious 2BR near campus",
- price: "Monthly Rent",
- description: "Description",
- descriptionPlaceholder: "Describe the sublet (amenities, location details, etc.)",
- media: "Property Photos",
- },
- successMessage: "Sublet listing created successfully!",
- queryKey: "sublets",
- redirectPath: "/sublets",
- },
-} as const;
-
-// Combined form values type for internal use
-type FormValues = {
- title: string;
- price: string;
- description: string;
- negotiable: boolean;
- expires_at: string;
- external_link: string;
- tags: string[];
- // Item-specific
- condition?: string;
- category?: string;
- // Sublet-specific
- address?: string;
- beds?: number;
- baths?: number;
- start_date?: string;
- end_date?: string;
-};
-
-export function ListingForm({ listingType }: ListingFormProps) {
- const router = useRouter();
- const queryClient = useQueryClient();
- const [images, setImages] = useState([]);
- const [isDragging, setIsDragging] = useState(false);
-
- const isItem = listingType === "item";
- const currentConfig = config[listingType];
-
- const {
- control,
- handleSubmit,
- formState: { errors, touchedFields },
- } = useForm({
- resolver: zodResolver(currentConfig.schema) as any,
- mode: "onChange",
- defaultValues: {
- title: "",
- price: "",
- description: "",
- negotiable: false,
- expires_at: "",
- external_link: "",
- tags: [],
- ...(isItem && { condition: "", category: "" }),
- ...(!isItem && { address: "", beds: 0, baths: 0, start_date: "", end_date: "" }),
- },
- });
-
- const { mutate, isPending } = useMutation({
- mutationFn: createListing,
- onSuccess: (data) => {
- toast.success(currentConfig.successMessage);
- queryClient.invalidateQueries({ queryKey: [currentConfig.queryKey] });
- router.push(`${currentConfig.redirectPath}/${data.id}`);
- },
- onError: (error: Error) => {
- toast.error(error.message || "Failed to create listing");
- },
- });
-
- const handleDragOver = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- setIsDragging(true);
- }, []);
-
- const handleDragLeave = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- setIsDragging(false);
- }, []);
-
- const handleDrop = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- setIsDragging(false);
- const droppedFiles = Array.from(e.dataTransfer.files).filter((file) =>
- file.type.startsWith("image/")
- );
- setImages((prev) => [...prev, ...droppedFiles].slice(0, 10));
- }, []);
-
- const handleFileSelect = useCallback(
- (e: React.ChangeEvent) => {
- if (e.target.files) {
- const selectedFiles = Array.from(e.target.files).filter((file) =>
- file.type.startsWith("image/")
- );
- setImages((prev) => [...prev, ...selectedFiles].slice(0, 10));
- }
- },
- []
- );
-
- const removeImage = useCallback((index: number) => {
- setImages((prev) => prev.filter((_, i) => i !== index));
- }, []);
-
- const onSubmit = (data: any) => {
- const basePayload = {
- title: data.title,
- description: data.description,
- price: String(data.price),
- negotiable: data.negotiable,
- expires_at: data.expires_at ? new Date(data.expires_at).toISOString() : "",
- external_link: data.external_link || undefined,
- tags: data.tags,
- };
-
- const payload = isItem
- ? ({
- ...basePayload,
- listing_type: "item",
- additional_data: {
- condition: data.condition,
- category: data.category,
- },
- } as CreateItemPayload)
- : ({
- ...basePayload,
- listing_type: "sublet",
- additional_data: {
- address: data.address,
- beds: data.beds,
- baths: data.baths,
- start_date: data.start_date,
- end_date: data.end_date,
- },
- } as CreateSubletPayload);
-
- mutate(payload);
- };
-
- return (
-
- );
-}
diff --git a/frontend/components/listings/form/BaseListingForm.tsx b/frontend/components/listings/form/BaseListingForm.tsx
new file mode 100644
index 0000000..f20aaaa
--- /dev/null
+++ b/frontend/components/listings/form/BaseListingForm.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useEffect } from "react";
+import { Controller, Control, FieldErrors, Path, FieldError } from "react-hook-form";
+import { FormField } from "@/components/common/FormField";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import type { BaseCreatePayload } from "@/lib/types";
+
+export interface BaseListingFormProps {
+ control: Control;
+ errors: FieldErrors;
+ touchedFields: Partial>;
+ disabled?: boolean;
+ displayLabel: string;
+ exampleTitle: string;
+ /** when true, show "/mo" suffix on price */
+ isSublet?: boolean;
+ /** rendered between price and description */
+ childrenAfterPrice?: React.ReactNode;
+ /** rendered after description */
+ childrenAfterDescription?: React.ReactNode;
+ /** when provided, warns on tab/window close if form is dirty or has unsaved images */
+ abandonGuard?: { isDirty: boolean; unsavedImageCount: number };
+}
+
+const getErrorMessage = (error: FieldError | undefined): string | undefined => {
+ return error?.message;
+};
+
+export const BaseListingForm = ({
+ control,
+ errors,
+ touchedFields,
+ disabled = false,
+ displayLabel,
+ exampleTitle,
+ isSublet = false,
+ childrenAfterPrice,
+ childrenAfterDescription,
+ abandonGuard,
+}: BaseListingFormProps) => {
+ /**
+ * safe cast: BaseCreatePayload guarantees these keys exist on T.
+ * RHF's Path is too deeply recursive for TS to infer this automatically
+ */
+ const basePath = (name: keyof BaseCreatePayload) => name as Path;
+
+ useEffect(() => {
+ if (abandonGuard === undefined) return;
+ const { isDirty, unsavedImageCount } = abandonGuard;
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (isDirty || unsavedImageCount > 0) {
+ e.preventDefault();
+ }
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [abandonGuard]);
+
+ return (
+ <>
+ {/* title */}
+ (
+
+
+
+ )}
+ />
+
+ {/* price */}
+ (
+
+
+
+ $
+
+
+ {isSublet && (
+
+ /mo
+
+ )}
+
+
+ )}
+ />
+
+ {childrenAfterPrice}
+
+ {/* description */}
+ (
+
+
+
+ )}
+ />
+
+ {childrenAfterDescription}
+ >
+ );
+};
diff --git a/frontend/components/listings/form/ItemForm.tsx b/frontend/components/listings/form/ItemForm.tsx
new file mode 100644
index 0000000..e5791dc
--- /dev/null
+++ b/frontend/components/listings/form/ItemForm.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useForm, Controller } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { FormSelect } from "@/components/common/FormSelect";
+import { BaseListingForm } from "@/components/listings/form/BaseListingForm";
+import { ListingFormShell } from "@/components/listings/form/ListingFormShell";
+import { useImageUpload } from "@/hooks/useImageUpload";
+import { createListing } from "@/lib/actions";
+import { CATEGORY_OPTIONS, CONDITION_OPTIONS } from "@/lib/constants";
+import { parsePriceString } from "@/lib/utils";
+import { type CreateItemFormData, createItemSchema } from "@/lib/validations";
+import type { CreateItemPayload } from "@/lib/types";
+
+const DISPLAY_LABEL = "Item";
+const EXAMPLE_TITLE = "e.g., Nike Air Force 1";
+
+export function ItemForm() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { errors, touchedFields, isDirty, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(createItemSchema),
+ mode: "onBlur",
+ reValidateMode: "onChange",
+ defaultValues: {
+ title: "",
+ price: "",
+ description: "",
+ tags: [],
+ condition: undefined,
+ category: undefined,
+ },
+ });
+
+ const imageUpload = useImageUpload({
+ maxFiles: 10,
+ maxSizeBytes: 10 * 1024 * 1024,
+ onError: (message) => toast.error(message),
+ });
+
+ const { mutate, isPending } = useMutation({
+ mutationFn: createListing,
+ onSuccess: (data) => {
+ toast.success(`${DISPLAY_LABEL} created successfully!`);
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ reset();
+ imageUpload.clearImages();
+ router.replace(`/items/${data.id}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create listing. Please try again.");
+ },
+ });
+
+ const onSubmit = (data: CreateItemFormData) => {
+ const payload: CreateItemPayload = {
+ title: data.title,
+ description: data.description,
+ price: String(parsePriceString(data.price)),
+ listing_type: "item",
+ additional_data: {
+ condition: data.condition,
+ category: data.category,
+ },
+ };
+ mutate(payload);
+ };
+
+ const isFormDisabled = isPending || isSubmitting;
+
+ const itemFieldsAfterDescription = (
+ <>
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ >
+ );
+
+ return (
+
+
+ control={control}
+ errors={errors}
+ touchedFields={touchedFields as Partial>}
+ disabled={isFormDisabled}
+ displayLabel={DISPLAY_LABEL}
+ exampleTitle={EXAMPLE_TITLE}
+ abandonGuard={{
+ isDirty,
+ unsavedImageCount: imageUpload.images.length,
+ }}
+ childrenAfterDescription={itemFieldsAfterDescription}
+ />
+
+ );
+}
diff --git a/frontend/components/listings/form/ListingFormShell.tsx b/frontend/components/listings/form/ListingFormShell.tsx
new file mode 100644
index 0000000..1b53640
--- /dev/null
+++ b/frontend/components/listings/form/ListingFormShell.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ImageDropzone } from "@/components/common/ImageDropzone";
+import type { useImageUpload } from "@/hooks/useImageUpload";
+
+interface ListingFormShellProps {
+ /** Fires on form submission */
+ onSubmit: (e: React.FormEvent) => void;
+ /** Whether the form is currently submitting or pending */
+ isFormDisabled: boolean;
+ /** Whether the mutation is in-flight (controls spinner) */
+ isPending: boolean;
+ /** Display label for the submit button and photo section (e.g., "Item", "Listing") */
+ displayLabel: string;
+ /** Max number of images allowed */
+ maxFiles?: number;
+ /** Everything returned from useImageUpload */
+ imageUpload: ReturnType;
+ /** The form fields (left column) */
+ children: React.ReactNode;
+}
+
+export function ListingFormShell({
+ onSubmit,
+ isFormDisabled,
+ isPending,
+ displayLabel,
+ maxFiles = 10,
+ imageUpload,
+ children,
+}: ListingFormShellProps) {
+ return (
+
+ );
+}
diff --git a/frontend/components/listings/form/SubletForm.tsx b/frontend/components/listings/form/SubletForm.tsx
new file mode 100644
index 0000000..7075813
--- /dev/null
+++ b/frontend/components/listings/form/SubletForm.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useForm, Controller } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Info } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { FormField } from "@/components/common/FormField";
+import { FormSelect } from "@/components/common/FormSelect";
+import { BaseListingForm } from "@/components/listings/form/BaseListingForm";
+import { ListingFormShell } from "@/components/listings/form/ListingFormShell";
+import { useImageUpload } from "@/hooks/useImageUpload";
+import { createListing } from "@/lib/actions";
+import { BEDS_OPTIONS, BATHS_OPTIONS } from "@/lib/constants";
+import { parsePriceString } from "@/lib/utils";
+import { createSubletSchema } from "@/lib/validations";
+import type { CreateSubletPayload } from "@/lib/types";
+import type { CreateSubletFormData } from "@/lib/validations";
+
+const DISPLAY_LABEL = "Listing";
+const EXAMPLE_TITLE = "e.g., Spacious 2BR near campus";
+
+export function SubletForm() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { errors, touchedFields, isDirty, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(createSubletSchema),
+ mode: "onBlur",
+ reValidateMode: "onChange",
+ defaultValues: {
+ title: "",
+ price: "",
+ description: "",
+ tags: [],
+ street_address: "",
+ beds: 0,
+ baths: 0,
+ start_date: "",
+ end_date: "",
+ },
+ });
+
+ const imageUpload = useImageUpload({
+ maxFiles: 10,
+ maxSizeBytes: 10 * 1024 * 1024,
+ onError: (message) => toast.error(message),
+ });
+
+ const { mutate, isPending } = useMutation({
+ mutationFn: createListing,
+ onSuccess: (data) => {
+ toast.success(`${DISPLAY_LABEL} created successfully!`);
+ queryClient.invalidateQueries({ queryKey: ["sublets"] });
+ reset();
+ imageUpload.clearImages();
+ router.replace(`/sublets/${data.id}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create listing. Please try again.");
+ },
+ });
+
+ const onSubmit = (data: CreateSubletFormData) => {
+ const payload: CreateSubletPayload = {
+ title: data.title,
+ description: data.description,
+ price: String(parsePriceString(data.price)),
+ listing_type: "sublet",
+ additional_data: {
+ street_address: data.street_address,
+ beds: data.beds,
+ baths: data.baths,
+ start_date: data.start_date,
+ end_date: data.end_date,
+ },
+ };
+ mutate(payload);
+ };
+
+ const isFormDisabled = isPending || isSubmitting;
+
+ const subletFieldsAfterPrice = (
+ <>
+ (
+
+
+
+ Your address will not be visible to the public. Only an approximate location will
+ be shown on the map.
+
+
+ }
+ >
+
+
+ )}
+ />
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ (
+
+
+
+ )}
+ />
+ (
+
+
+
+ )}
+ />
+
+ >
+ );
+
+ return (
+
+
+ control={control}
+ errors={errors}
+ touchedFields={touchedFields as Partial>}
+ disabled={isFormDisabled}
+ displayLabel={DISPLAY_LABEL}
+ exampleTitle={EXAMPLE_TITLE}
+ isSublet
+ abandonGuard={{
+ isDirty,
+ unsavedImageCount: imageUpload.images.length,
+ }}
+ childrenAfterPrice={subletFieldsAfterPrice}
+ />
+
+ );
+}
diff --git a/frontend/components/navbar/Navbar.tsx b/frontend/components/navbar/Navbar.tsx
index e4bbbd3..c56a614 100644
--- a/frontend/components/navbar/Navbar.tsx
+++ b/frontend/components/navbar/Navbar.tsx
@@ -26,9 +26,7 @@ export const Navbar = () => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const showListingsTabs = isAllListingsPage(pathname);
- const createNewConfig = showListingsTabs
- ? ALL_LISTINGS_PAGES_CONFIG[pathname]
- : undefined;
+ const createNewConfig = showListingsTabs ? ALL_LISTINGS_PAGES_CONFIG[pathname] : undefined;
const toggleMobileMenu = () => setIsMobileMenuOpen(!isMobileMenuOpen);
const closeMobileMenu = () => setIsMobileMenuOpen(false);
@@ -48,13 +46,15 @@ export const Navbar = () => {
)}
- {createNewConfig &&
}
+ {createNewConfig && (
+
+ )}
diff --git a/frontend/components/navbar/NavbarActions.tsx b/frontend/components/navbar/NavbarActions.tsx
index e2698a4..f952bc8 100644
--- a/frontend/components/navbar/NavbarActions.tsx
+++ b/frontend/components/navbar/NavbarActions.tsx
@@ -33,27 +33,29 @@ export const NavbarActions = ({
return (
{/* desktop only new listing button */}
- {
}
{/* mobile only new listing button (icon only) */}
- {
}
diff --git a/frontend/hooks/useImageUpload.ts b/frontend/hooks/useImageUpload.ts
new file mode 100644
index 0000000..906592c
--- /dev/null
+++ b/frontend/hooks/useImageUpload.ts
@@ -0,0 +1,195 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type ImageFile = {
+ file: File;
+ id: string;
+ preview: string;
+ status: "pending" | "uploading" | "uploaded" | "error";
+ error?: string;
+};
+
+type UseImageUploadOptions = {
+ maxFiles?: number;
+ maxSizeBytes?: number;
+ acceptedTypes?: string[];
+ onError?: (message: string) => void;
+};
+
+const DEFAULT_MAX_FILES = 10;
+const DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
+const DEFAULT_ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/heic", "image/webp"];
+
+export function useImageUpload({
+ maxFiles = DEFAULT_MAX_FILES,
+ maxSizeBytes = DEFAULT_MAX_SIZE,
+ acceptedTypes = DEFAULT_ACCEPTED_TYPES,
+ onError,
+}: UseImageUploadOptions = {}) {
+ const [images, setImages] = useState
([]);
+ const [isDragging, setIsDragging] = useState(false);
+ const dragCounter = useRef(0);
+
+ useEffect(() => {
+ return () => {
+ images.forEach((img) => {
+ if (img.preview.startsWith("blob:")) {
+ URL.revokeObjectURL(img.preview);
+ }
+ });
+ };
+ }, [images]);
+
+ const generateId = () => Math.random().toString(36).slice(2, 11);
+
+ const validateFile = useCallback(
+ (file: File): string | null => {
+ if (!acceptedTypes.includes(file.type)) {
+ return `File type "${file.type}" is not supported. Please use PNG, JPG, or HEIC.`;
+ }
+ if (file.size > maxSizeBytes) {
+ const sizeMB = (maxSizeBytes / (1024 * 1024)).toFixed(0);
+ return `File "${file.name}" is too large. Maximum size is ${sizeMB}MB.`;
+ }
+ return null;
+ },
+ [acceptedTypes, maxSizeBytes]
+ );
+
+ const addFiles = useCallback(
+ (files: FileList | File[]) => {
+ const fileArray = Array.from(files);
+ const validFiles: ImageFile[] = [];
+ const errors: string[] = [];
+
+ for (const file of fileArray) {
+ const error = validateFile(file);
+ if (error) {
+ errors.push(error);
+ continue;
+ }
+
+ validFiles.push({
+ file,
+ id: generateId(),
+ preview: URL.createObjectURL(file),
+ status: "pending",
+ });
+ }
+
+ if (errors.length > 0) {
+ onError?.(errors[0]);
+ }
+
+ setImages((prev) => {
+ const combined = [...prev, ...validFiles];
+ if (combined.length > maxFiles) {
+ onError?.(`Maximum ${maxFiles} images allowed. Some images were not added.`);
+ return combined.slice(0, maxFiles);
+ }
+ return combined;
+ });
+ },
+ [validateFile, maxFiles, onError]
+ );
+
+ const removeImage = useCallback((id: string) => {
+ setImages((prev) => {
+ const imageToRemove = prev.find((img) => img.id === id);
+ if (imageToRemove?.preview.startsWith("blob:")) {
+ URL.revokeObjectURL(imageToRemove.preview);
+ }
+ return prev.filter((img) => img.id !== id);
+ });
+ }, []);
+
+ const reorderImages = useCallback((fromIndex: number, toIndex: number) => {
+ setImages((prev) => {
+ const updated = [...prev];
+ const [removed] = updated.splice(fromIndex, 1);
+ updated.splice(toIndex, 0, removed);
+ return updated;
+ });
+ }, []);
+
+ const clearImages = useCallback(() => {
+ setImages((prev) => {
+ prev.forEach((img) => {
+ if (img.preview.startsWith("blob:")) {
+ URL.revokeObjectURL(img.preview);
+ }
+ });
+ return [];
+ });
+ }, []);
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounter.current++;
+ if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
+ setIsDragging(true);
+ }
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounter.current--;
+ if (dragCounter.current === 0) {
+ setIsDragging(false);
+ }
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ dragCounter.current = 0;
+
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ addFiles(e.dataTransfer.files);
+ }
+ },
+ [addFiles]
+ );
+
+ const handleFileSelect = useCallback(
+ (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files.length > 0) {
+ addFiles(e.target.files);
+ }
+ // Reset input to allow selecting the same file again
+ e.target.value = "";
+ },
+ [addFiles]
+ );
+
+ const getFiles = useCallback(() => {
+ return images.map((img) => img.file);
+ }, [images]);
+
+ return {
+ images,
+ isDragging,
+ canAddMore: images.length < maxFiles,
+ remainingSlots: maxFiles - images.length,
+ addFiles,
+ removeImage,
+ reorderImages,
+ clearImages,
+ handleDragEnter,
+ handleDragLeave,
+ handleDragOver,
+ handleDrop,
+ handleFileSelect,
+ getFiles,
+ };
+}
diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts
index 29392a2..111632b 100644
--- a/frontend/lib/actions.ts
+++ b/frontend/lib/actions.ts
@@ -4,7 +4,16 @@ import { cookies } from "next/headers";
import { FETCH_LISTINGS_LIMIT } from "@/constants/listings";
import { API_BASE_URL } from "@/lib/constants";
import { APIError, ErrorMessages } from "@/lib/errors";
-import { AuthTokens, CreateItemPayload, CreateSubletPayload, Item, Listing, PaginatedResponse, Sublet, User } from "@/lib/types";
+import {
+ AuthTokens,
+ CreateItemPayload,
+ CreateSubletPayload,
+ Item,
+ Listing,
+ PaginatedResponse,
+ Sublet,
+ User,
+} from "@/lib/types";
async function getTokensFromCookies(): Promise {
try {
@@ -202,14 +211,10 @@ export async function verifyPhoneCode(phoneNumber: string, code: string) {
});
}
-
-
// ------------------------------------------------------------
// creating new listings
// ------------------------------------------------------------
-
-
export type CreateListingPayload = CreateItemPayload | CreateSubletPayload;
export async function createListing(payload: CreateListingPayload): Promise {
@@ -217,4 +222,4 @@ export async function createListing(payload: CreateListingPayload): Promise;
+// price validation regex: allows optional $, comma-separated thousands, optional decimals (up to 2)
+const priceRegex = /^(\$?\d{1,3}(,\d{3})*|\$?\d+)(\.\d{1,2})?$/;
+const priceSchema = z
+ .string()
+ .trim()
+ .min(1, "Price is required")
+ .refine((s) => priceRegex.test(s), { message: "Price must be a valid amount" })
+ .refine(
+ (s) => {
+ const num = Number(s.replace(/[$,]/g, ""));
+ return num > 0;
+ },
+ { message: "Price must be a positive number" }
+ );
+// Zod schemas using the actual enum values from types.ts
+const itemConditionValues = [
+ "NEW",
+ "LIKE_NEW",
+ "GOOD",
+ "FAIR",
+] as const satisfies readonly ItemCondition[];
+const itemCategoryValues = [
+ "Art",
+ "Books",
+ "Clothing",
+ "Electronics",
+ "Furniture",
+ "Home and Garden",
+ "Music",
+ "Other",
+ "Tools",
+ "Vehicles",
+] as const satisfies readonly ItemCategory[];
export const createItemSchema = z.object({
- title: z.string().trim().min(1, "Title is required").max(200, "Title must be less than 200 characters"),
- description: z.string().trim().min(1, "Description is required").max(5000, "Description must be less than 5000 characters"),
- price: z.string()
+ title: z
+ .string()
.trim()
- .min(1, "Price is required")
- .refine((s) => /^(\$?\d{1,3}(,\d{3})*|\$?\d+)(\.\d{1,2})?$/.test(s), { message: "Price must be a valid amount" })
- .transform((s) => Number(s.replace(/[$,]/g, "")))
- .refine((n) => n > 0, { message: "Price must be a positive number" }),
- negotiable: z.coerce.boolean().default(false),
- expires_at: z.string()
- // .datetime("Expiration must be a valid date/time")
- .optional()
- .refine(
- (val) => !val || new Date(val).getTime() > Date.now(),
- { message: "Expiration must be in the future" }
- ),
- external_link: z.string().trim().url("Must be a valid URL").optional().or(z.literal("")),
- tags: z.array(z.string().trim()).default([]),
- condition: z.string().trim().min(1, "Condition is required"),
- category: z.string().trim().min(1, "Category is required"),
+ .min(1, "Title is required")
+ .max(200, "Title must be less than 200 characters"),
+ description: z
+ .string()
+ .trim()
+ .min(1, "Description is required")
+ .max(5000, "Description must be less than 5000 characters"),
+ price: priceSchema,
+ tags: z.array(z.string().trim()),
+ condition: z.enum(itemConditionValues, "Condition is required"),
+ category: z.enum(itemCategoryValues, "Category is required"),
});
export type CreateItemFormData = z.infer;
+export const createSubletSchema = z
+ .object({
+ title: z
+ .string()
+ .trim()
+ .min(1, "Title is required")
+ .max(200, "Title must be less than 200 characters"),
+ description: z
+ .string()
+ .trim()
+ .min(1, "Description is required")
+ .max(5000, "Description must be less than 5000 characters"),
+ price: priceSchema,
+ tags: z.array(z.string().trim()),
+ street_address: z.string().trim().min(1, "Street address is required"),
+ beds: z.number().int().min(0, "Beds must be 0 or more"),
+ baths: z.number().int().min(0, "Baths must be 0 or more"),
+ start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date is required"),
+ end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date is required"),
+ })
+ .refine(
+ (data) => {
+ const start = Date.parse(data.start_date);
+ const end = Date.parse(data.end_date);
+ return Number.isFinite(start) && Number.isFinite(end) && end > start;
+ },
+ { message: "End date must be after start date", path: ["end_date"] }
+ );
-export const createSubletSchema = z.object({
- title: z.string().trim().min(1, "Title is required").max(200, "Title must be less than 200 characters"),
- description: z.string().trim().min(1, "Description is required").max(5000, "Description must be less than 5000 characters"),
- price: z.string()
- .trim()
- .min(1, "Price is required")
- .refine((s) => /^(\$?\d{1,3}(,\d{3})*|\$?\d+)(\.\d{1,2})?$/.test(s), { message: "Price must be a valid amount" })
- .transform((s) => Number(s.replace(/[$,]/g, "")))
- .refine((n) => n > 0, { message: "Price must be a positive number" }),
- negotiable: z.coerce.boolean().default(false),
- expires_at: z.string()
- // .datetime("Expiration must be a valid date/time")
- .optional()
- .refine(
- (val) => !val || new Date(val).getTime() > Date.now(),
- { message: "Expiration must be in the future" }
- ),
- external_link: z.string().trim().url("Must be a valid URL").optional().or(z.literal("")),
- tags: z.array(z.string().trim()).default([]),
- address: z.string().trim().min(1, "Address is required"),
- beds: z.coerce.number().int().min(0, "Beds must be 0 or more"),
- baths: z.coerce.number().int().min(0, "Baths must be 0 or more"),
- start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date is required"),
- end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "End date is required"),
-}).refine(
- (data) => {
- const start = Date.parse(data.start_date);
- const end = Date.parse(data.end_date);
- return Number.isFinite(start) && Number.isFinite(end) && end > start;
- },
- { message: "End date must be after start date", path: ["end_date"] }
-);
-
-export type CreateSubletFormData = z.infer;
\ No newline at end of file
+export type CreateSubletFormData = z.infer;