Skip to content
This repository was archived by the owner on Feb 18, 2025. It is now read-only.

Commit

Permalink
feat: seller view published items (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
mrcaidev authored Oct 6, 2024
1 parent 95f38aa commit 21d915f
Show file tree
Hide file tree
Showing 13 changed files with 160 additions and 83 deletions.
6 changes: 2 additions & 4 deletions services/item/src/items/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ itemsController.get(
limit: z.coerce.number().int().positive().default(8),
cursor: z.string().optional(),
type: z.enum(["single", "pack"]).optional(),
status: z.coerce
.number()
.pipe(z.nativeEnum(ItemStatus))
.default(ItemStatus.FOR_SALE),
status: z.coerce.number().pipe(z.nativeEnum(ItemStatus)).optional(),
seller_id: z.coerce.number().int().positive().optional(),
}),
),
async (c) => {
Expand Down
39 changes: 6 additions & 33 deletions services/item/src/items/repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ItemStatus, type SingleItem } from "@/types";
import { ItemStatus, type Item, type SingleItem } from "@/types";
import { itemsCollection } from "@/utils/db";
import { ObjectId } from "mongodb";
import type { Filter, FindOptions } from "mongodb";

/**
* Data access layer for items.
Expand All @@ -11,39 +11,12 @@ export const itemsRepository = {
insertOne,
};

type FindAllDto = {
limit: number;
cursor?: string | undefined;
type?: "single" | "pack" | undefined;
status: ItemStatus;
};

async function findAll(dto: FindAllDto) {
return await itemsCollection
.find(
{
...(dto.type ? { type: dto.type } : {}),
status: dto.status,
...(dto.cursor ? { _id: { $lt: new ObjectId(dto.cursor) } } : {}),
},
{
sort: { _id: -1 },
limit: dto.limit,
},
)
.toArray();
async function findAll(filter: Filter<Item>, options: FindOptions<Item>) {
return await itemsCollection.find(filter, options).toArray();
}

type CountDto = {
type?: "single" | "pack" | undefined;
status: ItemStatus;
};

async function count(dto: CountDto) {
return await itemsCollection.countDocuments({
...(dto.type ? { type: dto.type } : {}),
status: dto.status,
});
async function count(filter: Filter<Item>) {
return await itemsCollection.countDocuments(filter);
}

type InsertOneDto = {
Expand Down
15 changes: 12 additions & 3 deletions services/item/src/items/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ItemStatus, type Account } from "@/types";
import { ObjectId } from "mongodb";
import { itemsRepository } from "./repository";

/**
Expand All @@ -13,13 +14,21 @@ type GetAllItemsDto = {
limit: number;
cursor?: string | undefined;
type?: "single" | "pack" | undefined;
status: ItemStatus;
status?: ItemStatus | undefined;
seller_id?: number | undefined;
};

async function getAllItems(dto: GetAllItemsDto) {
const filter = {
...(dto.cursor ? { _id: { $lt: new ObjectId(dto.cursor) } } : {}),
...(dto.type ? { type: dto.type } : {}),
...(dto.status ? { status: dto.status } : {}),
...(dto.seller_id ? { "seller.id": dto.seller_id } : {}),
};

const [items, count] = await Promise.all([
itemsRepository.findAll(dto),
itemsRepository.count(dto),
itemsRepository.findAll(filter, { sort: { _id: -1 }, limit: dto.limit }),
itemsRepository.count(filter),
]);

const nextCursor =
Expand Down
46 changes: 36 additions & 10 deletions services/item/tests/items/get-all-items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,38 +117,64 @@ describe("Given type", () => {
});

describe("Given status", () => {
it("returns FOR_SALE items when status is not given", async () => {
const res = await request("/");
it("filters out items of the given status", async () => {
const res = await request("/?status=1");
const body = (await res.json()) as ExpectedResponse;

expect(res.status).toEqual(200);

for (const item of body.items) {
expect(item.status).toEqual(ItemStatus.FOR_SALE);
expect(item.status).toEqual(ItemStatus.DEALT);
}
});

it("filters out items of the given status", async () => {
const res = await request("/?status=1");
it("returns 400 when status is not a valid status", async () => {
const res = await request("/?status=100");
const body = await res.json();

expect(res.status).toEqual(400);
expect(body).toMatchObject({ error: expect.any(String) });
});

it("returns 400 when status is not a number", async () => {
const res = await request("/?status=foo");
const body = await res.json();

expect(res.status).toEqual(400);
expect(body).toMatchObject({ error: expect.any(String) });
});
});

describe("Given seller_id", () => {
it("filters out items from the given seller", async () => {
const res = await request("/?seller_id=1");
const body = (await res.json()) as ExpectedResponse;

expect(res.status).toEqual(200);

for (const item of body.items) {
expect(item.status).toEqual(ItemStatus.DEALT);
expect(item.seller.id).toEqual(1);
}
});

it("returns 400 when status is not a valid status", async () => {
const res = await request("/?status=100");
it("returns 400 when seller_id is not a number", async () => {
const res = await request("/?seller_id=foo");
const body = await res.json();

expect(res.status).toEqual(400);
expect(body).toMatchObject({ error: expect.any(String) });
});

it("returns 400 when status is not a number", async () => {
const res = await request("/?status=foo");
it("returns 400 when seller_id is not an integer", async () => {
const res = await request("/?seller_id=1.5");
const body = await res.json();

expect(res.status).toEqual(400);
expect(body).toMatchObject({ error: expect.any(String) });
});

it("returns 400 when seller_id is not positive", async () => {
const res = await request("/?seller_id=0");
const body = await res.json();

expect(res.status).toEqual(400);
Expand Down
28 changes: 28 additions & 0 deletions services/web/src/app/belongings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ItemCardList } from "@/components/item";
import { type Account } from "@/types";
import { ServerRequester } from "@/utils/requester/server";
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "My Belongings",
};

export default async function BelongingsPage() {
const me = await new ServerRequester().get<Account | undefined>("/auth/me");

if (!me) {
return null;
}

return (
<div className="min-h-[calc(100vh-64px)]">
<div className="space-y-4 mt-4 md:mt-8 mb-8">
<h1 className="font-bold text-3xl">My Belongings</h1>
<p className="text-muted-foreground">
Here are the items you have listed for sale.
</p>
</div>
<ItemCardList sellerId={me.id} />
</div>
);
}
15 changes: 4 additions & 11 deletions services/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { ItemPreviewCardList } from "@/components/item/item-preview-card-list";
import type { SingleItem } from "@/types";
import { ServerRequester } from "@/utils/requester/server";
import { ItemCardList } from "@/components/item";
import { ItemStatus } from "@/types";
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Marketplace | NUS Second-Hand Market",
};

export default async function Home() {
const data = await new ServerRequester().get<{
items: SingleItem[];
count: number;
nextCursor: string;
}>("/items");

export default function Home() {
return (
<div className="min-h-[calc(100vh-64px)]">
<div className="space-y-4 mt-4 md:mt-8 mb-8">
Expand All @@ -22,7 +15,7 @@ export default async function Home() {
We found something you might be interested in!
</p>
</div>
<ItemPreviewCardList initialData={data} />
<ItemCardList status={ItemStatus.FOR_SALE} />
</div>
);
}
2 changes: 1 addition & 1 deletion services/web/src/components/framework/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function Nav() {
<HeartIcon className="size-4" />
My wishlist
</NavLink>
<NavLink href="#">
<NavLink href="/belongings">
<PackageIcon className="size-4" />
My belongings
</NavLink>
Expand Down
2 changes: 1 addition & 1 deletion services/web/src/components/item/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { SingleItemPreviewCard } from "./single-item-preview-card";
export { ItemCardListServer as ItemCardList } from "./item-card-list-server";
Original file line number Diff line number Diff line change
@@ -1,35 +1,32 @@
"use client";

import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import type { SingleItem } from "@/types";
import { ClientRequester } from "@/utils/requester/client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { SingleItemPreviewCard } from "./single-item-preview-card";
import { SingleItemCard } from "./single-item-card";
import type { ItemListResponse } from "./types";

type Props = {
initialData: {
items: SingleItem[];
count: number;
nextCursor: string;
};
initialData: ItemListResponse;
initialSearchParams: string;
};

export function ItemPreviewCardList({ initialData }: Props) {
export function ItemCardListClient({
initialData,
initialSearchParams,
}: Props) {
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
queryKey: ["items"],
queryFn: ({ pageParam: cursor }) => {
const searchParams = new URLSearchParams();
searchParams.set("limit", "8");
const searchParams = new URLSearchParams(initialSearchParams);
if (cursor) {
searchParams.set("cursor", cursor);
}

return new ClientRequester().get<{
items: SingleItem[];
count: number;
nextCursor: string;
}>(`/items?${searchParams.toString()}`);
return new ClientRequester().get<ItemListResponse>(
`/items?${searchParams.toString()}`,
);
},
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand All @@ -51,7 +48,7 @@ export function ItemPreviewCardList({ initialData }: Props) {
<ul className="grid min-[480px]:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{data?.pages
.flatMap((page) => page.items)
.map((item) => <SingleItemPreviewCard key={item.id} item={item} />)}
.map((item) => <SingleItemCard key={item.id} item={item} />)}
</ul>
<div ref={bottomRef}></div>
{hasNextPage || (
Expand Down
42 changes: 42 additions & 0 deletions services/web/src/components/item/item-card-list-server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ItemStatus, SingleItem } from "@/types";
import { ServerRequester } from "@/utils/requester/server";
import { ItemCardListClient } from "./item-card-list-client";

type Props = {
limit?: number;
type?: "single" | "pack";
status?: ItemStatus;
sellerId?: number;
};

export async function ItemCardListServer({
limit = 8,
type,
status,
sellerId,
}: Props) {
const initialSearchParams = new URLSearchParams();
initialSearchParams.set("limit", String(limit));
if (type) {
initialSearchParams.set("type", type);
}
if (status !== undefined) {
initialSearchParams.set("status", String(status));
}
if (sellerId) {
initialSearchParams.set("seller_id", String(sellerId));
}

const initialData = await new ServerRequester().get<{
items: SingleItem[];
count: number;
nextCursor: string;
}>(`/items?${initialSearchParams.toString()}`);

return (
<ItemCardListClient
initialData={initialData}
initialSearchParams={initialSearchParams.toString()}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ type Props = {
item: SingleItem;
};

export function SingleItemPreviewCard({ item }: Props) {
export function SingleItemCard({ item }: Props) {
const { color: statusColor, text: statusText } = translateStatus(item.status);

return (
<Card className="group relative flex flex-col hover:bg-muted/30 transition-colors overflow-hidden">
<Badge className={cn("absolute top-4 left-4 uppercase", statusColor)}>
<Badge
className={cn("absolute top-4 left-4 uppercase z-10", statusColor)}
>
{statusText}
</Badge>
<div className="relative aspect-square">
{item.photo_urls[0] ? (
<Image
src={item.photo_urls[0]}
alt="A photo of this second-hand item"
fill
width={200}
height={200}
className="w-full"
/>
) : (
<div className="grid place-items-center h-full bg-muted">
Expand Down
7 changes: 7 additions & 0 deletions services/web/src/components/item/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { SingleItem } from "@/types";

export type ItemListResponse = {
items: SingleItem[];
count: number;
nextCursor: string;
};
2 changes: 1 addition & 1 deletion services/web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "next/server";

const NO_AUTH_REG_EXPS = [/^\/login$/, /^\/register$/];
const AUTH_REG_EXPS = [/^\/settings/];
const AUTH_REG_EXPS = [/^\/settings/, /^\/belongings/];

export async function middleware(request: NextRequest) {
const isAuthenticated = cookies().has("access_token");
Expand Down

0 comments on commit 21d915f

Please sign in to comment.