diff --git a/frontend/app/items/[id]/page.tsx b/frontend/app/items/[id]/page.tsx index 1f5e426..2f174fb 100644 --- a/frontend/app/items/[id]/page.tsx +++ b/frontend/app/items/[id]/page.tsx @@ -1,9 +1,27 @@ import { ListingDetail } from "@/components/listings/detail/ListingDetail"; -import { getListing } from "@/lib/actions"; +import { + getCurrentUser, + getListing, + getOffersMade, + getOffersReceived, + getUsersFavorites, +} from "@/lib/actions"; export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const item = await getListing(id); + const currentUser = await getCurrentUser().catch(() => null); + const isOwner = currentUser?.id === item.seller.id; + const offersResponse = await (isOwner ? getOffersReceived() : getOffersMade()).catch(() => null); + 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 b508ef4..fdd28b8 100644 --- a/frontend/app/sublets/[id]/page.tsx +++ b/frontend/app/sublets/[id]/page.tsx @@ -1,9 +1,27 @@ import { ListingDetail } from "@/components/listings/detail/ListingDetail"; -import { getListing } from "@/lib/actions"; +import { + getCurrentUser, + getListing, + getOffersMade, + getOffersReceived, + getUsersFavorites, +} from "@/lib/actions"; export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const sublet = await getListing(id); + const currentUser = await 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/ExternalItemView.tsx b/frontend/components/listings/detail/ExternalItemView.tsx new file mode 100644 index 0000000..f06fd96 --- /dev/null +++ b/frontend/components/listings/detail/ExternalItemView.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Item, Offer, Sublet } from "@/lib/types"; +import { ListingActions } from "@/components/listings/detail/ListingActions"; +import { ListingInfo } from "@/components/listings/detail/ListingInfo"; +import { UserCard } from "@/components/listings/detail/UserCard"; + +interface OfferSectionProps { + offers: Offer[]; + offersMode: "received" | "made"; +} + +const OffersSection = ({ offers, offersMode }: OfferSectionProps) => ( +
+

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

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

No offers yet.

+ ) : ( +
+ {offers.map((offer) => ( +
+
+ ${offer.offered_price.toLocaleString()} + + {new Date(offer.created_at).toLocaleDateString()} + +
+ {offersMode === "received" && ( +

+ From {offer.user.first_name} {offer.user.last_name} +

+ )} + {offer.message &&

{offer.message}

} +
+ ))} +
+ )} +
+); + +interface ExternalItemViewProps { + listing: Item | Sublet; + priceLabel?: string; + listingOwnerLabel: string; + offers: Offer[]; + offersMode: "received" | "made"; +} + +export const ExternalItemView = ({ + listing, + priceLabel, + listingOwnerLabel, + offers, + offersMode, +}: ExternalItemViewProps) => ( +
+ + + + +
+); 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 5fb63f9..cb9e51a 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -1,22 +1,39 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions"; +import { useState } from "react"; import { Heart, Share } from "lucide-react"; -import { Item, Sublet } from "@/lib/types"; -import { ListingActions } from "@/components/listings/detail/ListingActions"; +import { Item, ItemCategory, ItemCondition, Offer, PaginatedResponse, Sublet } from "@/lib/types"; +import { 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 { BackButton } from "@/components/listings/detail/BackButton"; +import { + addToUsersFavorites, + deleteFromUsersFavorites, + getListing, + updateListing, + uploadListingImages, +} from "@/lib/actions"; +import { ExternalItemView } from "@/components/listings/detail/ExternalItemView"; +import { PersonalItemView } from "@/components/listings/detail/PersonalItemView"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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(); @@ -32,28 +49,138 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { const toggleFavoriteMutation = useMutation({ 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 [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 [draftImages, setDraftImages] = useState([]); + + const handleToggleFavorite = () => { toggleFavoriteMutation.mutate(!isFavorited); }; + const handleEditCancel = () => { + 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) : ""); + setDraftImages([]); + setIsEditing(false); + }; + + const handleEditStart = () => { + 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) : ""); + setDraftImages([]); + setIsEditing(true); + }; + + const handleEditSave = async () => { + const priceValue = Number(draftPrice); + if (!Number.isFinite(priceValue)) { + return; + } + + try { + const updated = 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 } : {}), + } + : {}, + }); + let nextListing = updated; + if (draftImages.length > 0) { + await uploadListingImages(listingState.id, draftImages); + nextListing = await getListing(String(listingState.id)); + setDraftImages([]); + } + setListingState(nextListing); + setIsEditing(false); + } catch (err) { + console.log(err); + } + }; + return (
@@ -72,23 +199,40 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
- -
- + {canEdit ? ( + - - -
+ )}
); diff --git a/frontend/components/listings/detail/PersonalItemView.tsx b/frontend/components/listings/detail/PersonalItemView.tsx new file mode 100644 index 0000000..9414049 --- /dev/null +++ b/frontend/components/listings/detail/PersonalItemView.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState } from "react"; +import { Item, ItemCategory, ItemCondition, Offer, Sublet } from "@/lib/types"; +import { ListingActions } from "@/components/listings/detail/ListingActions"; +import { ListingInfo } from "@/components/listings/detail/ListingInfo"; +import { UserCard } from "@/components/listings/detail/UserCard"; +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 { CATEGORY_OPTIONS, CONDITION_OPTIONS } from "@/lib/constants"; +import { deleteListing } from "@/lib/actions"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface OfferSectionProps { + offers: Offer[]; + offersMode: "received" | "made"; +} + +const OffersSection = ({ offers, offersMode }: OfferSectionProps) => ( +
+

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

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

No offers yet.

+ ) : ( +
+ {offers.map((offer) => ( +
+
+ ${offer.offered_price.toLocaleString()} + + {new Date(offer.created_at).toLocaleDateString()} + +
+ {offersMode === "received" && ( +

+ From {offer.user.first_name} {offer.user.last_name} +

+ )} + {offer.message &&

{offer.message}

} +
+ ))} +
+ )} +
+); + +interface PersonalItemViewProps { + listing: Item | Sublet; + priceLabel?: string; + listingOwnerLabel: string; + isEditing: boolean; + draftTitle: string; + draftPrice: string; + draftDescription: string; + draftCategory: ItemCategory | ""; + draftCondition: ItemCondition | ""; + draftImages: File[]; + onDraftTitleChange: (value: string) => void; + onDraftPriceChange: (value: string) => void; + onDraftDescriptionChange: (value: string) => void; + onDraftCategoryChange: (value: ItemCategory | "") => void; + onDraftConditionChange: (value: ItemCondition | "") => void; + onDraftImagesChange: (files: File[]) => void; + onEditCancel: () => void; + onEditSave: () => void; + onEditStart: () => void; + offers: Offer[]; + offersMode: "received" | "made"; +} + +export const PersonalItemView = ({ + listing, + priceLabel, + listingOwnerLabel, + isEditing, + draftTitle, + draftPrice, + draftDescription, + draftCategory, + draftCondition, + draftImages, + onDraftTitleChange, + onDraftPriceChange, + onDraftDescriptionChange, + onDraftCategoryChange, + onDraftConditionChange, + onDraftImagesChange, + onEditCancel, + onEditSave, + onEditStart, + offers, + offersMode, +}: PersonalItemViewProps) => { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDeleteConfirm = async () => { + if (isDeleting) return; + setIsDeleting(true); + try { + await deleteListing(listing.id); + window.location.href = "/"; + } catch (err) { + console.log(err); + setIsDeleting(false); + setIsDeleteOpen(false); + } + }; + + return ( +
+ +
+ +
+ + +
+ + +
+ + + + Delete Item + Are you sure you want to delete this Item? + + + + + + + + (!open ? onEditCancel() : onEditStart())}> + + + Edit Listing + Update your listing details below. + +
+
+ + onDraftTitleChange(e.target.value)} /> +
+
+ + onDraftPriceChange(e.target.value)} + /> +
+
+ +