From d72898523031928fbfbdd90d7591aaa17e8e61fb Mon Sep 17 00:00:00 2001 From: James Doh Date: Sat, 7 Feb 2026 21:48:51 -0500 Subject: [PATCH 1/3] make certain fields optional for listing --- .../management/commands/generate_listings.py | 19 ++----------- ..._address_sublet_street_address_and_more.py | 23 +++++++++++++++ backend/market/models.py | 4 +-- backend/market/serializers.py | 28 +++++++++++++++---- backend/market/views.py | 9 ++++-- 5 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 backend/market/migrations/0004_rename_address_sublet_street_address_and_more.py 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: From e46fb42a30bef8b053fa5be914172a437099bd62 Mon Sep 17 00:00:00 2001 From: James Doh Date: Sat, 7 Feb 2026 21:51:35 -0500 Subject: [PATCH 2/3] refactor listing form --- frontend/app/create/item/page.tsx | 8 +- frontend/app/create/sublet/page.tsx | 8 +- frontend/components/common/FormField.tsx | 18 +- frontend/components/common/FormSelect.tsx | 47 +- frontend/components/common/ImageDropzone.tsx | 160 ++++++ frontend/components/common/ImagePreview.tsx | 75 +++ frontend/components/listings/ListingForm.tsx | 498 ------------------ .../listings/form/BaseListingForm.tsx | 152 ++++++ .../components/listings/form/ItemForm.tsx | 138 +++++ .../listings/form/ListingFormShell.tsx | 77 +++ .../components/listings/form/SubletForm.tsx | 222 ++++++++ frontend/components/navbar/Navbar.tsx | 20 +- frontend/components/navbar/NavbarActions.tsx | 14 +- frontend/hooks/useImageUpload.ts | 195 +++++++ frontend/lib/actions.ts | 17 +- frontend/lib/types.ts | 15 +- frontend/lib/utils.ts | 5 + frontend/lib/validations.ts | 129 +++-- 18 files changed, 1176 insertions(+), 622 deletions(-) create mode 100644 frontend/components/common/ImageDropzone.tsx create mode 100644 frontend/components/common/ImagePreview.tsx delete mode 100644 frontend/components/listings/ListingForm.tsx create mode 100644 frontend/components/listings/form/BaseListingForm.tsx create mode 100644 frontend/components/listings/form/ItemForm.tsx create mode 100644 frontend/components/listings/form/ListingFormShell.tsx create mode 100644 frontend/components/listings/form/SubletForm.tsx create mode 100644 frontend/hooks/useImageUpload.ts 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 ( +
+
+ + + {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 && ( + + )} +
+ ); +}; 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 ( +
+ {`Upload + + {/* 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 ( -
-
-
- ( - - - - )} - /> - - ( - -
- - $ - - - {!isItem && ( - - /mo - - )} -
-
- )} - /> - - {/* Sublet-specific: Address */} - {!isItem && ( - ( - - - - )} - /> - )} - - {/* Sublet-specific: Beds and Baths */} - {!isItem && ( -
- ( - - )} - /> - - ( - - )} - /> -
- )} - - {/* Sublet-specific: Start and End Date */} - {!isItem && ( -
- ( - - - - )} - /> - - ( - - - - )} - /> -
- )} - - ( - -