feat: add the product list ui

This commit is contained in:
2025-07-27 15:38:08 +07:00
parent fd464912ad
commit af2ab04148
8 changed files with 206 additions and 53 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -1,32 +1,32 @@
import { Suspense } from 'react'; import { SearchParams } from 'nuqs/server';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient, trpc } from '@/trpc/server'; import { getQueryClient, trpc } from '@/trpc/server';
import { import { loadProductFilters } from '@/modules/products/search-params';
ProductList, import { ProductListView } from '@/modules/products/ui/views/product-list-view';
ProductListSkeleton,
} from '@/modules/products/ui/components/product-list';
interface Props { interface Props {
params: Promise<{ params: Promise<{
subcategory: string; subcategory: string;
}>; }>;
searchParams: Promise<SearchParams>;
} }
const Page = async ({ params }: Props) => { const Page = async ({ params, searchParams }: Props) => {
const { subcategory } = await params; const { subcategory } = await params;
const queryClient = getQueryClient(); const queryClient = getQueryClient();
const filters = await loadProductFilters(searchParams);
void queryClient.prefetchQuery( void queryClient.prefetchQuery(
trpc.products.getMany.queryOptions({ trpc.products.getMany.queryOptions({
category: subcategory, category: subcategory,
...filters,
}) })
); );
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<ProductListSkeleton />}> <ProductListView category={subcategory} />
<ProductList category={subcategory} />
</Suspense>
</HydrationBoundary> </HydrationBoundary>
); );
}; };
+2 -23
View File
@@ -1,15 +1,9 @@
import type { SearchParams } from 'nuqs/server'; import type { SearchParams } from 'nuqs/server';
import { Suspense } from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient, trpc } from '@/trpc/server'; import { getQueryClient, trpc } from '@/trpc/server';
import {
ProductList,
ProductListSkeleton,
} from '@/modules/products/ui/components/product-list';
import { ProductFilters } from '@/modules/products/ui/components/product-filters';
import { loadProductFilters } from '@/modules/products/search-params'; import { loadProductFilters } from '@/modules/products/search-params';
import { ProductSort } from '@/modules/products/ui/components/product-sort'; import { ProductListView } from '@/modules/products/ui/views/product-list-view';
interface Props { interface Props {
params: Promise<{ params: Promise<{
@@ -32,22 +26,7 @@ const Page = async ({ params, searchParams }: Props) => {
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<div className="px-4 lg:px-12 py-8 flex flex-col gap-4"> <ProductListView category={category} />
<div className="flex flex-col lg:flex-row lg:items-center gap-y-2 lg:gap-y-0 justify-between">
<p className='text-2xl font-medium'>Curated for you</p>
<ProductSort />
</div>
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-8 gap-y-6 gap-x-12">
<div className="lg:col-span-2 xl:col-span-2">
<ProductFilters />
</div>
<div className="lg:col-span-4 xl:col-span-6">
<Suspense fallback={<ProductListSkeleton />}>
<ProductList category={category} />
</Suspense>
</div>
</div>
</div>
</HydrationBoundary> </HydrationBoundary>
); );
}; };
@@ -1,6 +1,6 @@
import { useQueryStates, parseAsString, parseAsArrayOf, parseAsStringLiteral } from 'nuqs'; import { useQueryStates, parseAsString, parseAsArrayOf, parseAsStringLiteral } from 'nuqs';
const sortValues = ['curated', 'trending', 'hot_and_new']; const sortValues = ['curated', 'trending', 'hot_and_new'] as const;
const params = { const params = {
sort: parseAsStringLiteral(sortValues).withDefault('curated'), sort: parseAsStringLiteral(sortValues).withDefault('curated'),
+16 -2
View File
@@ -2,11 +2,15 @@ import type { Sort, Where } from "payload";
import { z } from "zod"; import { z } from "zod";
import { baseProcedure, createTRPCRouter } from "@/trpc/init"; import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { Category } from "@/payload-types"; import { DEFAULT_LIMIT } from "@/constants";
import { Category, Media } from "@/payload-types";
import { sortValues } from "../search-params"; import { sortValues } from "../search-params";
export const productsRouter = createTRPCRouter({ export const productsRouter = createTRPCRouter({
getMany: baseProcedure.input(z.object({ getMany: baseProcedure.input(z.object({
cursor: z.number().default(1),
limit: z.number().default(DEFAULT_LIMIT),
category: z.string().nullable().optional(), category: z.string().nullable().optional(),
minPrice: z.string().nullable().optional(), minPrice: z.string().nullable().optional(),
maxPrice: z.string().nullable().optional(), maxPrice: z.string().nullable().optional(),
@@ -90,8 +94,18 @@ export const productsRouter = createTRPCRouter({
depth: 1, // Populated "category" & "image" depth: 1, // Populated "category" & "image"
where, where,
sort, sort,
page: input.cursor,
limit: input.limit,
}); });
return data; return {
...data,
docs: data.docs.map((doc) => {
return {
...doc,
image: doc.images as Media || null,
};
}),
};
}) })
}) })
@@ -0,0 +1,79 @@
import { StarIcon } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
interface ProductCardProps {
id: string;
name: string;
imageUrl?: string | null;
authorUsername: string;
authorImageUrl?: string | null;
reviewRating: number;
reviewCount: number;
price: number;
}
export const ProductCard = ({
id,
name,
imageUrl,
authorUsername,
authorImageUrl,
reviewRating,
reviewCount,
price,
}: ProductCardProps) => {
return (
<Link href={`/products/${id}`}>
<div className="hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] border rounded-md bg-white overflow-hidden h-full flex flex-col">
<div className="relative aspect-square">
<Image
alt="name"
fill
src={imageUrl || '/placeholder.png'}
className="object-cover"
/>
</div>
<div className="p-4 border-y flex flex-col gap-3 flex-1">
<h2 className="text-lg font-medium line-clamp-4">{name}</h2>
{/* TODO: Redirect to the user shop */}
<div className="flex items-center gap-2" onClick={() => {}}>
{authorImageUrl && (
<Image
alt={authorUsername}
src={authorImageUrl}
width={16}
height={16}
className="rounded-full border shrink-0 size-[16px]"
/>
)}
<p className="text-sm underline font-medium">{authorUsername}</p>
</div>
{reviewCount > 0 && (
<div className="flex items-center gap-1">
<StarIcon className="size-3.5 fill-black" />
<p className="text-sm font-medium">
{reviewRating} ({reviewCount})
</p>
</div>
)}
</div>
<div className="p-4">
<div className="relative px-2 py-1 border bg-pink-400 w-fit">
<p className="text-sm font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(price)}
</p>
</div>
</div>
</div>
</Link>
);
};
export const ProductCardSkeleton = () => (
<div className="w-full aspect-3/4 bg-neutral-200 rounded-lg animate-pulse" />
);
@@ -1,37 +1,88 @@
'use client'; 'use client';
import { useSuspenseQuery } from '@tanstack/react-query'; import { InboxIcon } from 'lucide-react';
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client'; import { useTRPC } from '@/trpc/client';
import { DEFAULT_LIMIT } from '@/constants';
import { Button } from '@/components/ui/button';
import { useProductFilters } from '../../hooks/use-product-filters'; import { useProductFilters } from '../../hooks/use-product-filters';
import { ProductCard, ProductCardSkeleton } from './product-card';
interface Props { interface Props {
category: string; category?: string;
} }
export const ProductList = ({ category }: Props) => { export const ProductList = ({ category }: Props) => {
const [filters] = useProductFilters(); const [filters] = useProductFilters();
const trpc = useTRPC(); const trpc = useTRPC();
const { data } = useSuspenseQuery( const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
trpc.products.getMany.queryOptions({ useSuspenseInfiniteQuery(
category, trpc.products.getMany.infiniteQueryOptions(
...filters, {
}) ...filters,
); category,
limit: DEFAULT_LIMIT,
},
{
getNextPageParam: (lastPage) => {
return lastPage.docs.length > 0 ? lastPage.nextPage : undefined;
},
}
)
);
if (data?.pages?.[0]?.docs.length === 0)
return (
<div className="border border-black border-dashed flex items-center justify-center p-8 flex-col gap-y-4 bg-white w-full rounded-lg">
<InboxIcon />
<p className="text-base font-medium">No products found</p>
</div>
);
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4"> <>
{data?.docs.map((product) => ( <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<div key={product.id} className="border rounded-md bg-white p-4"> {data?.pages
<h2 className="text-xl font-medium">{product.name}</h2> .flatMap((page) => page.docs)
<p>${product.price}</p> .map((product) => (
</div> <ProductCard
))} key={product.id}
</div> id={product.id}
name={product.name}
imageUrl={product.image?.url}
authorUsername="nezumi"
authorImageUrl={undefined}
reviewRating={3}
reviewCount={5}
price={product.price}
/>
))}
</div>
<div className="flex justify-center pt-8">
{hasNextPage && (
<Button
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
className="font-medium disabled:opacity-50 text-base bg-white"
variant="elevated"
>
Load More
</Button>
)}
</div>
</>
); );
}; };
export const ProductListSkeleton = () => { export const ProductListSkeleton = () => {
return <div>Loading...</div>; return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{Array.from({ length: DEFAULT_LIMIT }).map((_, index) => (
<ProductCardSkeleton key={index} />
))}
</div>
);
}; };
@@ -0,0 +1,30 @@
import { Suspense } from 'react';
import { ProductFilters } from '../components/product-filters';
import { ProductSort } from '../components/product-sort';
import { ProductList, ProductListSkeleton } from '../components/product-list';
interface Props {
category?: string;
};
export const ProductListView = ({ category }: Props) => {
return (
<div className="px-4 lg:px-12 py-8 flex flex-col gap-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-y-2 lg:gap-y-0 justify-between">
<p className="text-2xl font-medium">Curated for you</p>
<ProductSort />
</div>
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-8 gap-y-6 gap-x-12">
<div className="lg:col-span-2 xl:col-span-2">
<ProductFilters />
</div>
<div className="lg:col-span-4 xl:col-span-6">
<Suspense fallback={<ProductListSkeleton />}>
<ProductList category={category} />
</Suspense>
</div>
</div>
</div>
);
};