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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user