From fd9c0e73b387fbba8fd105c3bf6396f912bd7b84 Mon Sep 17 00:00:00 2001 From: Lautaro Beck Date: Sat, 28 Feb 2026 01:55:16 -0500 Subject: [PATCH 01/14] Adding seller's view --- backend/market/serializers.py | 9 +- backend/market/urls.py | 5 + backend/market/views.py | 22 + frontend/app/items/[id]/page.tsx | 22 +- frontend/app/sublets/[id]/page.tsx | 25 +- .../listings/detail/ListingActions.tsx | 6 + .../listings/detail/ListingDetail.tsx | 462 +++++++++++++++++- frontend/lib/actions.ts | 48 +- frontend/lib/types.ts | 14 + 9 files changed, 582 insertions(+), 31 deletions(-) diff --git a/backend/market/serializers.py b/backend/market/serializers.py index 93ab683..706ce41 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -46,7 +46,14 @@ class OfferSerializer(ModelSerializer): class Meta: model = Offer - fields = ["id", "user", "listing", "offered_price", "message", "created_at"] + fields = [ + "id", + "user", + "listing", + "offered_price", + "message", + "created_at", + ] read_only_fields = ["id", "created_at", "user"] def create(self, validated_data): diff --git a/backend/market/urls.py b/backend/market/urls.py index b00a13c..deef9c6 100644 --- a/backend/market/urls.py +++ b/backend/market/urls.py @@ -11,8 +11,10 @@ OffersReceived, Tags, UserFavorites, + accept_offer, get_current_user, get_phone_status, + reject_offer, send_verification_code, verify_phone_code, ) @@ -49,6 +51,9 @@ "listings//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), + # Offer accept / reject + path("offers//accept/", accept_offer, name="offer-accept"), + path("offers//reject/", reject_offer, name="offer-reject"), # Image Creation path("listings//images/", CreateImages.as_view()), # Image Deletion diff --git a/backend/market/views.py b/backend/market/views.py index c85974c..74b395c 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -342,6 +342,28 @@ def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def accept_offer(request, offer_id): + offer = get_object_or_404(Offer, pk=offer_id) + if offer.listing.seller != request.user: + raise exceptions.PermissionDenied("Only the listing owner can accept offers.") + offer.status = Offer.Status.ACCEPTED + offer.save(update_fields=["status"]) + return Response(OfferSerializer(offer).data) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def reject_offer(request, offer_id): + offer = get_object_or_404(Offer, pk=offer_id) + if offer.listing.seller != request.user: + raise exceptions.PermissionDenied("Only the listing owner can reject offers.") + offer.status = Offer.Status.REJECTED + offer.save(update_fields=["status"]) + return Response(OfferSerializer(offer).data) + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) def send_verification_code(request): diff --git a/frontend/app/items/[id]/page.tsx b/frontend/app/items/[id]/page.tsx index bbf5b40..38e29cf 100644 --- a/frontend/app/items/[id]/page.tsx +++ b/frontend/app/items/[id]/page.tsx @@ -1,9 +1,25 @@ import { ListingDetail } from "@/components/listings/detail/ListingDetail"; -import { getListingOrNotFound } from "@/lib/actions"; +import { + getCurrentUser, + getListingOrNotFound, + getOffersMade, + getOffersReceived, +} from "@/lib/actions"; export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const item = await getListingOrNotFound(id); + const [item, currentUser] = await Promise.all([getListingOrNotFound(id), getCurrentUser()]); + const isOwner = currentUser?.id === item.seller.id; + const offersResponse = await (isOwner ? getOffersReceived() : getOffersMade()); + const offers = offersResponse?.results?.filter((offer) => offer.listing === item.id) ?? []; - return ; + return ( + + ); } diff --git a/frontend/app/sublets/[id]/page.tsx b/frontend/app/sublets/[id]/page.tsx index 2f71c36..7161be5 100644 --- a/frontend/app/sublets/[id]/page.tsx +++ b/frontend/app/sublets/[id]/page.tsx @@ -1,9 +1,28 @@ import { ListingDetail } from "@/components/listings/detail/ListingDetail"; -import { getListingOrNotFound } from "@/lib/actions"; +import { + getCurrentUser, + getListingOrNotFound, + getOffersMade, + getOffersReceived, +} from "@/lib/actions"; export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const sublet = await getListingOrNotFound(id); + const [sublet, currentUser] = await Promise.all([ + getListingOrNotFound(id), + getCurrentUser().catch(() => null), + ]); + const isOwner = currentUser?.id === sublet.seller.id; + const offersResponse = await (isOwner ? getOffersReceived() : getOffersMade()).catch(() => null); + const offers = offersResponse?.results?.filter((offer) => offer.listing === sublet.id) ?? []; - return ; + return ( + + ); } diff --git a/frontend/components/listings/detail/ListingActions.tsx b/frontend/components/listings/detail/ListingActions.tsx index 1fc3c9e..0d83918 100644 --- a/frontend/components/listings/detail/ListingActions.tsx +++ b/frontend/components/listings/detail/ListingActions.tsx @@ -14,6 +14,7 @@ interface Props { listingPrice: number; listingOwnerLabel: string; priceLabel?: string; + canEdit?: boolean; } type ModalState = "none" | "phone-input" | "verification" | "offer"; @@ -23,6 +24,7 @@ export const ListingActions = ({ listingPrice, priceLabel, listingOwnerLabel, + canEdit = false, }: Props) => { const [modalState, setModalState] = useState("none"); const [pendingPhoneNumber, setPendingPhoneNumber] = useState(""); @@ -35,6 +37,10 @@ export const ListingActions = ({ queryFn: getPhoneStatus, }); + if (canEdit) { + return null; + } + const handleMakeOfferClick = () => { if (!phoneStatus) return; diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index a4eb4e5..c4b5f7f 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -1,22 +1,219 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions"; -import { Heart, Share } from "lucide-react"; -import { Item, Sublet } from "@/lib/types"; -import { ListingActions } from "@/components/listings/detail/ListingActions"; +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { Check, Clock, Heart, Share, Star, X } from "lucide-react"; +import { Item, ItemCategory, ItemCondition, Offer, PaginatedResponse, Sublet } from "@/lib/types"; +import { CATEGORY_OPTIONS, CONDITION_OPTIONS } from "@/lib/constants"; import { ListingImageGallery } from "@/components/listings/detail/ListingImageGallery"; import { ListingInfo } from "@/components/listings/detail/ListingInfo"; import { UserCard } from "@/components/listings/detail/UserCard"; +import { ListingActions } from "@/components/listings/detail/ListingActions"; import { BackButton } from "@/components/listings/detail/BackButton"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { FormSelect } from "@/components/common/FormSelect"; +import { + acceptOffer, + addToUsersFavorites, + deleteListing, + deleteFromUsersFavorites, + rejectOffer, + updateListing, +} from "@/lib/actions"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +function formatOfferDate(dateString: string) { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatOfferTime(dateString: string) { + const date = new Date(dateString); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +const STATUS_BADGE: Record = { + accepted: { label: "Accepted", className: "bg-green-100 text-green-700" }, + rejected: { label: "Rejected", className: "bg-red-100 text-red-700" }, +}; + +const OfferCard = ({ + offer, + offersMode, + onStatusChange, +}: { + offer: Offer; + offersMode: "received" | "made"; + onStatusChange: (id: number, status: Offer["status"]) => void; +}) => { + const [loading, setLoading] = useState<"accept" | "reject" | null>(null); + + const handleAccept = async () => { + setLoading("accept"); + try { + await acceptOffer(offer.id); + onStatusChange(offer.id, "accepted"); + } catch (err) { + console.error(err); + } finally { + setLoading(null); + } + }; + + const handleReject = async () => { + setLoading("reject"); + try { + await rejectOffer(offer.id); + onStatusChange(offer.id, "rejected"); + } catch (err) { + console.error(err); + } finally { + setLoading(null); + } + }; + + const badge = STATUS_BADGE[offer.status]; + + return ( +
+
+ {offersMode === "received" && ( + {`${offer.user.first_name} + )} +
+
+ ${offer.offered_price.toLocaleString()} +
+ {badge && ( + + {badge.label} + + )} +
+ + + {formatOfferDate(offer.created_at)} at {formatOfferTime(offer.created_at)} + +
+
+
+ {offersMode === "received" && ( +
+ + {offer.user.first_name} {offer.user.last_name} + +
+ + 5.0 +
+
+ )} + {offer.message &&

{offer.message}

} + {offersMode === "received" && offer.status === "pending" && ( +
+ + +
+ )} +
+
+
+ ); +}; + +const OffersSection = ({ + offers: initialOffers, + offersMode, +}: { + offers: Offer[]; + offersMode: "received" | "made"; +}) => { + const [offers, setOffers] = useState(initialOffers); + + const handleStatusChange = (id: number, status: Offer["status"]) => { + setOffers((prev) => prev.map((o) => (o.id === id ? { ...o, status } : o))); + }; + + return ( +
+

+ {offersMode === "received" ? "Offers from others" : "My offers"} +

+ {offers.length === 0 ? ( +

No offers yet.

+ ) : ( +
+ {offers.map((offer) => ( + + ))} +
+ )} +
+ ); +}; interface Props { listing: Item | Sublet; initialIsFavorited: boolean; + offers: Offer[]; + offersMode: "received" | "made"; + canEdit: boolean; } -export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { - const listingType = listing.listing_type; +export const ListingDetail = ({ + listing, + initialIsFavorited, + offers, + offersMode, + canEdit, +}: Props) => { + const [listingState, setListingState] = useState(listing); + const listingType = listingState.listing_type; const priceLabel = listingType === "sublet" ? "/mo" : undefined; const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; const queryClient = useQueryClient(); @@ -29,32 +226,147 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { const isFavorited = favoritesQuery.data ?? false; + useEffect(() => { + console.log("listing", listingState); + }, [listingState]); + const toggleFavoriteMutation = useMutation({ - meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update + meta: { suppressErrorToast: true }, mutationFn: async (shouldFavorite: boolean) => { if (shouldFavorite) { - await addToUsersFavorites(listing.id); + await addToUsersFavorites(listingState.id); } else { - await deleteFromUsersFavorites(listing.id); + await deleteFromUsersFavorites(listingState.id); } }, onMutate: async (shouldFavorite: boolean) => { await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] }); - const previous = queryClient.getQueryData(["favorite", listing.id]); + const previousFavorite = queryClient.getQueryData(["favorite", listing.id]); queryClient.setQueryData(["favorite", listing.id], shouldFavorite); - return { previous }; + await queryClient.cancelQueries({ queryKey: ["favorites"] }); + const previousFavoritesList = queryClient.getQueryData>([ + "favorites", + ]); + + if (previousFavoritesList) { + const exists = previousFavoritesList.results?.some( + (favorite) => favorite.id === listingState.id + ); + let results = previousFavoritesList.results ?? []; + + if (shouldFavorite && !exists) { + results = [...results, listingState]; + } + if (!shouldFavorite && exists) { + results = results.filter((favorite) => favorite.id !== listingState.id); + } + + queryClient.setQueryData>(["favorites"], { + ...previousFavoritesList, + results, + }); + } + + return { previousFavorite, previousFavoritesList }; }, onError: (_error, _shouldFavorite, context) => { - if (context?.previous !== undefined) { - queryClient.setQueryData(["favorite", listing.id], context.previous); + if (context?.previousFavorite !== undefined) { + queryClient.setQueryData(["favorite", listing.id], context.previousFavorite); + } + if (context?.previousFavoritesList !== undefined) { + queryClient.setQueryData(["favorites"], context.previousFavoritesList); } }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["favorites"] }); + }, }); - const handleToggleFavorite = async () => { + const [isEditing, setIsEditing] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [draftTitle, setDraftTitle] = useState(listingState.title); + const [draftPrice, setDraftPrice] = useState(listingState.price.toString()); + const [draftDescription, setDraftDescription] = useState(listingState.description); + const [draftCategory, setDraftCategory] = useState( + listingState.listing_type === "item" ? listingState.additional_data.category : "" + ); + const resolveConditionValue = (condition: string) => + CONDITION_OPTIONS.find((option) => option.label === condition)?.value ?? + (condition as ItemCondition); + + const [draftCondition, setDraftCondition] = useState( + listingState.listing_type === "item" + ? resolveConditionValue(listingState.additional_data.condition) + : "" + ); + const [draftExpiresAt, setDraftExpiresAt] = useState( + listingState.expires_at ? listingState.expires_at.slice(0, 10) : "" + ); + + const handleToggleFavorite = () => { toggleFavoriteMutation.mutate(!isFavorited); }; + const resetDrafts = () => { + setDraftTitle(listingState.title); + setDraftPrice(listingState.price.toString()); + setDraftDescription(listingState.description); + if (listingState.listing_type === "item") { + setDraftCategory(listingState.additional_data.category); + setDraftCondition(resolveConditionValue(listingState.additional_data.condition)); + } + setDraftExpiresAt(listingState.expires_at ? listingState.expires_at.slice(0, 10) : ""); + }; + + const handleEditCancel = () => { + resetDrafts(); + setIsEditing(false); + }; + + const handleEditStart = () => { + resetDrafts(); + setIsEditing(true); + }; + + const handleEditSave = async () => { + const priceValue = Number(draftPrice); + if (!Number.isFinite(priceValue)) return; + + try { + const nextListing = await updateListing(listingState.id, { + title: draftTitle.trim(), + description: draftDescription.trim(), + price: priceValue, + listing_type: listingState.listing_type, + additional_data: + listingState.listing_type === "item" + ? { + ...(draftCondition ? { condition: draftCondition } : {}), + ...(draftCategory ? { category: draftCategory } : {}), + } + : {}, + }); + setListingState(nextListing); + setIsEditing(false); + } catch (err) { + console.log(err); + } + }; + + const handleDeleteConfirm = async () => { + if (isDeleting) return; + setIsDeleting(true); + try { + await deleteListing(listingState.id); + window.location.href = "/"; + } catch (err) { + console.log(err); + setIsDeleting(false); + setIsDeleteOpen(false); + } + }; + return (
@@ -73,22 +385,126 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
- +
- + + + {canEdit && ( + <> +
+ + +
+ + + + Delete Item + + Are you sure you want to delete this Item? + + + + + + + + + (!open ? handleEditCancel() : handleEditStart())} + > + + + Edit Listing + Update your listing details below. + +
+
+ + setDraftTitle(e.target.value)} /> +
+
+ + setDraftPrice(e.target.value)} + /> +
+
+ +