mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 13:38:34 +00:00
feat: add the product list ui
This commit is contained in:
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 { getQueryClient, trpc } from '@/trpc/server';
|
||||
import {
|
||||
ProductList,
|
||||
ProductListSkeleton,
|
||||
} from '@/modules/products/ui/components/product-list';
|
||||
import { loadProductFilters } from '@/modules/products/search-params';
|
||||
import { ProductListView } from '@/modules/products/ui/views/product-list-view';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
subcategory: string;
|
||||
}>;
|
||||
searchParams: Promise<SearchParams>;
|
||||
}
|
||||
|
||||
const Page = async ({ params }: Props) => {
|
||||
const Page = async ({ params, searchParams }: Props) => {
|
||||
const { subcategory } = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const filters = await loadProductFilters(searchParams);
|
||||
|
||||
void queryClient.prefetchQuery(
|
||||
trpc.products.getMany.queryOptions({
|
||||
category: subcategory,
|
||||
...filters,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense fallback={<ProductListSkeleton />}>
|
||||
<ProductList category={subcategory} />
|
||||
</Suspense>
|
||||
<ProductListView category={subcategory} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
import { Suspense } from 'react';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
|
||||
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 { ProductSort } from '@/modules/products/ui/components/product-sort';
|
||||
import { ProductListView } from '@/modules/products/ui/views/product-list-view';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
@@ -32,22 +26,7 @@ const Page = async ({ params, searchParams }: Props) => {
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<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>
|
||||
<ProductListView category={category} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 = {
|
||||
sort: parseAsStringLiteral(sortValues).withDefault('curated'),
|
||||
|
||||
@@ -2,11 +2,15 @@ import type { Sort, Where } from "payload";
|
||||
import { z } from "zod";
|
||||
|
||||
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";
|
||||
|
||||
export const productsRouter = createTRPCRouter({
|
||||
getMany: baseProcedure.input(z.object({
|
||||
cursor: z.number().default(1),
|
||||
limit: z.number().default(DEFAULT_LIMIT),
|
||||
category: z.string().nullable().optional(),
|
||||
minPrice: z.string().nullable().optional(),
|
||||
maxPrice: z.string().nullable().optional(),
|
||||
@@ -90,8 +94,18 @@ export const productsRouter = createTRPCRouter({
|
||||
depth: 1, // Populated "category" & "image"
|
||||
where,
|
||||
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';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { InboxIcon } from 'lucide-react';
|
||||
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useTRPC } from '@/trpc/client';
|
||||
import { DEFAULT_LIMIT } from '@/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { useProductFilters } from '../../hooks/use-product-filters';
|
||||
import { ProductCard, ProductCardSkeleton } from './product-card';
|
||||
|
||||
interface Props {
|
||||
category: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export const ProductList = ({ category }: Props) => {
|
||||
const [filters] = useProductFilters();
|
||||
|
||||
const trpc = useTRPC();
|
||||
const { data } = useSuspenseQuery(
|
||||
trpc.products.getMany.queryOptions({
|
||||
category,
|
||||
...filters,
|
||||
})
|
||||
);
|
||||
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useSuspenseInfiniteQuery(
|
||||
trpc.products.getMany.infiniteQueryOptions(
|
||||
{
|
||||
...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 (
|
||||
<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 key={product.id} className="border rounded-md bg-white p-4">
|
||||
<h2 className="text-xl font-medium">{product.name}</h2>
|
||||
<p>${product.price}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
{data?.pages
|
||||
.flatMap((page) => page.docs)
|
||||
.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
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 = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user