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)], +};