Skip to content
Open
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
13 changes: 13 additions & 0 deletions frontend/app/profile/listings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getMyListings } from "@/lib/actions";
import { MyListingsContent } from "@/components/profile/MyListingsContent";

export default async function MyListingsPage() {
const myListings = await getMyListings();

return (
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-4 pt-6 sm:px-12">
<h1 className="text-2xl font-bold">My Listings</h1>
<MyListingsContent listings={myListings.results} />
</div>
);
}
31 changes: 31 additions & 0 deletions frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getCurrentUser, getMyListings, getUsersFavorites } from "@/lib/actions";
import { ProfileHeader } from "@/components/profile/ProfileHeader";
import { ListingSection } from "@/components/profile/ListingSection";

export default async function ProfilePage() {
const [currentUser, myListings, savedPosts] = await Promise.all([
getCurrentUser(),
getMyListings(),
getUsersFavorites(),
]);

return (
<div className="container mx-auto w-full max-w-[96rem] space-y-8 px-4 pt-6 sm:px-12">
<ProfileHeader user={currentUser} />
<ListingSection
title="My Listings"
count={myListings.count}
listings={myListings.results}
seeAllHref="/profile/listings"
icon="listings"
/>
<ListingSection
title="Saved Posts"
count={savedPosts.count}
listings={savedPosts.results}
seeAllHref="/profile/saved"
icon="saved"
/>
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/app/profile/saved/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getUsersFavorites } from "@/lib/actions";
import { MyListingsContent } from "@/components/profile/MyListingsContent";

export default async function SavedListingsPage() {
const savedListings = await getUsersFavorites();

return (
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-4 pt-6 sm:px-12">
<h1 className="text-2xl font-bold">Saved Listings</h1>
<MyListingsContent listings={savedListings.results} />
</div>
);
}
6 changes: 3 additions & 3 deletions frontend/components/navbar/UserProfileDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function UserProfileDropdown({ isOpen, onClose }: UserProfileDropdownProp
role="menu"
>
<Link
href="/"
href="/profile"
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-t-md px-3 py-3 text-sm",
Expand All @@ -37,7 +37,7 @@ export function UserProfileDropdown({ isOpen, onClose }: UserProfileDropdownProp

<div className="text-foreground mt-1 px-3 py-1 text-xs font-bold">Selling</div>
<Link
href="/"
href="/profile/listings"
Comment thread
i30101 marked this conversation as resolved.
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-3 text-sm",
Expand All @@ -51,7 +51,7 @@ export function UserProfileDropdown({ isOpen, onClose }: UserProfileDropdownProp

<div className="text-foreground mt-1 px-3 py-1 text-xs font-bold">Buying</div>
<Link
href="/"
href="/profile/saved"
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-3 text-sm",
Expand Down
48 changes: 48 additions & 0 deletions frontend/components/profile/ListingSection.tsx
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this component used anywhere?

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Link from "next/link";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding bg to this component to distinguish itself from the background. It doesn't look bad right now, but just something I'd think about

import { ShoppingBag, Bookmark } from "lucide-react";
import { ListingsCard } from "@/components/listings/ListingsCard";
import { Listing } from "@/lib/types";

interface Props {
title: string;
count: number;
listings: Listing[];
seeAllHref: string;
icon: "listings" | "saved";
}

export const ListingSection = ({ title, count, listings, seeAllHref, icon }: Props) => {
const Icon = icon === "listings" ? ShoppingBag : Bookmark;

return (
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5" />
<h2 className="text-lg font-bold">
{title} ({count})
</h2>
</div>
<Link href={seeAllHref} className="text-brand text-sm font-medium hover:underline">
See All {title}
</Link>
</div>

{listings.length === 0 ? (
<p className="text-sm text-gray-500">No {title.toLowerCase()} yet.</p>
) : (
<div className="flex gap-4 overflow-x-auto pb-2">
{listings.map((listing) => (
<div key={listing.id} className="w-48 flex-shrink-0">
<ListingsCard
listing={listing}
previewImageUrl={listing.images[0]}
href={`/${listing.listing_type === "item" ? "items" : "sublets"}/${listing.id}`}
/>
</div>
))}
</div>
)}
</div>
);
};
120 changes: 120 additions & 0 deletions frontend/components/profile/MyListingsContent.tsx
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, the listings are horizontally scrollable - im wondering if this is necessary if we have "Show all my listings" button. What do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would say the same, I think it would be simpler if clicking on "My Listings" or "Saved Posts" sent you to their respective pages instead of what we have now

Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import { useState, useMemo } from "react";
import { SearchInput } from "@/components/filters/SearchInput";
import { ListingsCard } from "@/components/listings/ListingsCard";
import { Listing } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";

const TABS = ["All Listings", "Active Listings", "Completed"] as const;
type Tab = (typeof TABS)[number];

interface Props {
listings: Listing[];
}

function isActive(listing: Listing): boolean {
if (!listing.expires_at) return true;
return new Date(listing.expires_at) > new Date();
}

export const MyListingsContent = ({ listings }: Props) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine for now how you are showing all my listings at once, but better practice is using paginated listings. It's unlikely that a single user has that many listings that fetching all at once is gonna cause any issues, but we never know. Take a look at how this is handled in the main grid view. If you want feel free to take a stab at it here as well, but its not necessary at this pt imo

const [activeTab, setActiveTab] = useState<Tab>("All Listings");
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>("all");

const categories = useMemo(() => {
const cats = new Set<string>();
listings.forEach((l) => {
if (l.listing_type === "item") cats.add(l.additional_data.category);
if (l.listing_type === "sublet") cats.add("Sublet");
});
return Array.from(cats).sort();
}, [listings]);

const filtered = useMemo(() => {
let result = listings;

if (activeTab === "Active Listings") {
result = result.filter(isActive);
} else if (activeTab === "Completed") {
result = result.filter((l) => !isActive(l));
}

if (search.trim()) {
const q = search.trim().toLowerCase();
result = result.filter((l) => l.title.toLowerCase().includes(q));
}

if (category !== "all") {
result = result.filter((l) => {
if (category === "Sublet") return l.listing_type === "sublet";
return l.listing_type === "item" && l.additional_data.category === category;
});
}

return result;
}, [listings, activeTab, search, category]);

return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex gap-6">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"pb-1 text-sm font-medium transition-colors",
activeTab === tab
? "text-brand border-brand border-b-2"
: "text-gray-500 hover:text-gray-700"
)}
>
{tab}
</button>
))}
</div>

<div className="flex items-center gap-3">
<SearchInput placeholder="Search Listings" value={search} onChange={setSearch} />
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

{filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-gray-500">No listings found.</p>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((listing) => (
<ListingsCard
key={listing.id}
listing={listing}
previewImageUrl={listing.images[0]}
href={`/${listing.listing_type === "item" ? "items" : "sublets"}/${listing.id}`}
Comment on lines +109 to +113
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image I think the card ratio looks kinda weird here? Maybe make it bigger?

/>
))}
</div>
)}
</div>
);
};
58 changes: 58 additions & 0 deletions frontend/components/profile/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Image from "next/image";
import { Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { User } from "@/lib/types";

interface Props {
user: User;
}

export const ProfileHeader = ({ user }: Props) => {
const fullName = `${user.first_name} ${user.last_name}`;

return (
<div className="rounded-xl border border-gray-200 bg-white p-6 sm:p-8">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4 sm:gap-6">
<Image
src="/images/default-avatar.png"
alt={fullName}
width={80}
height={80}
className="rounded-full"
/>
<div className="space-y-1">
<h1 className="text-xl font-bold sm:text-2xl">{fullName}</h1>
<div className="flex items-center gap-1.5 text-sm text-gray-600">
{/* TODO: replace with actual user rating */}
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
Comment thread
i30101 marked this conversation as resolved.
<span className="ml-1 font-medium">5.0</span>
<span className="text-gray-400">(10 reviews)</span>
</div>
</div>
</div>
<Button variant="outline">Edit Info</Button>
</div>

<div className="mt-6 flex flex-wrap gap-x-12 gap-y-3 text-sm">
<div>
<p className="text-brand font-medium">Email</p>
<p className="text-gray-700">{user.email}</p>
</div>
<div>
<p className="text-brand font-medium">Phone number</p>
<p className="text-gray-700">{user.phone_number || "Not set"}</p>
</div>
<div>
<p className="text-brand font-medium">Notification method</p>
{/* TODO: replace with actual notification preference */}
<p className="text-gray-700">Email</p>
</div>
</div>
Comment thread
i30101 marked this conversation as resolved.
</div>
);
};
16 changes: 16 additions & 0 deletions frontend/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,19 @@ export async function createListing(payload: CreateListingPayload): Promise<List
body: JSON.stringify(payload),
});
}

// ------------------------------------------------------------
// user's own listings
// ------------------------------------------------------------

export async function getMyListings({ pageParam = 1 }: { pageParam?: unknown } = {}) {
const page = typeof pageParam === "number" ? pageParam : 1;
const offset = (page - 1) * FETCH_LISTINGS_LIMIT;

const params = new URLSearchParams();
params.append("seller", "true");
params.append("limit", FETCH_LISTINGS_LIMIT.toString());
params.append("offset", offset.toString());

return await serverFetch<PaginatedResponse<Listing>>(`/market/listings/?${params.toString()}`);
}
3 changes: 3 additions & 0 deletions frontend/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type User = {
first_name: string;
last_name: string;
email: string;
phone_number: string | null;
phone_verified: boolean;
};

// ------------------------------------------------------------
Expand Down Expand Up @@ -124,6 +126,7 @@ type BaseListing = {
images: string[];
tags: string[];
favorite_count: number;
offers_count?: number;
is_favorited?: boolean;
seller: User;
};
Expand Down
Loading