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) => {
);
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts
index ff22058..1c24c9f 100644
--- a/frontend/lib/actions.ts
+++ b/frontend/lib/actions.ts
@@ -10,6 +10,7 @@ import {
CreateSubletPayload,
Item,
Listing,
+ Offer,
PaginatedResponse,
Sublet,
User,
@@ -186,6 +187,14 @@ export async function createOffer({
return offer;
}
+export async function getOffersMade() {
+ return await serverFetch>("/market/offers/made/");
+}
+
+export async function getOffersReceived() {
+ return await serverFetch>("/market/offers/received/");
+}
+
export async function getPhoneStatus() {
return await serverFetch<{
phone_number: string | null;
@@ -229,7 +238,6 @@ export async function deleteFromUsersFavorites(listingId: number) {
export async function getUsersFavorites() {
return await serverFetch>("/market/favorites/");
}
-
// ------------------------------------------------------------
// creating new listings
// ------------------------------------------------------------
@@ -242,3 +250,89 @@ export async function createListing(payload: CreateListingPayload): Promise;
+};
+
+export async function updateListing(
+ listingId: number,
+ payload: UpdateListingPayload
+): Promise {
+ return await serverFetch(`/market/listings/${listingId}/`, {
+ method: "PATCH",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function uploadListingImages(listingId: number, images: File[]) {
+ const tokens = await getTokensFromCookies();
+ const accessToken = tokens?.accessToken;
+
+ if (!accessToken) {
+ throw new APIError(ErrorMessages.NO_ACCESS_TOKEN, 401);
+ }
+
+ const formData = new FormData();
+ images.forEach((image) => formData.append("images", image));
+
+ const response = await fetch(`${API_BASE_URL}/market/listings/${listingId}/images/`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null);
+ let errorMessage = `${ErrorMessages.API_REQUEST_FAILED}: ${response.status}`;
+
+ if (errorData) {
+ const firstKey = Object.keys(errorData)[0];
+ if (firstKey) {
+ const firstError = errorData[firstKey];
+ errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
+ }
+ }
+
+ throw new APIError(errorMessage, response.status);
+ }
+
+ return response.json();
+}
+
+export async function deleteListing(listingId: number): Promise {
+ const tokens = await getTokensFromCookies();
+ const accessToken = tokens?.accessToken;
+
+ if (!accessToken) {
+ throw new APIError(ErrorMessages.NO_ACCESS_TOKEN, 401);
+ }
+
+ const response = await fetch(`${API_BASE_URL}/market/listings/${listingId}/`, {
+ method: "DELETE",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null);
+ let errorMessage = `${ErrorMessages.API_REQUEST_FAILED}: ${response.status}`;
+
+ if (errorData) {
+ const firstKey = Object.keys(errorData)[0];
+ if (firstKey) {
+ const firstError = errorData[firstKey];
+ errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
+ }
+ }
+ throw new APIError(errorMessage, response.status);
+ }
+}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 2c16154..432d4c1 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -87,6 +87,15 @@ export type Sublet = BaseListing & {
export type Listing = Item | Sublet;
export type ListingTypes = "items" | "sublets";
+export type Offer = {
+ id: number;
+ user: User;
+ listing: number;
+ offered_price: number;
+ message: string | null;
+ created_at: string;
+};
+
// ------------------------------------------------------------
// api responses
// ------------------------------------------------------------