Skip to content

Commit d8ddfab

Browse files
authored
Merge branch 'master' into james/remove-my-purchases
2 parents 65b14db + 8fba73d commit d8ddfab

13 files changed

Lines changed: 213 additions & 56 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ yarn-error.log*
5050
# TypeScript
5151
*.tsbuildinfo
5252
next-env.d.ts
53+
54+
# git worktrees
55+
.worktrees/
56+
.claude/worktrees/

CLAUDE.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Penn Marketplace is a full-stack platform for Penn students to buy/sell items and browse/post sublets. It uses a **hybrid Docker development approach**: services (Postgres, Redis, Django, Next.js) run in Docker, but dependencies are also installed locally for IDE support.
8+
9+
- **Frontend**: Next.js 15 (App Router) + React 19 + TypeScript + Tailwind CSS 4
10+
- **Backend**: Django 5.0 + Django REST Framework
11+
- **Database**: PostgreSQL 15.8, **Cache**: Redis
12+
- **Auth**: Penn Platform OIDC via `django-labs-accounts` (backend) and `jose` JWT handling (frontend)
13+
14+
## Commands
15+
16+
### Development (Docker)
17+
18+
```bash
19+
make setup # One-time: install local deps + pre-commit hooks
20+
make up # Start all containers (frontend :3000, backend :8000)
21+
make up-d # Start in background
22+
make down # Stop containers
23+
make logs # View all container logs
24+
```
25+
26+
### Frontend (from `frontend/`, package manager: pnpm)
27+
28+
```bash
29+
pnpm dev # Dev server with Turbopack
30+
pnpm check # Run all checks: lint + typecheck + format:check
31+
pnpm fix # Auto-fix: lint:fix + format
32+
pnpm lint # ESLint only
33+
pnpm typecheck # TypeScript only (tsc --noEmit)
34+
pnpm format # Prettier only
35+
```
36+
37+
### Backend (from `backend/`, package manager: uv)
38+
39+
```bash
40+
uv run pytest # Run tests
41+
uv run ruff check . # Lint
42+
uv run ruff format . # Format
43+
make migrate # Apply migrations (via Docker)
44+
make makemigrations # Create migrations (via Docker)
45+
make test # Run pytest via Docker
46+
make generate-data # Generate fake listings
47+
```
48+
49+
### CI Checks (must pass before merge)
50+
51+
Backend: `uv run ruff check .` + `uv run ruff format .`
52+
Frontend: `pnpm lint` + `pnpm typecheck`
53+
54+
Run `./scripts/check.sh` locally to match CI. Pre-commit hooks auto-fix on commit.
55+
56+
## Architecture
57+
58+
### Frontend (`frontend/`)
59+
60+
- **App Router**: Pages in `app/`, with `items/`, `sublets/`, `create/` routes
61+
- **Server Actions** (`lib/actions.ts`): All API calls go through `serverFetch<T>()` which handles auth headers from cookies. This is the main data-fetching layer.
62+
- **TanStack Query**: Client-side caching/refetching via `providers/TanstackQueryProvider.tsx`
63+
- **Form handling**: React Hook Form + Zod schemas (`lib/validations.ts`)
64+
- **Middleware** (`middleware.ts`): OIDC token refresh and auth redirects
65+
- **UI primitives**: shadcn/ui components in `components/ui/` (built on Radix UI)
66+
- **Types**: All API response types in `lib/types.ts`
67+
- **Constants**: Environment variables and app constants in `lib/constants.ts`
68+
69+
### Backend (`backend/`)
70+
71+
- **Single Django app** (`market/`): models, views, serializers, permissions, urls
72+
- **Config**: Split settings in `config/settings/` (base, development, production)
73+
- **Models**: `User` (extends AbstractUser), `Listing` (base), `Item` and `Sublet` (inherit from Listing), `ListingImage`, `Offer`, `Category`, `Tag`
74+
- **Views**: DRF ViewSets (`Listings`, `Offers`, `Favorites`) and function-based views for user/phone endpoints
75+
- **URL prefix**: All market endpoints under `/market/` (listings, offers, favorites, tags, phone verification, user)
76+
- **Tests**: `tests/market/test_market.py` using pytest + DRF's APIClient
77+
78+
### API Pattern
79+
80+
REST JSON API with offset/limit pagination. Endpoints return `{ count, next, previous, results }`. Auth via `Authorization: Bearer <token>` header (set automatically by server actions).
81+
82+
## Key Conventions
83+
84+
### Frontend
85+
- Strict TypeScript (`"strict": true`) — avoid `any`
86+
- Prettier: 100 char width, 2 spaces, semicolons, Tailwind class sorting plugin
87+
- Server Components by default; use `"use client"` only when needed
88+
- Component composition: Page → Filters → Grid → Card
89+
90+
### Backend
91+
- Ruff for linting + formatting (88 char width, double quotes, isort with 2 blank lines after imports)
92+
- Excluded from ruff: `migrations/`, `.venv/`
93+
- Migrations are auto-applied on Docker startup
94+
95+
### Installing New Packages
96+
97+
**Frontend**: `cd frontend && pnpm add <pkg>`, then `docker compose exec frontend pnpm install`
98+
**Backend**: `cd backend && uv add <pkg>`, then `docker compose exec backend uv sync`
99+
100+
No Docker rebuild needed — lock files are volume-mounted.

backend/market/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def retrieve(self, request, *args, **kwargs):
189189
serializer_class = ListingSerializer
190190
else:
191191
serializer_class = ListingSerializerPublic
192-
serializer = serializer_class(instance)
192+
serializer = serializer_class(instance, context={"request": request})
193193
return Response(serializer.data)
194194

195195

frontend/app/items/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const item = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(item.id), item);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={item.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/page.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getItems } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { ItemFilters } from "@/components/filters/ItemFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function ItemsPage() {
7-
const [items, currentUser] = await Promise.all([getItems({ pageParam: 1 }), getCurrentUser()]);
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("items"),
14+
queryFn: () => getItems({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
20+
getCurrentUser(),
21+
]);
822

923
return (
1024
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1125
<PageHeader title="Browse Items" description="Discover the latest items on sale at Penn" />
1226
<ItemFilters />
13-
<ListingsGrid type="items" listings={items} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="items" currentUser={currentUser} />
29+
</HydrationBoundary>
1430
</div>
1531
);
1632
}

frontend/app/sublets/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const sublet = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(sublet.id), sublet);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={sublet.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/sublets/page.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getSublets } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { SubletFilters } from "@/components/filters/SubletFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function SubletsPage() {
7-
const [sublets, currentUser] = await Promise.all([
8-
getSublets({ pageParam: 1 }),
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("sublets"),
14+
queryFn: () => getSublets({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
920
getCurrentUser(),
1021
]);
1122

1223
return (
1324
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1425
<PageHeader title="Browse Sublets" description="Find your perfect housing solution at Penn" />
1526
<SubletFilters />
16-
<ListingsGrid type="sublets" listings={sublets} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="sublets" currentUser={currentUser} />
29+
</HydrationBoundary>
1730
</div>
1831
);
1932
}

frontend/components/listings/ListingsGrid.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@ import { Spinner } from "@/components/ui/spinner";
55
import { ListingsCard } from "@/components/listings/ListingsCard";
66
import { NoListingsFound } from "@/components/listings/NoListingsFound";
77
import { useListings } from "@/hooks/useListings";
8-
import { Item, PaginatedResponse, Sublet, User } from "@/lib/types";
8+
import { ListingTypes, User } from "@/lib/types";
99

10-
type Props =
11-
| {
12-
type: "items";
13-
listings: PaginatedResponse<Item>;
14-
currentUser: User;
15-
}
16-
| {
17-
type: "sublets";
18-
listings: PaginatedResponse<Sublet>;
19-
currentUser: User;
20-
};
10+
type Props = {
11+
type: ListingTypes;
12+
currentUser: User;
13+
};
2114

22-
export const ListingsGrid = ({ type, listings, currentUser }: Props) => {
23-
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type, listings });
15+
export const ListingsGrid = ({ type, currentUser }: Props) => {
16+
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type });
2417

2518
const totalResults = data?.pages.reduce((acc, page) => acc + page.results.length, 0) || 0;
2619
const isEmpty = totalResults === 0;

frontend/components/listings/detail/ListingDetail.tsx

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

33
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4-
import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions";
4+
import { addToUsersFavorites, deleteFromUsersFavorites, getListing } from "@/lib/actions";
5+
import { queryKeys } from "@/lib/queryKeys";
56
import { Heart, Share } from "lucide-react";
67
import { Item, Sublet } from "@/lib/types";
78
import { ListingActions } from "@/components/listings/detail/ListingActions";
@@ -12,42 +13,40 @@ import { BackButton } from "@/components/listings/detail/BackButton";
1213
import { SubletMap } from "@/components/listings/detail/SubletMap";
1314

1415
interface Props {
15-
listing: Item | Sublet;
16-
initialIsFavorited: boolean;
16+
listingId: number;
1717
}
1818

19-
export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
20-
const listingType = listing.listing_type;
21-
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
22-
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
19+
export const ListingDetail = ({ listingId }: Props) => {
2320
const queryClient = useQueryClient();
24-
const favoritesQuery = useQuery({
25-
queryKey: ["favorite", listing.id],
26-
queryFn: async () => initialIsFavorited,
27-
initialData: initialIsFavorited,
28-
staleTime: Infinity,
21+
const queryKey = queryKeys.listing(listingId);
22+
23+
const { data: listing } = useQuery({
24+
queryKey,
25+
queryFn: () => getListing(String(listingId)),
2926
});
3027

31-
const isFavorited = favoritesQuery.data ?? false;
28+
const isFavorited = listing?.is_favorited ?? false;
3229

3330
const toggleFavoriteMutation = useMutation({
3431
meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update
3532
mutationFn: async (shouldFavorite: boolean) => {
3633
if (shouldFavorite) {
37-
await addToUsersFavorites(listing.id);
34+
await addToUsersFavorites(listingId);
3835
} else {
39-
await deleteFromUsersFavorites(listing.id);
36+
await deleteFromUsersFavorites(listingId);
4037
}
4138
},
4239
onMutate: async (shouldFavorite: boolean) => {
43-
await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] });
44-
const previous = queryClient.getQueryData<boolean>(["favorite", listing.id]);
45-
queryClient.setQueryData(["favorite", listing.id], shouldFavorite);
40+
await queryClient.cancelQueries({ queryKey });
41+
const previous = queryClient.getQueryData<Item | Sublet>(queryKey);
42+
if (previous) {
43+
queryClient.setQueryData(queryKey, { ...previous, is_favorited: shouldFavorite });
44+
}
4645
return { previous };
4746
},
4847
onError: (_error, _shouldFavorite, context) => {
49-
if (context?.previous !== undefined) {
50-
queryClient.setQueryData(["favorite", listing.id], context.previous);
48+
if (context?.previous) {
49+
queryClient.setQueryData(queryKey, context.previous);
5150
}
5251
},
5352
});
@@ -56,6 +55,12 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
5655
toggleFavoriteMutation.mutate(!isFavorited);
5756
};
5857

58+
if (!listing) return null;
59+
60+
const listingType = listing.listing_type;
61+
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
62+
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
63+
5964
const subletCoords = listingType === "sublet" ? listing.additional_data : null;
6065
const hasLocation = subletCoords?.latitude != null && subletCoords?.longitude != null;
6166

frontend/components/navbar/NavbarActions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const NavbarActions = ({
9191
<Button
9292
variant="ghost"
9393
size="icon"
94-
className="relative"
94+
className="relative cursor-pointer"
9595
onClick={handleNotificationClick}
9696
aria-label={`Notifications${hasUnreadNotifications ? " (unread)" : ""}`}
9797
>
@@ -109,7 +109,7 @@ export const NavbarActions = ({
109109
<Button
110110
variant="ghost"
111111
size="icon"
112-
className="overflow-hidden rounded-full p-0 transition-opacity hover:opacity-80"
112+
className="cursor-pointer overflow-hidden rounded-full p-0 transition-opacity hover:opacity-80"
113113
onClick={handleAvatarClick}
114114
aria-label="User menu"
115115
aria-expanded={isDropdownOpen}

0 commit comments

Comments
 (0)