diff --git a/.gitignore b/.gitignore index 2a738cb..dfa6613 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ .env*.local # Python +**/__pycache__/ __pycache__/ *.py[cod] +*.pyc *.pyo *.pyd .Python diff --git a/backend/market/management/commands/create_listing_offers.py b/backend/market/management/commands/create_listing_offers.py new file mode 100644 index 0000000..4d60e09 --- /dev/null +++ b/backend/market/management/commands/create_listing_offers.py @@ -0,0 +1,121 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from market.models import Listing, Offer + + +# Create two pending test offers on each listing sold by USER_ID. +# manage.py create_listing_offers + +User = get_user_model() + + +def _seed_two_buyers(stdout, style): + alice, _ = User.objects.get_or_create( + username="alice", + defaults={ + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Johnson", + "phone_number": "+12155551234", + "phone_verified": True, + }, + ) + alice.set_password("testpassword123") + alice.save() + + bob, _ = User.objects.get_or_create( + username="bob", + defaults={ + "email": "bob@example.com", + "first_name": "Bob", + "last_name": "Williams", + "phone_number": "+12155555678", + "phone_verified": True, + }, + ) + bob.set_password("testpassword123") + bob.save() + + stdout.write(style.SUCCESS("Buyers ready: Alice Johnson, Bob Williams")) + return alice, bob + + +def _add_offers_to_listing(listing, alice, bob): + """Returns (created_count, skipped_count) for this listing.""" + _, created_alice = Offer.objects.get_or_create( + user=alice, + listing=listing, + defaults={ + "offered_price": Decimal("40.00"), + "message": "Would you take $40? I can pick up today.", + }, + ) + + _, created_bob = Offer.objects.get_or_create( + user=bob, + listing=listing, + defaults={ + "offered_price": Decimal("45.00"), + "message": "Interested! Is the price negotiable?", + }, + ) + + new_count = int(created_alice) + int(created_bob) + return new_count, 2 - new_count + + +class Command(BaseCommand): + help = "Create two pending test offers on each listing for USER_ID." + + def add_arguments(self, parser): + parser.add_argument( + "user_id", + type=int, + help="Seller's User.id; offers are added to all of their listings.", + ) + + def handle(self, *args, **options): + user_id = options["user_id"] + + try: + seller = User.objects.get(pk=user_id) + except User.DoesNotExist: + self.stdout.write(self.style.ERROR(f"No user with id={user_id}")) + return + + alice, bob = _seed_two_buyers(self.stdout, self.style) + + listings = list(Listing.objects.filter(seller=seller).order_by("id")) + if not listings: + self.stdout.write( + self.style.WARNING( + f"User {seller.username} (id={user_id}) has no listings." + ) + ) + return + self.stdout.write( + self.style.SUCCESS( + f"Seeding offers on {len(listings)} listing(s) for " + f"{seller.username} (id={user_id})" + ) + ) + + total_created = 0 + total_skipped = 0 + for listing in listings: + c, s = _add_offers_to_listing(listing, alice, bob) + total_created += c + total_skipped += s + self.stdout.write( + f" listing id={listing.id} {listing.title!r}: +{c} new, {s} skipped" + ) + + self.stdout.write( + self.style.SUCCESS( + f"\nDone! Created {total_created} offers, " + f"skipped {total_skipped} (already existed)." + ) + ) diff --git a/backend/market/migrations/0005_offer_status.py b/backend/market/migrations/0005_offer_status.py new file mode 100644 index 0000000..1c467aa --- /dev/null +++ b/backend/market/migrations/0005_offer_status.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.2 on 2026-03-27 21:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0004_rename_address_sublet_street_address_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="offer", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ], + default="pending", + max_length=10, + ), + ), + ] diff --git a/backend/market/models.py b/backend/market/models.py index 7812865..341176e 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -22,6 +22,11 @@ class User(AbstractUser): class Offer(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + ACCEPTED = "accepted", "Accepted" + REJECTED = "rejected", "Rejected" + class Meta: constraints = [ models.UniqueConstraint( @@ -42,11 +47,15 @@ class Meta: max_digits=10, decimal_places=2, validators=[MinValueValidator(0)] ) message = models.TextField(max_length=500, blank=True) + status = models.CharField( + max_length=10, choices=Status.choices, default=Status.PENDING + ) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"Offer for {self.listing} made by {self.user}" + class Category(models.Model): name = models.CharField(max_length=100, unique=True) @@ -170,7 +179,8 @@ def _calculate_approximate_location(self, latitude, longitude): def approximate_location(self): if self.latitude is not None and self.longitude is not None: approximate_location = self._calculate_approximate_location( - self.latitude, self.longitude) + self.latitude, self.longitude + ) return approximate_location return None, None diff --git a/backend/market/permissions.py b/backend/market/permissions.py index 4dc214a..c7828cb 100644 --- a/backend/market/permissions.py +++ b/backend/market/permissions.py @@ -43,16 +43,27 @@ def has_object_permission(self, request, view, obj): ) -class OfferOwnerPermission(permissions.BasePermission): +class ListingOwnerOffersPermission(permissions.BasePermission): """ - Custom permission to allow owner of an offer to delete it. + The listing seller may act on an Offer for their listing. + Use only on views/actions where that is intended (e.g. list offers, PATCH status). """ def has_permission(self, request, view): return request.user.is_authenticated def has_object_permission(self, request, view, obj): - if request.method in permissions.SAFE_METHODS: # GET - return obj.listing.seller == request.user + return obj.listing.seller == request.user + + +class OfferOwnerPermission(permissions.BasePermission): + """ + The user who created the offer may act on that Offer. + Use only on buyer-facing views (e.g. withdraw offer, PATCH details). + """ - return obj.user == request.user + def has_permission(self, request, view): + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + return obj.user_id == request.user.id diff --git a/backend/market/serializers.py b/backend/market/serializers.py index c129131..ab573db 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -1,4 +1,3 @@ - from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as ModelValidationError from profanity_check import predict @@ -47,14 +46,49 @@ class OfferSerializer(ModelSerializer): class Meta: model = Offer - fields = ["id", "user", "listing", "offered_price", "message", "created_at"] - read_only_fields = ["id", "created_at", "user"] + fields = [ + "id", + "user", + "listing", + "offered_price", + "message", + "status", + "created_at", + ] + read_only_fields = ["id", "created_at", "user", "status"] def create(self, validated_data): validated_data["user"] = self.context["request"].user return super().create(validated_data) +class OfferStatusSerializer(ModelSerializer): + class Meta: + model = Offer + fields = ["id", "status"] + read_only_fields = ["id"] + + def validate_status(self, value): + valid_statuses = [choice[0] for choice in Offer.Status.choices] + if value not in valid_statuses: + raise ValidationError( + f"Invalid status. Must be one of: {', '.join(valid_statuses)}" + ) + return value + + +class OfferDetailsSerializer(ModelSerializer): + """ + Allows the offer owner to edit the offer's offered_price and message. + Status is intentionally read-only (managed by the listing owner). + """ + + class Meta: + model = Offer + fields = ["id", "offered_price", "message", "status"] + read_only_fields = ["id", "status"] + + # Create/Update Image Serializer class ListingImageSerializer(ModelSerializer): image = ImageField(write_only=True, required=False, allow_null=True) @@ -126,6 +160,7 @@ def get_longitude(self, obj): return float(approx_lon) return None + # Unified serializer for all listing types (Items and Sublets); used for CRUD operations class ListingSerializer(ListingTypeMixin, ModelSerializer): LISTING_TYPE_CONFIG = { @@ -291,7 +326,6 @@ def _create_sublet(self, validated_data, additional_data): latitude = additional_data.get("latitude") longitude = additional_data.get("longitude") - if latitude is not None: latitude = float(latitude) if longitude is not None: diff --git a/backend/market/urls.py b/backend/market/urls.py index b00a13c..04028a1 100644 --- a/backend/market/urls.py +++ b/backend/market/urls.py @@ -6,9 +6,12 @@ DeleteImage, Favorites, Listings, + MyOfferForListing, + OfferDetailsUpdate, Offers, OffersMade, OffersReceived, + OfferStatusUpdate, Tags, UserFavorites, get_current_user, @@ -46,9 +49,28 @@ # post: create an offer for an listing # delete: delete an offer for an listing path( - "listings//offers/", + "listings//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), + # Current user's offer for an individual listing + # (Returns 404 when the user has no offer for that listing.) + path( + "listings//offers/mine/", + MyOfferForListing.as_view(), + name="offers-mine", + ), + # Update offer status only (PATCH; listing seller or superuser) + path( + "offers//status/", + OfferStatusUpdate.as_view(), + name="offer-status", + ), + # Update offer offered_price + message (PATCH; offer owner or superuser) + path( + "offers//details/", + OfferDetailsUpdate.as_view(), + name="offer-details", + ), # Image Creation path("listings//images/", CreateImages.as_view()), # Image Deletion diff --git a/backend/market/views.py b/backend/market/views.py index d61d749..5f6dbf9 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -9,6 +9,8 @@ CreateAPIView, DestroyAPIView, ListAPIView, + RetrieveAPIView, + UpdateAPIView, get_object_or_404, ) from rest_framework.parsers import FormParser, MultiPartParser @@ -21,6 +23,7 @@ from market.permissions import ( IsSuperUser, ListingImageOwnerPermission, + ListingOwnerOffersPermission, ListingOwnerPermission, OfferOwnerPermission, ) @@ -30,7 +33,9 @@ ListingSerializer, ListingSerializerList, ListingSerializerPublic, + OfferDetailsSerializer, OfferSerializer, + OfferStatusSerializer, TagSerializer, UserSerializer, ) @@ -192,6 +197,11 @@ def retrieve(self, request, *args, **kwargs): serializer = serializer_class(instance, context={"request": request}) return Response(serializer.data) + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response({"deleted": True}, status=status.HTTP_200_OK) + # TODO: This doesn't use CreateAPIView's functionality # since we overrode the create method. @@ -296,10 +306,18 @@ class Offers(viewsets.ModelViewSet): Delete the offer between the user and the listing matching the ID. """ - permission_classes = [OfferOwnerPermission | IsSuperUser] serializer_class = OfferSerializer pagination_class = PageSizeOffsetPagination + def get_permissions(self): + if self.action == "create": + return [IsAuthenticated()] + if self.action == "destroy": + return [(OfferOwnerPermission | IsSuperUser)()] + if self.action == "list": + return [(ListingOwnerOffersPermission | IsSuperUser)()] + return [(ListingOwnerOffersPermission | IsSuperUser)()] + def get_queryset(self): if Listing.objects.filter(pk=int(self.kwargs["listing_id"])).exists(): return Offer.objects.filter( @@ -332,7 +350,7 @@ def destroy(self, request, *args, **kwargs): obj = get_object_or_404(queryset, **filter) self.check_object_permissions(self.request, obj) self.perform_destroy(obj) - return Response(status=status.HTTP_204_NO_CONTENT) + return Response({"deleted": True}, status=status.HTTP_200_OK) def list(self, request, *args, **kwargs): if not Listing.objects.filter(pk=int(self.kwargs["listing_id"])).exists(): @@ -342,6 +360,56 @@ def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) +class OfferStatusUpdate(UpdateAPIView): + """Allow the listing seller (or superuser) to update an offer's status (PATCH).""" + + queryset = Offer.objects.all() + serializer_class = OfferStatusSerializer + permission_classes = [ListingOwnerOffersPermission | IsSuperUser] + lookup_url_kwarg = "offer_id" + http_method_names = ["patch"] + + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(OfferSerializer(instance).data) + + +class OfferDetailsUpdate(UpdateAPIView): + """Offer owner (or superuser) may PATCH 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 partial_update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(OfferSerializer(instance).data) + + +class MyOfferForListing(RetrieveAPIView): + """Return the authenticated user's offer for a given listing (GET).""" + + 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 + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) def send_verification_code(request): diff --git a/backend/tests/market/__pycache__/__init__.cpython-311.pyc b/backend/tests/market/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index b421b5a..0000000 Binary files a/backend/tests/market/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/backend/tests/market/__pycache__/test_market.cpython-311.pyc b/backend/tests/market/__pycache__/test_market.cpython-311.pyc deleted file mode 100644 index 33d9aef..0000000 Binary files a/backend/tests/market/__pycache__/test_market.cpython-311.pyc and /dev/null differ diff --git a/backend/tests/market/__pycache__/test_market_2.cpython-311.pyc b/backend/tests/market/__pycache__/test_market_2.cpython-311.pyc deleted file mode 100644 index 2b4ab09..0000000 Binary files a/backend/tests/market/__pycache__/test_market_2.cpython-311.pyc and /dev/null differ diff --git a/backend/tests/market/__pycache__/test_permissions.cpython-311.pyc b/backend/tests/market/__pycache__/test_permissions.cpython-311.pyc deleted file mode 100644 index 317cfe2..0000000 Binary files a/backend/tests/market/__pycache__/test_permissions.cpython-311.pyc and /dev/null differ diff --git a/backend/tests/market/test_market.py b/backend/tests/market/test_market.py index 5318a81..c938793 100644 --- a/backend/tests/market/test_market.py +++ b/backend/tests/market/test_market.py @@ -1369,7 +1369,8 @@ def test_create_offer_existing(self): def test_delete_offer(self): self.assertTrue(Offer.objects.filter(id=self.offers[0].id).exists()) response = self.client.delete(f"/market/listings/{self.items[1].id}/offers/") - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"deleted": True}) self.assertFalse(Offer.objects.filter(id=self.offers[0].id).exists()) def test_delete_offer_nonexistent(self): diff --git a/frontend/app/edit/item/[id]/page.tsx b/frontend/app/edit/item/[id]/page.tsx new file mode 100644 index 0000000..ffaa0de --- /dev/null +++ b/frontend/app/edit/item/[id]/page.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { BackButton } from "@/components/listings/detail/BackButton"; +import { ItemForm } from "@/components/listings/form/ItemForm"; +import { getCurrentUser, getListingOrNotFound } from "@/lib/actions"; + +export default async function EditItemPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [listing, currentUser] = await Promise.all([getListingOrNotFound(id), getCurrentUser()]); + + if (listing.listing_type !== "item" || currentUser.id !== listing.seller.id) { + notFound(); + } + + return ( +
+ + + + +

Edit Item

+ + +
+ ); +} diff --git a/frontend/app/edit/sublet/[id]/page.tsx b/frontend/app/edit/sublet/[id]/page.tsx new file mode 100644 index 0000000..287e609 --- /dev/null +++ b/frontend/app/edit/sublet/[id]/page.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { BackButton } from "@/components/listings/detail/BackButton"; +import { SubletForm } from "@/components/listings/form/SubletForm"; +import { getCurrentUser, getListingOrNotFound } from "@/lib/actions"; + +export default async function EditSubletPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [listing, currentUser] = await Promise.all([getListingOrNotFound(id), getCurrentUser()]); + + if (listing.listing_type !== "sublet" || currentUser.id !== listing.seller.id) { + notFound(); + } + + return ( +
+ + + + +

Edit Sublet

+ + +
+ ); +} diff --git a/frontend/app/items/[id]/edit/page.tsx b/frontend/app/items/[id]/edit/page.tsx new file mode 100644 index 0000000..080e962 --- /dev/null +++ b/frontend/app/items/[id]/edit/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function EditItemPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + redirect(`/edit/item/${id}`); +} diff --git a/frontend/app/items/[id]/page.tsx b/frontend/app/items/[id]/page.tsx index 464b8ac..740f3b6 100644 --- a/frontend/app/items/[id]/page.tsx +++ b/frontend/app/items/[id]/page.tsx @@ -1,20 +1,33 @@ import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { ListingDetail } from "@/components/listings/detail/ListingDetail"; -import { getListingOrNotFound } from "@/lib/actions"; +import { + getCurrentUser, + getListingOrNotFound, + getMyOfferForListing, + getOffersReceivedForListing, +} from "@/lib/actions"; import { queryKeys } from "@/lib/queryKeys"; 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 offersReceivedResponse = isOwner ? await getOffersReceivedForListing(item.id) : null; + const offersReceived = offersReceivedResponse?.results ?? []; + const myOfferGiven = !isOwner ? await getMyOfferForListing(item.id) : null; - // seed the cache with the already-fetched listing (no additional fetch). - // We use setQueryData instead of prefetchQuery to preserve the notFound() throw above. const queryClient = new QueryClient(); queryClient.setQueryData(queryKeys.listing(item.id), item); return ( - + ); } diff --git a/frontend/app/sublets/[id]/edit/page.tsx b/frontend/app/sublets/[id]/edit/page.tsx new file mode 100644 index 0000000..48af146 --- /dev/null +++ b/frontend/app/sublets/[id]/edit/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default async function EditSubletPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + redirect(`/edit/sublet/${id}`); +} diff --git a/frontend/app/sublets/[id]/page.tsx b/frontend/app/sublets/[id]/page.tsx index f0d101e..0ecae7d 100644 --- a/frontend/app/sublets/[id]/page.tsx +++ b/frontend/app/sublets/[id]/page.tsx @@ -1,20 +1,33 @@ import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { ListingDetail } from "@/components/listings/detail/ListingDetail"; -import { getListingOrNotFound } from "@/lib/actions"; +import { + getCurrentUser, + getListingOrNotFound, + getMyOfferForListing, + getOffersReceivedForListing, +} from "@/lib/actions"; import { queryKeys } from "@/lib/queryKeys"; 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()]); + const isOwner = currentUser?.id === sublet.seller.id; + const offersReceivedResponse = isOwner ? await getOffersReceivedForListing(sublet.id) : null; + const offersReceived = offersReceivedResponse?.results ?? []; + const myOfferGiven = !isOwner ? await getMyOfferForListing(sublet.id) : null; - // seed the cache with the already-fetched listing (no additional fetch). - // We use setQueryData instead of prefetchQuery to preserve the notFound() throw above. const queryClient = new QueryClient(); queryClient.setQueryData(queryKeys.listing(sublet.id), sublet); return ( - + ); } diff --git a/frontend/components/listings/detail/DeleteListing.tsx b/frontend/components/listings/detail/DeleteListing.tsx new file mode 100644 index 0000000..168d646 --- /dev/null +++ b/frontend/components/listings/detail/DeleteListing.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { deleteListing } from "@/lib/actions"; +import type { Item, Sublet } from "@/lib/types"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface Props { + listing: Item | Sublet; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const DeleteListing = ({ listing, open, onOpenChange }: Props) => { + const router = useRouter(); + const queryClient = useQueryClient(); + const typeLabel = listing.listing_type === "sublet" ? "Sublet" : "Item"; + + const deleteMutation = useMutation({ + mutationFn: () => deleteListing(listing.id), + onSuccess: () => { + const listKey = listing.listing_type === "sublet" ? "sublets" : "items"; + queryClient.invalidateQueries({ queryKey: [listKey] }); + queryClient.removeQueries({ queryKey: ["listing", listing.id] }); + router.push(listing.listing_type === "sublet" ? "/sublets" : "/items"); + }, + onError: () => { + onOpenChange(false); + }, + }); + + return ( + + + + Delete {typeLabel} + + Are you sure you want to delete this {typeLabel.toLowerCase()}? + + + + + + + + + ); +}; diff --git a/frontend/components/listings/detail/ListingActions.tsx b/frontend/components/listings/detail/ListingActions.tsx index 1fc3c9e..ec4a532 100644 --- a/frontend/components/listings/detail/ListingActions.tsx +++ b/frontend/components/listings/detail/ListingActions.tsx @@ -1,53 +1,93 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { DollarSign } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { MakeOfferModal } from "@/components/listings/offer/MakeOfferModal"; import { PhoneInputModal } from "@/components/listings/offer/PhoneInputModal"; import { VerificationCodeModal } from "@/components/listings/offer/VerificationCodeModal"; +import { DeleteListing } from "@/components/listings/detail/DeleteListing"; import { Button } from "@/components/ui/button"; -import { getPhoneStatus } from "@/lib/actions"; +import { getMyOfferForListing, getPhoneStatus } from "@/lib/actions"; +import type { Item, Offer, Sublet } from "@/lib/types"; interface Props { - listingId: number; + listing: Item | Sublet; listingPrice: number; listingOwnerLabel: string; priceLabel?: string; + isOwner?: boolean; + initialMyOffer?: Offer | null; } type ModalState = "none" | "phone-input" | "verification" | "offer"; export const ListingActions = ({ - listingId, + listing, listingPrice, priceLabel, listingOwnerLabel, + isOwner = false, + initialMyOffer = null, }: Props) => { const [modalState, setModalState] = useState("none"); const [pendingPhoneNumber, setPendingPhoneNumber] = useState(""); const [isChangingPhone, setIsChangingPhone] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); const queryClient = useQueryClient(); const { data: phoneStatus } = useQuery({ queryKey: ["phoneStatus"], queryFn: getPhoneStatus, + enabled: !isOwner, }); + const { data: myOffer, isLoading: isLoadingMyOffer } = useQuery({ + queryKey: ["myOffer", listing.id], + queryFn: () => getMyOfferForListing(listing.id), + enabled: !isOwner, + initialData: initialMyOffer, + }); + + if (isOwner) { + const typeLabel = listing.listing_type === "sublet" ? "Sublet" : "Item"; + const editHref = + listing.listing_type === "sublet" ? `/edit/sublet/${listing.id}` : `/edit/item/${listing.id}`; + + return ( + <> +
+ + +
+ + + ); + } + const handleMakeOfferClick = () => { if (!phoneStatus) return; if (!phoneStatus.phone_number || !phoneStatus.phone_verified) { - // no phone number or not verified - start with phone input - // if we already have a pending phone number, go to verification if (pendingPhoneNumber) { setModalState("verification"); } else { setModalState("phone-input"); } } else { - // phone verified - show create offer modal setModalState("offer"); } }; @@ -71,13 +111,15 @@ export const ListingActions = ({ return ( <> - + {!isLoadingMyOffer && !myOffer && ( + + )} setModalState("none")} - listingId={listingId} + listingId={listing.id} listingPrice={listingPrice} listingOwnerLabel={listingOwnerLabel} priceLabel={priceLabel} diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index 358023e..2c8f1ef 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -1,39 +1,64 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { addToUsersFavorites, deleteFromUsersFavorites, getListing } from "@/lib/actions"; -import { queryKeys } from "@/lib/queryKeys"; import { Heart, Share } from "lucide-react"; -import { Item, Sublet } from "@/lib/types"; -import { ListingActions } from "@/components/listings/detail/ListingActions"; +import { Item, Offer, Sublet } from "@/lib/types"; 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 { OffersPanel } from "@/components/listings/offer/OffersPanel"; import { BackButton } from "@/components/listings/detail/BackButton"; import { SubletMap } from "@/components/listings/detail/SubletMap"; +import { + addToUsersFavorites, + deleteFromUsersFavorites, + getListing, +} from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; interface Props { - listingId: number; + listing: Item | Sublet; + initialIsFavorited: boolean; + offersReceived: Offer[]; + isOwner: boolean; + myOfferGiven?: Offer | null; } -export const ListingDetail = ({ listingId }: Props) => { +export const ListingDetail = ({ + listing, + initialIsFavorited, + offersReceived, + isOwner, + myOfferGiven = null, +}: Props) => { const queryClient = useQueryClient(); - const queryKey = queryKeys.listing(listingId); + const queryKey = queryKeys.listing(listing.id); - const { data: listing } = useQuery({ + const listingQuery = useQuery({ queryKey, - queryFn: () => getListing(String(listingId)), + queryFn: () => getListing(listing.id.toString()), + initialData: { + ...listing, + is_favorited: listing.is_favorited ?? initialIsFavorited, + }, + staleTime: Infinity, }); + const listingData = listingQuery.data; - const isFavorited = listing?.is_favorited ?? false; + const listingType = listingData.listing_type; + const priceLabel = listingType === "sublet" ? "/mo" : undefined; + const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; + + const isFavorited = listingData.is_favorited ?? false; const toggleFavoriteMutation = useMutation({ meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update mutationFn: async (shouldFavorite: boolean) => { if (shouldFavorite) { - await addToUsersFavorites(listingId); + await addToUsersFavorites(listingData.id); } else { - await deleteFromUsersFavorites(listingId); + await deleteFromUsersFavorites(listingData.id); } }, onMutate: async (shouldFavorite: boolean) => { @@ -55,13 +80,8 @@ export const ListingDetail = ({ listingId }: Props) => { toggleFavoriteMutation.mutate(!isFavorited); }; - if (!listing) return null; - - const listingType = listing.listing_type; - const priceLabel = listingType === "sublet" ? "/mo" : undefined; - const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; - - const subletCoords = listingType === "sublet" ? listing.additional_data : null; + const subletCoords = + listingData.listing_type === "sublet" ? listingData.additional_data : null; const hasLocation = subletCoords?.latitude != null && subletCoords?.longitude != null; return ( @@ -82,33 +102,41 @@ export const ListingDetail = ({ listingId }: Props) => {
- +
- + {hasLocation && (

{"Where you'll be living"}

- Approximate location shown. The exact location will be shared once you connect - with the owner. + Approximate location shown. The exact location will be shared once you connect with + the owner.

- +
)} +
diff --git a/frontend/components/listings/form/ItemForm.tsx b/frontend/components/listings/form/ItemForm.tsx index bd814f2..5b2203d 100644 --- a/frontend/components/listings/form/ItemForm.tsx +++ b/frontend/components/listings/form/ItemForm.tsx @@ -9,18 +9,23 @@ import { FormSelect } from "@/components/common/FormSelect"; import { BaseListingForm } from "@/components/listings/form/BaseListingForm"; import { ListingFormShell } from "@/components/listings/form/ListingFormShell"; import { useImageUpload } from "@/hooks/useImageUpload"; -import { createListing } from "@/lib/actions"; +import { createListing, updateListing } from "@/lib/actions"; import { CATEGORY_OPTIONS, CONDITION_OPTIONS } from "@/lib/constants"; import { parsePriceString } from "@/lib/utils"; import { type CreateItemFormData, createItemSchema } from "@/lib/validations"; -import type { CreateItemPayload } from "@/lib/types"; +import type { CreateItemPayload, Item, UpdateItemPayload } from "@/lib/types"; const DISPLAY_LABEL = "Item"; const EXAMPLE_TITLE = "e.g., Nike Air Force 1"; -export function ItemForm() { +type ItemFormProps = { + initialListing?: Item; +}; + +export function ItemForm({ initialListing }: ItemFormProps) { const router = useRouter(); const queryClient = useQueryClient(); + const isEditMode = Boolean(initialListing); const { control, @@ -32,12 +37,12 @@ export function ItemForm() { mode: "onBlur", reValidateMode: "onChange", defaultValues: { - title: "", - price: "", - description: "", + title: initialListing?.title ?? "", + price: initialListing ? String(initialListing.price) : "", + description: initialListing?.description ?? "", tags: [], - condition: undefined, - category: undefined, + condition: initialListing?.additional_data.condition, + category: initialListing?.additional_data.category, }, }); @@ -45,30 +50,51 @@ export function ItemForm() { maxFiles: 10, maxSizeBytes: 10 * 1024 * 1024, onError: (message) => toast.error(message), + initialUrls: initialListing?.images, }); const { mutate, isPending } = useMutation({ - mutationFn: createListing, + mutationFn: (payload: CreateItemPayload | UpdateItemPayload) => + isEditMode && initialListing + ? updateListing(initialListing.id, payload as UpdateItemPayload) + : createListing(payload as CreateItemPayload), onSuccess: (data) => { - toast.success(`${DISPLAY_LABEL} created successfully!`); + toast.success(`${DISPLAY_LABEL} ${isEditMode ? "updated" : "created"} successfully!`); + if (isEditMode) { + queryClient.invalidateQueries({ queryKey: ["listing", initialListing?.id] }); + } queryClient.invalidateQueries({ queryKey: ["items"] }); - reset(); - imageUpload.clearImages(); + if (!isEditMode) { + reset(); + imageUpload.clearImages(); + } router.replace(`/items/${data.id}`); }, }); const onSubmit = (data: CreateItemFormData) => { - const payload: CreateItemPayload = { - title: data.title, - description: data.description, - price: String(parsePriceString(data.price)), - listing_type: "item", - additional_data: { - condition: data.condition, - category: data.category, - }, - }; + const parsedPrice = parsePriceString(data.price); + const payload: CreateItemPayload | UpdateItemPayload = isEditMode + ? { + title: data.title, + description: data.description, + price: parsedPrice, + listing_type: "item", + additional_data: { + condition: data.condition, + category: data.category, + }, + } + : { + title: data.title, + description: data.description, + price: String(parsedPrice), + listing_type: "item", + additional_data: { + condition: data.condition, + category: data.category, + }, + }; mutate(payload); }; @@ -116,6 +142,8 @@ export function ItemForm() { isPending={isPending} displayLabel={DISPLAY_LABEL} imageUpload={imageUpload} + submitLabel={isEditMode ? "Save Changes" : undefined} + pendingLabel={isEditMode ? "Saving..." : undefined} > control={control} diff --git a/frontend/components/listings/form/ListingFormShell.tsx b/frontend/components/listings/form/ListingFormShell.tsx index 1b53640..174a95c 100644 --- a/frontend/components/listings/form/ListingFormShell.tsx +++ b/frontend/components/listings/form/ListingFormShell.tsx @@ -20,6 +20,10 @@ interface ListingFormShellProps { imageUpload: ReturnType; /** The form fields (left column) */ children: React.ReactNode; + /** Optional override for submit button text */ + submitLabel?: string; + /** Optional override for pending button text */ + pendingLabel?: string; } export function ListingFormShell({ @@ -30,6 +34,8 @@ export function ListingFormShell({ maxFiles = 10, imageUpload, children, + submitLabel, + pendingLabel, }: ListingFormShellProps) { return (
@@ -52,6 +58,10 @@ export function ListingFormShell({ onRemove={imageUpload.removeImage} />
+ {/* + Keep the live region message in sync with the button state so screen readers + announce the right action in both create and edit modes. + */}
- {isPending && "Creating your listing, please wait..."} + {isPending && (pendingLabel ?? "Creating your listing, please wait...")}
diff --git a/frontend/components/listings/form/SubletForm.tsx b/frontend/components/listings/form/SubletForm.tsx index 34f9412..5e977a3 100644 --- a/frontend/components/listings/form/SubletForm.tsx +++ b/frontend/components/listings/form/SubletForm.tsx @@ -13,19 +13,25 @@ import { AddressAutocomplete } from "@/components/listings/address/AddressAutoco import { BaseListingForm } from "@/components/listings/form/BaseListingForm"; import { ListingFormShell } from "@/components/listings/form/ListingFormShell"; import { useImageUpload } from "@/hooks/useImageUpload"; -import { createListing } from "@/lib/actions"; +import { createListing, updateListing } from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; import { BEDS_OPTIONS, BATHS_OPTIONS } from "@/lib/constants"; import { parsePriceString } from "@/lib/utils"; import { createSubletSchema } from "@/lib/validations"; -import type { CreateSubletPayload } from "@/lib/types"; +import type { CreateSubletPayload, Sublet, UpdateSubletPayload } from "@/lib/types"; import type { CreateSubletFormData } from "@/lib/validations"; const DISPLAY_LABEL = "Listing"; const EXAMPLE_TITLE = "e.g., Spacious 2BR near campus"; -export function SubletForm() { +type SubletFormProps = { + initialListing?: Sublet; +}; + +export function SubletForm({ initialListing }: SubletFormProps) { const router = useRouter(); const queryClient = useQueryClient(); + const isEditMode = Boolean(initialListing); const { control, @@ -37,17 +43,17 @@ export function SubletForm() { mode: "onBlur", reValidateMode: "onChange", defaultValues: { - title: "", - price: "", - description: "", + title: initialListing?.title ?? "", + price: initialListing ? String(initialListing.price) : "", + description: initialListing?.description ?? "", tags: [], - street_address: "", - latitude: 0, - longitude: 0, - beds: 0, - baths: 0, - startDate: "", - endDate: "", + street_address: initialListing?.additional_data.street_address ?? "", + latitude: initialListing?.additional_data.latitude ?? 0, + longitude: initialListing?.additional_data.longitude ?? 0, + beds: initialListing?.additional_data.beds ?? 0, + baths: initialListing?.additional_data.baths ?? 0, + startDate: initialListing?.additional_data.start_date?.slice(0, 10) ?? "", + endDate: initialListing?.additional_data.end_date?.slice(0, 10) ?? "", }, }); @@ -55,35 +61,54 @@ export function SubletForm() { maxFiles: 10, maxSizeBytes: 10 * 1024 * 1024, onError: (message) => toast.error(message), + initialUrls: initialListing?.images, }); const { mutate, isPending } = useMutation({ - mutationFn: createListing, + mutationFn: (payload: CreateSubletPayload | UpdateSubletPayload) => + isEditMode && initialListing + ? updateListing(initialListing.id, payload as UpdateSubletPayload) + : createListing(payload as CreateSubletPayload), onSuccess: (data) => { - toast.success(`${DISPLAY_LABEL} created successfully!`); + toast.success(`${DISPLAY_LABEL} ${isEditMode ? "updated" : "created"} successfully!`); + if (isEditMode && initialListing) { + queryClient.invalidateQueries({ queryKey: queryKeys.listing(initialListing.id) }); + } queryClient.invalidateQueries({ queryKey: ["sublets"] }); - reset(); - imageUpload.clearImages(); + if (!isEditMode) { + reset(); + imageUpload.clearImages(); + } router.replace(`/sublets/${data.id}`); }, }); const onSubmit = (data: CreateSubletFormData) => { - const payload: CreateSubletPayload = { - title: data.title, - description: data.description, - price: String(parsePriceString(data.price)), - listing_type: "sublet", - additional_data: { - street_address: data.street_address, - latitude: data.latitude, - longitude: data.longitude, - beds: data.beds, - baths: data.baths, - start_date: data.startDate, - end_date: data.endDate, - }, + const parsedPrice = parsePriceString(data.price); + const additional_data = { + street_address: data.street_address, + latitude: data.latitude, + longitude: data.longitude, + beds: data.beds, + baths: data.baths, + start_date: data.startDate, + end_date: data.endDate, }; + const payload: CreateSubletPayload | UpdateSubletPayload = isEditMode + ? { + title: data.title, + description: data.description ?? "", + price: parsedPrice, + listing_type: "sublet", + additional_data, + } + : { + title: data.title, + description: data.description ?? "", + price: String(parsedPrice), + listing_type: "sublet", + additional_data, + }; mutate(payload); }; @@ -225,6 +250,8 @@ export function SubletForm() { isPending={isPending} displayLabel={DISPLAY_LABEL} imageUpload={imageUpload} + submitLabel={isEditMode ? "Save Changes" : undefined} + pendingLabel={isEditMode ? "Saving..." : undefined} > control={control} diff --git a/frontend/components/listings/offer/EditMyOfferModal.tsx b/frontend/components/listings/offer/EditMyOfferModal.tsx new file mode 100644 index 0000000..2e24a21 --- /dev/null +++ b/frontend/components/listings/offer/EditMyOfferModal.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useEffect } from "react"; +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 { Pencil } from "lucide-react"; + +import type { Offer } from "@/lib/types"; +import { offerSchema, type OfferFormData } from "@/lib/validations"; +import { updateMyOfferDetails } from "@/lib/actions"; +import { parsePriceString } from "@/lib/utils"; +import { FormDialog } from "@/components/common/FormDialog"; +import { FormField } from "@/components/common/FormField"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; + +export function EditMyOfferModal({ + isOpen, + onClose, + offer, + onSaved, +}: { + isOpen: boolean; + onClose: () => void; + offer: Offer; + onSaved: () => void; +}) { + const queryClient = useQueryClient(); + + const { + control, + handleSubmit, + reset, + formState: { errors, isSubmitting, isValid, touchedFields }, + } = useForm({ + resolver: zodResolver(offerSchema), + mode: "onChange", + defaultValues: { + offeredPrice: "", + message: "", + }, + }); + + useEffect(() => { + if (isOpen) { + reset({ + offeredPrice: offer.offered_price.toString(), + message: offer.message ?? "", + }); + } + }, [isOpen, offer, reset]); + + const editMutation = useMutation({ + mutationFn: async (data: OfferFormData) => { + return updateMyOfferDetails(offer.id, { + offeredPrice: parsePriceString(data.offeredPrice), + message: data.message?.trim() || "", + }); + }, + onMutate: async (data: OfferFormData) => { + await queryClient.cancelQueries({ queryKey: ["myOffer", offer.listing] }); + const previousOffer = queryClient.getQueryData(["myOffer", offer.listing]); + queryClient.setQueryData(["myOffer", offer.listing], { + ...offer, + offered_price: parsePriceString(data.offeredPrice), + message: data.message?.trim() || "", + }); + return { previousOffer }; + }, + onSuccess: (updatedOffer) => { + toast.success("Offer updated!"); + queryClient.setQueryData(["myOffer", offer.listing], updatedOffer); + onSaved(); + }, + onError: (_error, _vars, context) => { + if (context?.previousOffer !== undefined) { + queryClient.setQueryData(["myOffer", offer.listing], context.previousOffer); + } + toast.error("Failed to update offer."); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["myOffer", offer.listing] }); + }, + }); + + return ( + + editMutation.mutate(data))} + className="space-y-4" + > +
+ + Current status: {offer.status} +
+ + + ( + + )} + /> + + + ( + +