Skip to content

Commit 075c60b

Browse files
committed
Addressing frontend comments 2
1 parent ca8ac1c commit 075c60b

9 files changed

Lines changed: 131 additions & 437 deletions

File tree

frontend/components/listings/detail/EditListing.tsx

Lines changed: 0 additions & 408 deletions
This file was deleted.

frontend/components/listings/detail/ListingActions.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ import { PhoneInputModal } from "@/components/listings/offer/PhoneInputModal";
99
import { VerificationCodeModal } from "@/components/listings/offer/VerificationCodeModal";
1010
import { DeleteListing } from "@/components/listings/detail/DeleteListing";
1111
import { Button } from "@/components/ui/button";
12-
import { getPhoneStatus } from "@/lib/actions";
13-
import type { Item, Sublet } from "@/lib/types";
12+
import { getMyOfferForListing, getPhoneStatus } from "@/lib/actions";
13+
import type { Item, Offer, Sublet } from "@/lib/types";
1414

1515
interface Props {
1616
listing: Item | Sublet;
1717
listingPrice: number;
1818
listingOwnerLabel: string;
1919
priceLabel?: string;
2020
isOwner?: boolean;
21+
initialMyOffer?: Offer | null;
2122
}
2223

2324
type ModalState = "none" | "phone-input" | "verification" | "offer";
@@ -28,6 +29,7 @@ export const ListingActions = ({
2829
priceLabel,
2930
listingOwnerLabel,
3031
isOwner = false,
32+
initialMyOffer = null,
3133
}: Props) => {
3234
const [modalState, setModalState] = useState<ModalState>("none");
3335
const [pendingPhoneNumber, setPendingPhoneNumber] = useState<string>("");
@@ -41,6 +43,12 @@ export const ListingActions = ({
4143
queryFn: getPhoneStatus,
4244
enabled: !isOwner,
4345
});
46+
const { data: myOffer, isLoading: isLoadingMyOffer } = useQuery({
47+
queryKey: ["myOffer", listing.id],
48+
queryFn: () => getMyOfferForListing(listing.id),
49+
enabled: !isOwner,
50+
initialData: initialMyOffer,
51+
});
4452

4553
if (isOwner) {
4654
const typeLabel = listing.listing_type === "sublet" ? "Sublet" : "Item";
@@ -102,13 +110,15 @@ export const ListingActions = ({
102110

103111
return (
104112
<>
105-
<Button
106-
onClick={handleMakeOfferClick}
107-
className="bg-brand hover:bg-brand-hover h-12 w-full cursor-pointer text-base text-white"
108-
>
109-
<DollarSign className="mr-2 h-5 w-5" />
110-
Make an Offer
111-
</Button>
113+
{!isLoadingMyOffer && !myOffer && (
114+
<Button
115+
onClick={handleMakeOfferClick}
116+
className="bg-brand hover:bg-brand-hover h-12 w-full cursor-pointer text-base text-white"
117+
>
118+
<DollarSign className="mr-2 h-5 w-5" />
119+
Make an Offer
120+
</Button>
121+
)}
112122

113123
<PhoneInputModal
114124
isOpen={modalState === "phone-input"}

frontend/components/listings/detail/ListingDetail.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ListingImageGallery } from "@/components/listings/detail/ListingImageGa
66
import { ListingInfo } from "@/components/listings/detail/ListingInfo";
77
import { UserCard } from "@/components/listings/detail/UserCard";
88
import { ListingActions } from "@/components/listings/detail/ListingActions";
9-
import { OffersReceivedSection } from "@/components/listings/offer/OffersSection";
9+
import { OffersReceived } from "@/components/listings/offer/OffersReceived";
1010
import { BackButton } from "@/components/listings/detail/BackButton";
1111
import {
1212
addToUsersFavorites,
@@ -142,8 +142,9 @@ export const ListingDetail = ({
142142
priceLabel={priceLabel}
143143
listingOwnerLabel={listingOwnerLabel}
144144
isOwner={isOwner}
145+
initialMyOffer={myOfferGiven}
145146
/>
146-
<OffersReceivedSection
147+
<OffersReceived
147148
isOwner={isOwner}
148149
offersReceived={offersReceived}
149150
myOfferGiven={myOfferGiven}

frontend/components/listings/offer/EditMyOfferModal.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,30 @@ export function EditMyOfferModal({
6060
message: data.message?.trim() || "",
6161
});
6262
},
63-
onSuccess: () => {
63+
onMutate: async (data: OfferFormData) => {
64+
await queryClient.cancelQueries({ queryKey: ["myOffer", offer.listing] });
65+
const previousOffer = queryClient.getQueryData<Offer | null>(["myOffer", offer.listing]);
66+
queryClient.setQueryData<Offer | null>(["myOffer", offer.listing], {
67+
...offer,
68+
offered_price: parsePriceString(data.offeredPrice),
69+
message: data.message?.trim() || "",
70+
});
71+
return { previousOffer };
72+
},
73+
onSuccess: (updatedOffer) => {
6474
toast.success("Offer updated!");
65-
queryClient.invalidateQueries({ queryKey: ["myOffer", offer.listing] });
75+
queryClient.setQueryData(["myOffer", offer.listing], updatedOffer);
6676
onSaved();
6777
},
68-
onError: () => {
78+
onError: (_error, _vars, context) => {
79+
if (context?.previousOffer !== undefined) {
80+
queryClient.setQueryData(["myOffer", offer.listing], context.previousOffer);
81+
}
6982
toast.error("Failed to update offer.");
7083
},
84+
onSettled: () => {
85+
queryClient.invalidateQueries({ queryKey: ["myOffer", offer.listing] });
86+
},
7187
});
7288

7389
return (

frontend/components/listings/offer/MakeOfferModal.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,42 @@ export function MakeOfferModal({
6060

6161
const createOfferMutation = useMutation({
6262
mutationFn: createOffer,
63+
onMutate: async (variables) => {
64+
await queryClient.cancelQueries({ queryKey: ["myOffer", listingId] });
65+
const previousOffer = queryClient.getQueryData<Offer | null>(["myOffer", listingId]);
66+
const optimisticOffer: Offer = {
67+
id: -Date.now(),
68+
user: previousOffer?.user ?? {
69+
id: 0,
70+
username: "you",
71+
first_name: "You",
72+
last_name: "",
73+
email: "",
74+
phone_number: null,
75+
phone_verified: false,
76+
},
77+
listing: listingId,
78+
offered_price: variables.offeredPrice,
79+
message: variables.message ?? "",
80+
status: "pending",
81+
created_at: new Date().toISOString(),
82+
};
83+
queryClient.setQueryData(["myOffer", listingId], optimisticOffer);
84+
return { previousOffer };
85+
},
6386
onSuccess: (createdOffer: Offer) => {
6487
toast.success(`Offer sent! The ${listingOwnerLabel.toLowerCase()} will contact you.`);
6588
queryClient.setQueryData(["myOffer", listingId], createdOffer);
66-
queryClient.invalidateQueries({ queryKey: ["myOffer", listingId] });
6789
onClose();
6890
},
91+
onError: (_error, _vars, context) => {
92+
if (context?.previousOffer !== undefined) {
93+
queryClient.setQueryData(["myOffer", listingId], context.previousOffer);
94+
}
95+
},
96+
onSettled: () => {
97+
queryClient.invalidateQueries({ queryKey: ["myOffer", listingId] });
98+
},
6999
});
70100

71101
const handleCreateOffer = (data: OfferFormData) => {

frontend/components/listings/offer/MyOfferCard.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"use client";
22

33
import { Pencil, Trash2, Clock, Star } from "lucide-react";
4+
import { useQuery } from "@tanstack/react-query";
45
import type { Offer } from "@/lib/types";
56
import { Button } from "@/components/ui/button";
67
import Image from "next/image";
7-
import { formatDateTime } from "@/lib/utils";
8+
import { formatServerDateTimeToLocal } from "@/lib/utils";
89

910
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
1011
pending: { label: "Pending", className: "bg-yellow-100 text-yellow-700" },
@@ -21,7 +22,11 @@ export function MyOfferCard({
2122
onEdit: () => void;
2223
onDelete: () => void;
2324
}) {
24-
const { date, time } = formatDateTime(offer.created_at);
25+
const { data: localCreatedAt } = useQuery({
26+
queryKey: ["localDateTime", offer.created_at],
27+
queryFn: () => formatServerDateTimeToLocal(offer.created_at),
28+
staleTime: Infinity,
29+
});
2530
const badge = STATUS_BADGE[offer.status];
2631

2732
return (
@@ -50,7 +55,7 @@ export function MyOfferCard({
5055
<div className="flex items-center gap-1 text-xs text-gray-400">
5156
<Clock className="h-3 w-3" />
5257
<span>
53-
{date} at {time}
58+
{localCreatedAt ? `${localCreatedAt.date} at ${localCreatedAt.time}` : ""}
5459
</span>
5560
</div>
5661
</div>

frontend/components/listings/offer/OfferCard.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import Image from "next/image";
44
import { Check, Clock, Star, X } from "lucide-react";
5-
import { useMutation } from "@tanstack/react-query";
5+
import { useMutation, useQuery } from "@tanstack/react-query";
66
import { Offer } from "@/lib/types";
77
import { Button } from "@/components/ui/button";
88
import { changeOfferStatus } from "@/lib/actions";
9-
import { formatDateTime } from "@/lib/utils";
9+
import { formatServerDateTimeToLocal } from "@/lib/utils";
1010

1111
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
1212
accepted: { label: "Accepted", className: "bg-green-100 text-green-700" },
@@ -20,7 +20,11 @@ export const OfferCard = ({
2020
offer: Offer;
2121
onStatusChange: (id: number, status: Offer["status"]) => void;
2222
}) => {
23-
const { date, time } = formatDateTime(offer.created_at);
23+
const { data: localCreatedAt } = useQuery({
24+
queryKey: ["localDateTime", offer.created_at],
25+
queryFn: () => formatServerDateTimeToLocal(offer.created_at),
26+
staleTime: Infinity,
27+
});
2428

2529
const mutation = useMutation({
2630
mutationFn: (status: Offer["status"]) => changeOfferStatus(offer.id, status),
@@ -53,7 +57,7 @@ export const OfferCard = ({
5357
<div className="flex items-center gap-1 text-xs text-gray-400">
5458
<Clock className="h-3 w-3" />
5559
<span>
56-
{date} at {time}
60+
{localCreatedAt ? `${localCreatedAt.date} at ${localCreatedAt.time}` : ""}
5761
</span>
5862
</div>
5963
</div>

frontend/components/listings/offer/OffersSection.tsx renamed to frontend/components/listings/offer/OffersReceived.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { Offer } from "@/lib/types";
55
import { OfferCard } from "@/components/listings/offer/OfferCard";
66
import { MyOfferCard } from "@/components/listings/offer/MyOfferCard";
77
import { EditMyOfferModal } from "@/components/listings/offer/EditMyOfferModal";
8-
import { deleteMyOfferForListing } from "@/lib/actions";
9-
import { useMutation, useQueryClient } from "@tanstack/react-query";
8+
import { deleteMyOfferForListing, getMyOfferForListing } from "@/lib/actions";
9+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1010
import { Button } from "@/components/ui/button";
1111
import {
1212
Dialog,
@@ -17,7 +17,7 @@ import {
1717
DialogTitle,
1818
} from "@/components/ui/dialog";
1919

20-
export const OffersReceivedSection = ({
20+
export const OffersReceived = ({
2121
isOwner,
2222
offersReceived: initialOffersReceived,
2323
myOfferGiven,
@@ -32,28 +32,47 @@ export const OffersReceivedSection = ({
3232
const [isEditOfferOpen, setIsEditOfferOpen] = useState(false);
3333
const [isDeleteOfferOpen, setIsDeleteOfferOpen] = useState(false);
3434
const queryClient = useQueryClient();
35+
const { data: myOffer } = useQuery({
36+
queryKey: ["myOffer", listingId],
37+
queryFn: () => getMyOfferForListing(listingId),
38+
enabled: !isOwner,
39+
initialData: myOfferGiven,
40+
});
3541

3642
const handleStatusChange = (id: number, status: Offer["status"]) => {
3743
setOffersReceived((prev) => prev.map((offer) => (offer.id === id ? { ...offer, status } : offer)));
3844
};
3945

4046
const deleteMyOfferMutation = useMutation({
4147
mutationFn: () => deleteMyOfferForListing(listingId),
48+
onMutate: async () => {
49+
await queryClient.cancelQueries({ queryKey: ["myOffer", listingId] });
50+
const previousOffer = queryClient.getQueryData<Offer | null>(["myOffer", listingId]);
51+
queryClient.setQueryData<Offer | null>(["myOffer", listingId], null);
52+
return { previousOffer };
53+
},
4254
onSuccess: () => {
4355
setIsDeleteOfferOpen(false);
56+
},
57+
onError: (_error, _vars, context) => {
58+
if (context?.previousOffer !== undefined) {
59+
queryClient.setQueryData(["myOffer", listingId], context.previousOffer);
60+
}
61+
},
62+
onSettled: () => {
4463
queryClient.invalidateQueries({ queryKey: ["myOffer", listingId] });
4564
},
4665
});
4766

4867
if (!isOwner) {
49-
if (!myOfferGiven) return null;
68+
if (!myOffer) return null;
5069

5170
return (
5271
<>
5372
<div className="space-y-3">
5473
<h2 className="text-lg font-semibold">Your offer</h2>
5574
<MyOfferCard
56-
offer={myOfferGiven}
75+
offer={myOffer}
5776
onEdit={() => setIsEditOfferOpen(true)}
5877
onDelete={() => setIsDeleteOfferOpen(true)}
5978
/>
@@ -62,7 +81,7 @@ export const OffersReceivedSection = ({
6281
<EditMyOfferModal
6382
isOpen={isEditOfferOpen}
6483
onClose={() => setIsEditOfferOpen(false)}
65-
offer={myOfferGiven}
84+
offer={myOffer}
6685
onSaved={() => {
6786
queryClient.invalidateQueries({ queryKey: ["myOffer", listingId] });
6887
setIsEditOfferOpen(false);
@@ -116,4 +135,3 @@ export const OffersReceivedSection = ({
116135
</div>
117136
);
118137
};
119-

frontend/lib/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ export function formatDate(dateString: string) {
2626
}
2727

2828
export function formatDateTime(dateString: string) {
29+
const date = new Date(dateString);
30+
const datePart = date.toLocaleDateString("en-US", {
31+
month: "short",
32+
day: "numeric",
33+
year: "numeric",
34+
timeZone: "UTC",
35+
});
36+
const timePart = date.toLocaleTimeString("en-US", {
37+
hour: "numeric",
38+
minute: "2-digit",
39+
hour12: true,
40+
timeZone: "UTC",
41+
});
42+
return { date: datePart, time: timePart };
43+
}
44+
45+
// Convert an ISO timestamp from the server into the viewer's local machine time.
46+
export function formatServerDateTimeToLocal(dateString: string) {
2947
const date = new Date(dateString);
3048
const datePart = date.toLocaleDateString("en-US", {
3149
month: "short",

0 commit comments

Comments
 (0)