Skip to content

Lau/listing view#58

Open
LautaroJBeck wants to merge 15 commits into
masterfrom
lao/listing-view
Open

Lau/listing view#58
LautaroJBeck wants to merge 15 commits into
masterfrom
lao/listing-view

Conversation

@LautaroJBeck
Copy link
Copy Markdown
Collaborator

No description provided.

@LautaroJBeck LautaroJBeck changed the title Lao/listing view Lau/listing view Mar 1, 2026
Base automatically changed from james/better-error-handling to master March 19, 2026 20:15
? resolveConditionValue(listingState.additional_data.condition)
: ""
);
const [draftExpiresAt, setDraftExpiresAt] = useState(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused variables

Comment on lines +478 to +481
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Title</label>
<Input value={draftTitle} onChange={(e) => setDraftTitle(e.target.value)} />
</div>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Input component's borders are partly hidden on the left, fix styling

Image

@pennlabs pennlabs deleted a comment from i30101 Mar 27, 2026
Copy link
Copy Markdown
Collaborator

@jamesdoh0109 jamesdoh0109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay on the review. I left a bunch of comments and I encourage you to go through each of them carefully and address them. Generally, get more familiarized with our codebase and what frameworks/libraries we use, and also the coding convention and pattern we use. Try to leverage these when you write code. You should have good context about the codebase you are working in. Good code isn't always necessarily the cleanest, most elegant code, but rather one that follows the convention set in the codebase.

Very likely, there will prob be more comments after you address the current ones. But i encourage you to take this PR as an opportunity to set yourself a better foundation - both in understanding the patterns we've established and building the habit of reading existing code before writing new code

Comment thread frontend/app/items/[id]/page.tsx Outdated
Comment on lines +8 to +9
const offersResponse = isOwner ? await getOffersReceived() : null;
const offers = offersResponse?.results?.filter((offer) => offer.listing === item.id) ?? [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed offline, but see if you can fetch offers just for this item, not all offers and filter by id

Comment thread frontend/app/sublets/[id]/page.tsx Outdated
Comment on lines +13 to +14
const offersResponse = await (isOwner ? getOffersReceived() : getOffersMade());
const offers = offersResponse?.results?.filter((offer) => offer.listing === sublet.id) ?? [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here

rejected: { label: "Rejected", className: "bg-red-100 text-red-700" },
};

const OfferCard = ({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull this out into a separte component in its own file

onStatusChange,
}: {
offer: Offer;
offersMode: "received" | "made";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be no reason to have this prop. You only check for offersMode == "received" in this, so this component should only be rendered when offersMode is "received". Honestly, isOwner should be able to determien this

Comment thread backend/market/urls.py Outdated
Comment on lines +54 to +56
# Offer accept / reject
path("offers/<int:offer_id>/accept/", accept_offer, name="offer-accept"),
path("offers/<int:offer_id>/reject/", reject_offer, name="offer-reject"),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed offline, but this is not consistent w REST API convention. Instead of having verbs like "accept" and "reject", you can just directly edit the offer object (i.e. PATCH /offers), and then just modify the offer's status

);
};

const OffersSection = ({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can also be its own component file. Put all the offer related components under listings/offer

listingOwnerLabel={listingOwnerLabel}
canEdit={canEdit}
/>
{offersMode === "received" && <OffersSection offers={offers} offersMode={offersMode} />}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if i made an offer for some item? Do i still want to see "Make Offer" button? I think instead, its better to show the offer i made for the item and then show its status as a read only badge

offersMode,
canEdit,
}: Props) => {
const [listingState, setListingState] = useState(listing);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, the initial listing gets copied into useState(listing) and then whenever you update/edit a listing, this local state gets updated and you just show this value in the UI. This is a really bad approach because the local state can drift from the server and it duplicates what tanstack query already does. Also, if the update req to backend ever fails, setListingState never runs but the user gets no feedback and we just silently swallow them with console.log(error). And lastly, you should see how much complexity you added to your code by introducting this state.

Generally, you should get familiar with tanstack query (useQuery and useMutation) and how we use it throughout our codebase. In this situation, replace the useState(listing) wiuth useQuery call seeded w the initial data from the server componentn prop. ANd then you can convert handleEditSave and handleDeleteConfirm to useMutation calls that invalidate the query on success rather than manually resetting local state. You already did a similar thing with toggleFavoriteMutation - follow this convention


const toggleFavoriteMutation = useMutation({
meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update
meta: { suppressErrorToast: true },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are you getting rid of this comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you revert back the comment plz

canEdit={canEdit}
/>
{offersMode === "received" && <OffersSection offers={offers} offersMode={offersMode} />}
{canEdit && (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I would encourage you to put these into ListingActions. There, you can parse isOwner and then either display these edit/delete buttons or make offer button. This is a much better way to componentize
  2. The edit form right now has no validation. In our code base, we use react hook form and zod schemas but looks like you are again using raw useState. Please look at how we handle these in other forms. You might also be able to leverage existing form components instead of defining a brand new form

@@ -0,0 +1,109 @@
from decimal import Decimal
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is a script to artificially add offers to one listing

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok maybe make a comment in the file. Also not sure seed_offer is the best name

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not commit this - all pycache should be in .gitignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not commit this - all pycache should be in .gitignore

Comment thread backend/market/views.py Outdated
self.check_object_permissions(self.request, obj)
self.perform_destroy(obj)
return Response(status=status.HTTP_204_NO_CONTENT)
# Keep a JSON body so the frontend `serverFetch` can safely parse.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is not necessary imo

Comment thread backend/market/urls.py Outdated
name="offers-mine",
),
# Update offer status (PATCH)
path("offers/<int:offer_id>/", change_offer_status, name="offer-status"),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make it clear that this is for modifying status by adding /status at the end (like how you did for details)

Comment thread backend/market/views.py Outdated
offer = Offer.objects.filter(listing_id=listing_id_int, user=request.user).first()
if offer is None:
raise exceptions.NotFound("No offer for this listing")
return Response(OfferSerializer(offer).data)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new FBVs (change_offer_status, get_my_offer_for_listing, change_offer_details) break from the codebase's convention of using class-based views for resource operations. The existing pattern is:

  • Class-based views (ModelViewSet, ListAPIView, UpdateAPIView, etc.) for resource CRUD (Listings, Offers, Favorites, Tags)
  • Function-based views (@api_view) for one-off actions (send_verification_code, get_current_user, etc.)

Using FBVs here also forces manual object-level permission checks (since DRF's @permission_classes decorator only runs has_permission, not has_object_permission), which is why the permission logic is inconsistent across the three views — change_offer_status uses a manual loop, change_offer_details inlines the check, and get_my_offer_for_listing doesn't need one.

Consider refactoring to class-based views:

class OfferStatusUpdate(UpdateAPIView):
    """Allow the listing seller to update an offer's status."""
    queryset = Offer.objects.all()
    serializer_class = OfferStatusSerializer
    permission_classes = [ListingOwnerOffersPermission | IsSuperUser]
    lookup_url_kwarg = "offer_id"
    http_method_names = ["patch"]

    def update(self, request, *args, **kwargs):
        response = super().update(request, *args, partial=True, **kwargs)
        return Response(OfferSerializer(self.get_object()).data)


class OfferDetailsUpdate(UpdateAPIView):
    """Allow the offer owner to edit offered_price and message."""
    queryset = Offer.objects.all()
    serializer_class = OfferDetailsSerializer
    permission_classes = [OfferOwnerPermission | IsSuperUser]
    lookup_url_kwarg = "offer_id"
    http_method_names = ["patch"]

    def update(self, request, *args, **kwargs):
        response = super().update(request, *args, partial=True, **kwargs)
        return Response(OfferSerializer(self.get_object()).data)


class MyOfferForListing(RetrieveAPIView):
    """Return the authenticated user's offer for a given listing."""
    serializer_class = OfferSerializer
    permission_classes = [IsAuthenticated]

    def get_object(self):
        offer = Offer.objects.filter(
            listing_id=self.kwargs["listing_id"],
            user=self.request.user,
        ).first()
        if offer is None:
            raise exceptions.NotFound("No offer for this listing")
        return offer

Note: OfferDetailsUpdate needs an OfferOwnerPermission that checks obj.user == request.user. The original OfferOwnerPermission was renamed to ListingOwnerOffersPermission and repurposed — but ListingOwnerOffersPermission returns obj.listing.seller == request.user for PATCH, which is wrong for offer-owner edits. Consider keeping the original OfferOwnerPermission alongside the new ListingOwnerOffersPermission.

Copy link
Copy Markdown
Collaborator

@jamesdoh0109 jamesdoh0109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some more backend comments, we can talk during gbm, but this should be my last set of backend comments

@@ -0,0 +1,109 @@
from decimal import Decimal
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok maybe make a comment in the file. Also not sure seed_offer is the best name

Comment thread backend/market/permissions.py Outdated
Comment on lines +77 to +79
if request.method in ("PATCH", "PUT"):
return obj.user_id == request.user.id
return False
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if request.method in ("PATCH", "PUT"):
return obj.user_id == request.user.id
return False
return request.method in {"PATCH", "PUT"} and obj.user_id == request.user.id

Comment thread backend/market/permissions.py Outdated
Comment on lines +58 to +61
if request.method in permissions.SAFE_METHODS:
return obj.listing.seller == request.user

if request.method in ("PATCH", "PUT"):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can prob combine these 2 conditions since they are doing the same thing

Comment thread backend/market/views.py Outdated
"""

permission_classes = [OfferOwnerPermission | IsSuperUser]
permission_classes = [ListingOwnerOffersPermission | IsSuperUser]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListingOwnerOffersPermission mixes seller and buyer logic, and the Offers viewset relies on DRF internals for correct access control.

The Offers viewset serves three actions (list, create, destroy) for two different actors (seller and buyer), but applies a single permission class (ListingOwnerOffersPermission) to all of them. This causes a few issues:

create works by accident. ListingOwnerOffersPermission.has_object_permission only grants access to the listing seller (for GET/PATCH/PUT) or the offer creator (fallthrough). But create works for any authenticated user because DRF never calls has_object_permission on create actions — there's no object yet. The permission class doesn't actually permit this; it just never gets asked.

destroy has redundant access control. The destroy() method hard-filters the queryset to user=request.user, so only the buyer's own offer is ever fetched. The fallthrough in has_object_permission (return obj.user == request.user) then passes for the same user. The permission check is redundant with the view logic, and the intent is unclear.

Seller and buyer checks are mixed in one class. ListingOwnerOffersPermission checks obj.listing.seller for GET/PATCH/PUT but obj.user for DELETE. The class name and docstring suggest it's about the listing owner, but it quietly handles buyer logic too.

Fix: Replace the single permission_classes on the Offers viewset with get_permissions() that returns the appropriate permission per action — IsAuthenticated for create, OfferOwnerPermission | IsSuperUser for destroy, and ListingOwnerOffersPermission | IsSuperUser for list. Then simplify both permission classes: ListingOwnerOffersPermission should only check obj.listing.seller == request.user, and OfferOwnerPermission should only check obj.user_id == request.user.id, with no method-specific branching (the view controls which methods are allowed).

Copy link
Copy Markdown
Collaborator

@jamesdoh0109 jamesdoh0109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll most likely have more comments after this round of feedback, but for your frontend, try to refactor so that ListingActions.tsx has only logic that actually correspond to "actions" - so button for "Make Offer" (if it is not your item" or buttons for editing and deleting (if it is your item). Then your OffersSection component, you can conditionally render either an offer that you have made for an item that is not yours or offers that you have received for items that are yours. For Offer Card, try to see the difference between my offer card vs offer card for offers you received.

Comment thread backend/market/management/commands/seed_offers.py Outdated
Comment on lines +27 to +35
const deleteMutation = useMutation({
mutationFn: () => deleteListing(listing.id),
onSuccess: () => {
router.push(listing.listing_type === "sublet" ? "/sublets" : "/items");
},
onError: () => {
onOpenChange(false);
},
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look into invalidating the tanstack cache for the list/optimistically updating it so we see that the listing is deleted right away in the list

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets make this into a page, not modal, including images. That way we can also re use what we have

const [modalState, setModalState] = useState<ModalState>("none");
const [pendingPhoneNumber, setPendingPhoneNumber] = useState<string>("");
const [isChangingPhone, setIsChangingPhone] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState(false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prob wont need this anymore after we remove edit modal

Comment thread frontend/lib/actions.ts Outdated
return await serverFetch<PaginatedResponse<Offer>>("/market/offers/received/");
}

export async function getOffersForListing(listingId: number) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe getOffersReceivedForListing?

Comment thread frontend/app/items/[id]/page.tsx Outdated
Comment on lines +9 to +10
const offers = offersResponse?.results ?? [];
const initialMyOffer = !isOwner ? await getMyOfferForListing(item.id) : null;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename these to offersReceived and myOfferGiven?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or something similar

Comment thread frontend/app/sublets/[id]/page.tsx Outdated
Comment on lines +8 to +10
const offersResponse = isOwner ? await getOffersForListing(sublet.id) : null;
const offers = offersResponse?.results ?? [];
const initialMyOffer = !isOwner ? await getMyOfferForListing(sublet.id) : null;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here about naming

Copy link
Copy Markdown
Collaborator

@jamesdoh0109 jamesdoh0109 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left comments

Comment thread backend/market/permissions.py Outdated
Comment on lines +48 to +51
Permission for the owner of a listing:
- GET: listing seller can view offers on their listing
- PATCH/PUT: listing seller can update offer status
- DELETE: offer creator can delete/withdraw their own offer
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Permission for the owner of a listing:
- GET: listing seller can view offers on their listing
- PATCH/PUT: listing seller can update offer status
- DELETE: offer creator can delete/withdraw their own offer
Permission for the listing seller:
- GET: can view offers on their listing
- PATCH/PUT: can update offer status

Comment thread backend/market/permissions.py Outdated
Comment on lines 58 to 64
if request.method in permissions.SAFE_METHODS:
return obj.listing.seller == request.user

if request.method in ("PATCH", "PUT"):
return obj.listing.seller == request.user

return obj.user == request.user
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if request.method in permissions.SAFE_METHODS:
return obj.listing.seller == request.user
if request.method in ("PATCH", "PUT"):
return obj.listing.seller == request.user
return obj.user == request.user
return obj.listing.seller == request.user

Comment thread backend/market/permissions.py Outdated
class OfferOwnerPermission(permissions.BasePermission):
"""
Permission for the buyer who created an offer:
- PATCH/PUT: offer owner can edit their own offer (e.g. price/message)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- PATCH/PUT: offer owner can edit their own offer (e.g. price/message)
- PATCH/PUT: can edit their own offer (price/message)
- DELETE: can withdraw their own offer

Comment thread backend/market/permissions.py Outdated
Comment on lines +77 to +79
if request.method in ("PATCH", "PUT"):
return obj.user_id == request.user.id
return False
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if request.method in ("PATCH", "PUT"):
return obj.user_id == request.user.id
return False
return obj.user_id == request.user.id


const toggleFavoriteMutation = useMutation({
meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update
meta: { suppressErrorToast: true },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you revert back the comment plz

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really dont see the reason for having this whole separate component for editing. Cant we just reuse the ItemForm and SubletForm? Obviously, we'll have to refactor a bit to conditionally pre-fill the form with exisitng listing data or render either "Edit Item" or "Create Item" button

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having separate MyOfferCard.tsx and OfferCard.tsx, can we combine them into one offer card component since they display mostly similar things?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OffersReceived isnt an accurate name if you are also conditionally rendering offer that I gave

Comment thread frontend/lib/actions.ts Outdated
Comment on lines +243 to +249
try {
return await serverFetch<Offer>(`/market/listings/${listingId}/offers/mine/`);
} catch (error) {
if (error instanceof APIError && error.status === 404) return null;
throw error;
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to catch here, since the new serverFetch will handle

Comment thread package-lock.json
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this for? You didnt make any changes to package.json?

LautaroJBeck and others added 3 commits May 13, 2026 06:51
Resolve conflicts: hydrate listing query + keep offers/edit UX; SubletForm with geocode + edit mode; SubletMap on detail; 404-safe getMyOfferForListing

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants