diff --git a/backend/market/views.py b/backend/market/views.py
index c85974c..d61d749 100644
--- a/backend/market/views.py
+++ b/backend/market/views.py
@@ -189,7 +189,7 @@ def retrieve(self, request, *args, **kwargs):
serializer_class = ListingSerializer
else:
serializer_class = ListingSerializerPublic
- serializer = serializer_class(instance)
+ serializer = serializer_class(instance, context={"request": request})
return Response(serializer.data)
diff --git a/frontend/app/items/[id]/page.tsx b/frontend/app/items/[id]/page.tsx
index bbf5b40..464b8ac 100644
--- a/frontend/app/items/[id]/page.tsx
+++ b/frontend/app/items/[id]/page.tsx
@@ -1,9 +1,20 @@
+import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
import { getListingOrNotFound } 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);
- return ;
+ // 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/page.tsx b/frontend/app/page.tsx
index 1c448a5..65f27ef 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,16 +1,32 @@
+import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getCurrentUser, getItems } from "@/lib/actions";
+import { queryKeys } from "@/lib/queryKeys";
import { PageHeader } from "@/components/common/PageHeader";
import { ItemFilters } from "@/components/filters/ItemFilters";
import { ListingsGrid } from "@/components/listings/ListingsGrid";
export default async function ItemsPage() {
- const [items, currentUser] = await Promise.all([getItems({ pageParam: 1 }), getCurrentUser()]);
+ const queryClient = new QueryClient();
+
+ const [, currentUser] = await Promise.all([
+ queryClient.fetchInfiniteQuery({
+ queryKey: queryKeys.listings("items"),
+ queryFn: () => getItems({ pageParam: 1 }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.results.length > 0 ? allPages.length + 1 : undefined,
+ pages: 1,
+ }),
+ getCurrentUser(),
+ ]);
return (
);
}
diff --git a/frontend/app/sublets/[id]/page.tsx b/frontend/app/sublets/[id]/page.tsx
index 2f71c36..f0d101e 100644
--- a/frontend/app/sublets/[id]/page.tsx
+++ b/frontend/app/sublets/[id]/page.tsx
@@ -1,9 +1,20 @@
+import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
import { getListingOrNotFound } 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);
- return ;
+ // 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/app/sublets/page.tsx b/frontend/app/sublets/page.tsx
index 30c4966..c36ae5f 100644
--- a/frontend/app/sublets/page.tsx
+++ b/frontend/app/sublets/page.tsx
@@ -1,11 +1,22 @@
+import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getCurrentUser, getSublets } from "@/lib/actions";
+import { queryKeys } from "@/lib/queryKeys";
import { PageHeader } from "@/components/common/PageHeader";
import { SubletFilters } from "@/components/filters/SubletFilters";
import { ListingsGrid } from "@/components/listings/ListingsGrid";
export default async function SubletsPage() {
- const [sublets, currentUser] = await Promise.all([
- getSublets({ pageParam: 1 }),
+ const queryClient = new QueryClient();
+
+ const [, currentUser] = await Promise.all([
+ queryClient.fetchInfiniteQuery({
+ queryKey: queryKeys.listings("sublets"),
+ queryFn: () => getSublets({ pageParam: 1 }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.results.length > 0 ? allPages.length + 1 : undefined,
+ pages: 1,
+ }),
getCurrentUser(),
]);
@@ -13,7 +24,9 @@ export default async function SubletsPage() {
);
}
diff --git a/frontend/components/listings/ListingsGrid.tsx b/frontend/components/listings/ListingsGrid.tsx
index 07ae27e..122b166 100644
--- a/frontend/components/listings/ListingsGrid.tsx
+++ b/frontend/components/listings/ListingsGrid.tsx
@@ -5,22 +5,15 @@ import { Spinner } from "@/components/ui/spinner";
import { ListingsCard } from "@/components/listings/ListingsCard";
import { NoListingsFound } from "@/components/listings/NoListingsFound";
import { useListings } from "@/hooks/useListings";
-import { Item, PaginatedResponse, Sublet, User } from "@/lib/types";
+import { ListingTypes, User } from "@/lib/types";
-type Props =
- | {
- type: "items";
- listings: PaginatedResponse- ;
- currentUser: User;
- }
- | {
- type: "sublets";
- listings: PaginatedResponse;
- currentUser: User;
- };
+type Props = {
+ type: ListingTypes;
+ currentUser: User;
+};
-export const ListingsGrid = ({ type, listings, currentUser }: Props) => {
- const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type, listings });
+export const ListingsGrid = ({ type, currentUser }: Props) => {
+ const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type });
const totalResults = data?.pages.reduce((acc, page) => acc + page.results.length, 0) || 0;
const isEmpty = totalResults === 0;
diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx
index 612d439..358023e 100644
--- a/frontend/components/listings/detail/ListingDetail.tsx
+++ b/frontend/components/listings/detail/ListingDetail.tsx
@@ -1,7 +1,8 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions";
+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";
@@ -12,42 +13,40 @@ import { BackButton } from "@/components/listings/detail/BackButton";
import { SubletMap } from "@/components/listings/detail/SubletMap";
interface Props {
- listing: Item | Sublet;
- initialIsFavorited: boolean;
+ listingId: number;
}
-export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
- const listingType = listing.listing_type;
- const priceLabel = listingType === "sublet" ? "/mo" : undefined;
- const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
+export const ListingDetail = ({ listingId }: Props) => {
const queryClient = useQueryClient();
- const favoritesQuery = useQuery({
- queryKey: ["favorite", listing.id],
- queryFn: async () => initialIsFavorited,
- initialData: initialIsFavorited,
- staleTime: Infinity,
+ const queryKey = queryKeys.listing(listingId);
+
+ const { data: listing } = useQuery({
+ queryKey,
+ queryFn: () => getListing(String(listingId)),
});
- const isFavorited = favoritesQuery.data ?? false;
+ const isFavorited = listing?.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(listing.id);
+ await addToUsersFavorites(listingId);
} else {
- await deleteFromUsersFavorites(listing.id);
+ await deleteFromUsersFavorites(listingId);
}
},
onMutate: async (shouldFavorite: boolean) => {
- await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] });
- const previous = queryClient.getQueryData(["favorite", listing.id]);
- queryClient.setQueryData(["favorite", listing.id], shouldFavorite);
+ await queryClient.cancelQueries({ queryKey });
+ const previous = queryClient.getQueryData
- (queryKey);
+ if (previous) {
+ queryClient.setQueryData(queryKey, { ...previous, is_favorited: shouldFavorite });
+ }
return { previous };
},
onError: (_error, _shouldFavorite, context) => {
- if (context?.previous !== undefined) {
- queryClient.setQueryData(["favorite", listing.id], context.previous);
+ if (context?.previous) {
+ queryClient.setQueryData(queryKey, context.previous);
}
},
});
@@ -56,6 +55,12 @@ export const ListingDetail = ({ listing, initialIsFavorited }: 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 hasLocation = subletCoords?.latitude != null && subletCoords?.longitude != null;
diff --git a/frontend/hooks/useListings.ts b/frontend/hooks/useListings.ts
index e6c6e93..2bdb960 100644
--- a/frontend/hooks/useListings.ts
+++ b/frontend/hooks/useListings.ts
@@ -2,7 +2,8 @@ import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { useInfiniteQuery } from "@tanstack/react-query";
import { getItems, getSublets } from "@/lib/actions";
-import { ListingTypes, ListingDataMap, PaginatedResponse, Listing } from "@/lib/types";
+import { queryKeys } from "@/lib/queryKeys";
+import { ListingTypes, PaginatedResponse, Listing } from "@/lib/types";
import { useFilters } from "@/providers/FiltersProvider";
const LISTING_FETCHERS = {
@@ -12,30 +13,22 @@ const LISTING_FETCHERS = {
export type UseListingsParams = {
type: T;
- listings: PaginatedResponse;
};
-export function useListings({ type, listings }: UseListingsParams) {
+export function useListings({ type }: UseListingsParams) {
const { ref, inView } = useInView();
const { debouncedFilters } = useFilters();
const filters = debouncedFilters[type];
- const queryKey = [type, "list", filters];
const queryFn = ({ pageParam = 1 }: { pageParam: unknown }) =>
LISTING_FETCHERS[type]({ pageParam, ...filters });
- const hasActiveFilters = Object.values(filters).some((value) => {
- if (typeof value === "string") return value.trim() !== "";
- return value !== undefined;
- });
-
const { data, error, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery<
PaginatedResponse
>({
- queryKey,
+ queryKey: queryKeys.listings(type, filters),
queryFn,
- initialData: hasActiveFilters ? undefined : { pages: [listings], pageParams: [1] },
placeholderData: (previousData) => previousData,
initialPageParam: 1,
getNextPageParam(lastPage, allPages) {
diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts
index 7061b7e..439c970 100644
--- a/frontend/lib/actions.ts
+++ b/frontend/lib/actions.ts
@@ -182,7 +182,7 @@ export async function getSublets({
// ------------------------------------------------------------
// single listing (items or sublets)
// ------------------------------------------------------------
-async function getListing(id: string) {
+export async function getListing(id: string) {
return await serverFetch
- (`/market/listings/${id}/`);
}
diff --git a/frontend/lib/queryKeys.ts b/frontend/lib/queryKeys.ts
new file mode 100644
index 0000000..cf971af
--- /dev/null
+++ b/frontend/lib/queryKeys.ts
@@ -0,0 +1,11 @@
+import { DEFAULT_FILTERS } from "@/lib/constants";
+import { ListingFiltersMap, ListingTypes } from "@/lib/types";
+
+export const queryKeys = {
+ listings: (type: T, filters?: ListingFiltersMap[T]) => [
+ type,
+ "list",
+ filters ?? DEFAULT_FILTERS[type],
+ ],
+ listing: (id: number | string) => ["listing", String(id)],
+};