diff --git a/public/placeholder.png b/public/placeholder.png new file mode 100644 index 0000000..6e5e3dd Binary files /dev/null and b/public/placeholder.png differ diff --git a/src/app/(app)/(home)/[category]/[subcategory]/page.tsx b/src/app/(app)/(home)/[category]/[subcategory]/page.tsx index 6f29e43..765d2bb 100644 --- a/src/app/(app)/(home)/[category]/[subcategory]/page.tsx +++ b/src/app/(app)/(home)/[category]/[subcategory]/page.tsx @@ -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; } -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 ( - }> - - + ); }; diff --git a/src/app/(app)/(home)/[category]/page.tsx b/src/app/(app)/(home)/[category]/page.tsx index 6944eae..d4fedb2 100644 --- a/src/app/(app)/(home)/[category]/page.tsx +++ b/src/app/(app)/(home)/[category]/page.tsx @@ -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 ( -
-
-

Curated for you

- -
-
-
- -
-
- }> - - -
-
-
+
); }; diff --git a/src/modules/products/hooks/use-product-filters.ts b/src/modules/products/hooks/use-product-filters.ts index 6ae0573..4fcf141 100644 --- a/src/modules/products/hooks/use-product-filters.ts +++ b/src/modules/products/hooks/use-product-filters.ts @@ -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'), diff --git a/src/modules/products/server/procedures.ts b/src/modules/products/server/procedures.ts index 7364e05..426d63c 100644 --- a/src/modules/products/server/procedures.ts +++ b/src/modules/products/server/procedures.ts @@ -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, + }; + }), + }; }) }) \ No newline at end of file diff --git a/src/modules/products/ui/components/product-card.tsx b/src/modules/products/ui/components/product-card.tsx new file mode 100644 index 0000000..33bea63 --- /dev/null +++ b/src/modules/products/ui/components/product-card.tsx @@ -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 ( + +
+
+ name +
+
+

{name}

+ {/* TODO: Redirect to the user shop */} +
{}}> + {authorImageUrl && ( + {authorUsername} + )} +

{authorUsername}

+
+ {reviewCount > 0 && ( +
+ +

+ {reviewRating} ({reviewCount}) +

+
+ )} +
+
+
+

+ {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(price)} +

+
+
+
+ + ); +}; + +export const ProductCardSkeleton = () => ( +
+); diff --git a/src/modules/products/ui/components/product-list.tsx b/src/modules/products/ui/components/product-list.tsx index cfec877..8acf4ab 100644 --- a/src/modules/products/ui/components/product-list.tsx +++ b/src/modules/products/ui/components/product-list.tsx @@ -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 ( +
+ +

No products found

+
+ ); return ( -
- {data?.docs.map((product) => ( -
-

{product.name}

-

${product.price}

-
- ))} -
+ <> +
+ {data?.pages + .flatMap((page) => page.docs) + .map((product) => ( + + ))} +
+ +
+ {hasNextPage && ( + + )} +
+ ); }; export const ProductListSkeleton = () => { - return
Loading...
; + return ( +
+ {Array.from({ length: DEFAULT_LIMIT }).map((_, index) => ( + + ))} +
+ ); }; diff --git a/src/modules/products/ui/views/product-list-view.tsx b/src/modules/products/ui/views/product-list-view.tsx new file mode 100644 index 0000000..f44055e --- /dev/null +++ b/src/modules/products/ui/views/product-list-view.tsx @@ -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 ( +
+
+

Curated for you

+ +
+
+
+ +
+
+ }> + + +
+
+
+ ); +};