Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/market/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
13 changes: 12 additions & 1 deletion frontend/app/items/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
// 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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<ListingDetail listingId={item.id} />
</HydrationBoundary>
);
}
20 changes: 18 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
<PageHeader title="Browse Items" description="Discover the latest items on sale at Penn" />
<ItemFilters />
<ListingsGrid type="items" listings={items} currentUser={currentUser} />
<HydrationBoundary state={dehydrate(queryClient)}>
<ListingsGrid type="items" currentUser={currentUser} />
</HydrationBoundary>
</div>
);
}
13 changes: 12 additions & 1 deletion frontend/app/sublets/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
// 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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<ListingDetail listingId={sublet.id} />
</HydrationBoundary>
);
}
19 changes: 16 additions & 3 deletions frontend/app/sublets/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
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(),
]);

return (
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
<PageHeader title="Browse Sublets" description="Find your perfect housing solution at Penn" />
<SubletFilters />
<ListingsGrid type="sublets" listings={sublets} currentUser={currentUser} />
<HydrationBoundary state={dehydrate(queryClient)}>
<ListingsGrid type="sublets" currentUser={currentUser} />
</HydrationBoundary>
</div>
);
}
21 changes: 7 additions & 14 deletions frontend/components/listings/ListingsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item>;
currentUser: User;
}
| {
type: "sublets";
listings: PaginatedResponse<Sublet>;
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;
Expand Down
45 changes: 25 additions & 20 deletions frontend/components/listings/detail/ListingDetail.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<boolean>(["favorite", listing.id]);
queryClient.setQueryData(["favorite", listing.id], shouldFavorite);
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<Item | Sublet>(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);
}
},
});
Expand All @@ -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;

Expand Down
15 changes: 4 additions & 11 deletions frontend/hooks/useListings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -12,30 +13,22 @@ const LISTING_FETCHERS = {

export type UseListingsParams<T extends ListingTypes> = {
type: T;
listings: PaginatedResponse<ListingDataMap[T]>;
};

export function useListings<T extends ListingTypes>({ type, listings }: UseListingsParams<T>) {
export function useListings<T extends ListingTypes>({ type }: UseListingsParams<T>) {
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<Listing>
>({
queryKey,
queryKey: queryKeys.listings(type, filters),
queryFn,
initialData: hasActiveFilters ? undefined : { pages: [listings], pageParams: [1] },
placeholderData: (previousData) => previousData,
initialPageParam: 1,
getNextPageParam(lastPage, allPages) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item | Sublet>(`/market/listings/${id}/`);
}

Expand Down
11 changes: 11 additions & 0 deletions frontend/lib/queryKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { DEFAULT_FILTERS } from "@/lib/constants";
import { ListingFiltersMap, ListingTypes } from "@/lib/types";

export const queryKeys = {
listings: <T extends ListingTypes>(type: T, filters?: ListingFiltersMap[T]) => [
type,
"list",
filters ?? DEFAULT_FILTERS[type],
],
listing: (id: number | string) => ["listing", String(id)],
};
Loading