From 48490ea1f2df9aa2712ec5f63baba7614a77fb61 Mon Sep 17 00:00:00 2001 From: Harold Graphs <44398016+haroldcastillo@users.noreply.github.com> Date: Sat, 22 Nov 2025 00:28:44 +0800 Subject: [PATCH 01/27] fix(auth): remove process.env.JWT_SECRET_KEY check causing login failure Accessing JWT_SECRET_KEY in client-side code is not suggested. --- apps/admin/src/app/login/components/user-auth-form.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/admin/src/app/login/components/user-auth-form.tsx b/apps/admin/src/app/login/components/user-auth-form.tsx index 96bd55be..7c0d5c19 100644 --- a/apps/admin/src/app/login/components/user-auth-form.tsx +++ b/apps/admin/src/app/login/components/user-auth-form.tsx @@ -121,13 +121,6 @@ function TryComponents({ isLoading, setIsLoading, setFetchedOTP }) { async function onSubmitEmail() { try { setIsLoading(true) - - if (!process.env.JWT_SECRET_KEY) { - console.error('JWT secret key is missing') - setIsLoading(false) - return - } - const response = await fetch('/api/auth/otp/email/try', { method: 'POST', body: JSON.stringify({ email }), From 20fd42e348a3aee52896b4196a32653feadb82e0 Mon Sep 17 00:00:00 2001 From: Harold Graphs <44398016+haroldcastillo@users.noreply.github.com> Date: Sat, 22 Nov 2025 00:34:36 +0800 Subject: [PATCH 02/27] feat(config): add /admin route matcher --- apps/admin/src/middleware.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts index 9948b619..047f5e6c 100644 --- a/apps/admin/src/middleware.ts +++ b/apps/admin/src/middleware.ts @@ -64,5 +64,6 @@ export const config = { '/codes/:path*', '/users/:path*', '/api/:path*', + '/admin/:path*', ], } From cb600d2c0b3d65e5bc3d235fb6492200e9183a24 Mon Sep 17 00:00:00 2001 From: Harold Graphs <44398016+haroldcastillo@users.noreply.github.com> Date: Sat, 22 Nov 2025 02:04:43 +0800 Subject: [PATCH 03/27] fix(theme-toggle): prevent hydration mismatch by delaying mount --- apps/storefront/src/components/native/nav/parent.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/storefront/src/components/native/nav/parent.tsx b/apps/storefront/src/components/native/nav/parent.tsx index d349219c..637a0ede 100644 --- a/apps/storefront/src/components/native/nav/parent.tsx +++ b/apps/storefront/src/components/native/nav/parent.tsx @@ -1,14 +1,18 @@ 'use client' +// components import { CommandMenu } from '@/components/composites/command' import { MobileNav } from '@/components/native//nav/mobile' import { UserNav } from '@/components/native//nav/user' import { MainNav } from '@/components/native/nav/desktop' import { Button } from '@/components/ui/button' +// utils import { useAuthenticated } from '@/hooks/useAuthentication' +// assets import { LogInIcon, MoonIcon, ShoppingBasketIcon, SunIcon } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' +import { useEffect, useState } from 'react' export default function Header() { const { authenticated } = useAuthenticated() @@ -54,6 +58,13 @@ function LoginDialog() { function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) return null return ( + + +
+
+ + { + if (value !== formik.values.sort) { + formik.setFieldValue('sort', value) + } else { + formik.setFieldValue('sort', '') + } + }} + /> +
+
+ + + formik.setFieldValue('categories', value) + } + /> +
+
+ + + formik.setFieldValue('minPrice', value) + } + maxOnChange={(value) => + formik.setFieldValue('maxPrice', value) + } + isError={ + formik.touched.minPrice || formik.touched.maxPrice + ? !!( + formik.errors.minPrice || formik.errors.maxPrice + ) + : false + } + errorMessage={ + formik.errors.minPrice || formik.errors.maxPrice || '' + } + /> +
+
+ + formik.setFieldValue('brand', value)} + /> +
+ + + +
+
+ + ) +} diff --git a/apps/storefront/src/app/(store)/(routes)/products/components/options.tsx b/apps/storefront/src/app/(store)/(routes)/products/components/options.tsx index 8118ce4b..03877fe2 100644 --- a/apps/storefront/src/app/(store)/(routes)/products/components/options.tsx +++ b/apps/storefront/src/app/(store)/(routes)/products/components/options.tsx @@ -1,5 +1,7 @@ 'use client' +// utils +// Components import { Button } from '@/components/ui/button' import { Command, @@ -7,7 +9,9 @@ import { CommandGroup, CommandInput, CommandItem, + CommandList, } from '@/components/ui/command' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Popover, @@ -22,78 +26,30 @@ import { SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { useDebounce } from '@/hooks/useDebounce' +import { useUpdateQueryParam } from '@/hooks/useUpdateQueryParam' +// utils import { cn, isVariableValid } from '@/lib/utils' import { slugify } from '@persepolis/slugify' +import { max } from 'date-fns' +// Icons import { Check, ChevronsUpDown } from 'lucide-react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import React, { useEffect } from 'react' -export function SortBy({ initialData }) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - - const [value, setValue] = React.useState('featured') - - useEffect(() => { - if (isVariableValid(initialData)) setValue(initialData) - }, [initialData]) - - return ( - - ) +type SortByProps = { + value?: string + onChange?: (value: string) => void } -export function CategoriesCombobox({ categories, initialCategory }) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - +export function SortBy({ value, onChange }: SortByProps) { const [open, setOpen] = React.useState(false) - const [value, setValue] = React.useState('') - - function getCategoryTitle() { - for (const category of categories) { - if (slugify(category.title) === slugify(value)) return category.title - } + const valueMap: Record = { + most_expensive: 'Most Expensive', + least_expensive: 'Least Expensive', + title_asc: 'Title Ascending', + title_desc: 'Title Descending', } - - useEffect(() => { - setValue(initialCategory) - }, [initialCategory]) - return ( @@ -101,80 +57,134 @@ export function CategoriesCombobox({ categories, initialCategory }) { variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between" + className="w-full flex justify-between overflow-hidden " > - {value ? getCategoryTitle() : 'Select category...'} - + + {value ? valueMap[value] : 'Sort by...'} + - + - - No category found. - - {categories.map((category) => ( - { - const current = new URLSearchParams( - Array.from(searchParams.entries()) - ) - - if (currentValue === value) { - current.delete('category') - setValue('') - } else { - current.set('category', currentValue) - setValue(currentValue) - } - - // cast to string - const search = current.toString() - // or const query = `${'?'.repeat(search.length && 1)}${search}`; - const query = search ? `?${search}` : '' - - router.replace(`${pathname}${query}`, { - scroll: false, - }) - - setOpen(false) - }} - > - - {category.title} - - ))} - + + No category found. + {Object.entries(valueMap).map(([key, label]) => { + const isSelected = value === key + return ( + { + onChange?.(key) + setOpen(false) + }} + className={`flex align-start content-start hover:opacity-100 cursor-pointer ${isSelected ? 'opacity-100' : 'opacity-50'}`} + > + + {label} + + ) + })} + ) } -export function BrandCombobox({ brands, initialBrand }) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() +export function PriceRange({ + minValue, + maxValue, + minOnChange, + maxOnChange, + isError, + errorMessage, +}: { + minValue?: string + maxValue?: string + minOnChange?: (value: string) => void + maxOnChange?: (value: string) => void + isError?: boolean + errorMessage?: any +}) { + return ( +
+
+ { + if (['e', 'E', '+', '-'].includes(e.key)) { + e.preventDefault() + } + }} + max={maxValue ? parseFloat(maxValue) : 1000000} + onChange={(e) => minOnChange?.(e.target.value)} + /> + - + { + if (['e', 'E', '+', '-'].includes(e.key)) { + e.preventDefault() + } + }} + max={1000000} + onChange={(e) => maxOnChange?.(e.target.value)} + /> +
+ {isError && ( +

{errorMessage}

+ )} +
+ ) +} +export function CategoriesCombobox({ categories, initialCategory, onChange }) { const [open, setOpen] = React.useState(false) - const [value, setValue] = React.useState('') + const [value, setValue] = React.useState([]) - function getBrandTitle() { - for (const brand of brands) { - if (slugify(brand.title) === slugify(value)) return brand.title + function getCategoryTitle() { + const selectedCategories = categories.filter((category) => + value.includes(slugify(category.title)) + ) + return selectedCategories.map((cat) => cat.title).join(', ') + } + + function toggleListItem(slug: string) { + let updatedList = [...value] + if (updatedList.includes(slug)) { + updatedList = updatedList.filter((item) => item !== slug) + } else { + updatedList.push(slug) } + setValue(updatedList) + + onChange?.(updatedList.join('+')) } useEffect(() => { - setValue(initialBrand) - }, [initialBrand]) + // check if initialCategory is defined + if (!initialCategory) return + + const initialSlugs = initialCategory + .split('+') + .map((title) => slugify(title)) + + setValue(initialSlugs) + }, [initialCategory]) return ( @@ -183,63 +193,109 @@ export function BrandCombobox({ brands, initialBrand }) { variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between" + className="w-full flex justify-between overflow-hidden " > - {value ? getBrandTitle() : 'Select brand...'} + + {value.length > 0 ? getCategoryTitle() : 'Select category...'} + - + - - No brand found. - - {brands.map((brand) => ( - { - const current = new URLSearchParams( - Array.from(searchParams.entries()) - ) - - if (currentValue === value) { - current.delete('brand') - setValue('') - } else { - current.set('brand', currentValue) - setValue(currentValue) - } - - // cast to string - const search = current.toString() - // or const query = `${'?'.repeat(search.length && 1)}${search}`; - const query = search ? `?${search}` : '' - - router.replace(`${pathname}${query}`, { - scroll: false, - }) - - setOpen(false) - }} - > - - {brand.title} - - ))} - + + + No category found. + {categories.map((category) => { + const slug = slugify(category.title) + const isSelected = value.includes(slug) + return ( + { + toggleListItem(slug) + }} + className={`flex align-start content-start hover:opacity-100 cursor-pointer ${isSelected ? 'opacity-100' : 'opacity-50'}`} + > + + {category.title} + + ) + })} + ) } +export function BrandCombobox({ brands, value, onChange }) { + const [open, setOpen] = React.useState(false) + + function getBrandTitle() { + for (const brand of brands) { + if (brand.id === value) return brand.title + } + } + + return ( + <> + + + + + + + + + No brand found. + + + {brands.map((brand) => { + const isSelected = value === brand.id + return ( + { + onChange?.(brand.id) + setOpen(false) + }} + className={`flex align-start content-start hover:opacity-100 cursor-pointer ${isSelected ? 'opacity-100' : 'opacity-50'}`} + > + + {brand.title} + + ) + })} + + + + + + + ) +} + export function AvailableToggle({ initialData }) { const router = useRouter() const pathname = usePathname() @@ -282,3 +338,34 @@ export function AvailableToggle({ initialData }) { ) } + +export function SearchInput({ + initialData, + className, + ...props +}: { + initialData?: string + className?: string + [key: string]: any +}) { + const { updateQuery } = useUpdateQueryParam() + + const [value, setValue] = React.useState(initialData || '') + const debouncedValue = useDebounce(value, 500) + + // Update search param when debounced value changes + useEffect(() => { + updateQuery('search', debouncedValue) + }, [debouncedValue]) + + return ( + setValue(e.target.value)} + {...props} + /> + ) +} diff --git a/apps/storefront/src/app/(store)/(routes)/products/page.tsx b/apps/storefront/src/app/(store)/(routes)/products/page.tsx index f494e595..81d69fd2 100644 --- a/apps/storefront/src/app/(store)/(routes)/products/page.tsx +++ b/apps/storefront/src/app/(store)/(routes)/products/page.tsx @@ -4,37 +4,53 @@ import { Separator } from '@/components/native/separator' import prisma from '@/lib/prisma' import { isVariableValid } from '@/lib/utils' -import { - AvailableToggle, - BrandCombobox, - CategoriesCombobox, - SortBy, -} from './components/options' +import { FilterButton } from './components/filter-button' +import { SearchInput } from './components/options' export default async function Products({ searchParams }) { - const { sort, isAvailable, brand, category, page = 1 } = searchParams ?? null + const { + sort, + isAvailable, + brand, + category, + page = 1, + search, + minPrice, + maxPrice, + } = searchParams ?? null const orderBy = getOrderBy(sort) const brands = await prisma.brand.findMany() const categories = await prisma.category.findMany() + + const categoryList = category ? category.split('+').filter(Boolean) : null const products = await prisma.product.findMany({ where: { + title: { + contains: search, + mode: 'insensitive', + }, isAvailable: isAvailable == 'true' || sort ? true : undefined, brand: { - title: { - contains: brand, - mode: 'insensitive', - }, + id: brand || undefined, }, - categories: { - some: { - title: { - contains: category, - mode: 'insensitive', - }, - }, + price: { + gte: minPrice ? Number(minPrice) : undefined, + lte: maxPrice ? Number(maxPrice) : undefined, }, + categories: categoryList + ? { + some: { + OR: categoryList.map((c) => ({ + title: { + contains: c, + mode: 'insensitive', + }, + })), + }, + } + : undefined, }, orderBy, skip: (page - 1) * 12, @@ -50,16 +66,22 @@ export default async function Products({ searchParams }) { + + + + } /> -
- - - - -
+ {isVariableValid(products) ? ( @@ -74,13 +96,6 @@ function getOrderBy(sort) { let orderBy switch (sort) { - case 'featured': - orderBy = { - orders: { - _count: 'desc', - }, - } - break case 'most_expensive': orderBy = { price: 'desc', @@ -92,6 +107,17 @@ function getOrderBy(sort) { } break + case 'title_asc': + orderBy = { + title: 'asc', + } + break + + case 'title_desc': + orderBy = { + title: 'desc', + } + break default: orderBy = { orders: { diff --git a/apps/storefront/src/app/api/products/route.ts b/apps/storefront/src/app/api/products/route.ts index 98bc2378..d7a0a800 100644 --- a/apps/storefront/src/app/api/products/route.ts +++ b/apps/storefront/src/app/api/products/route.ts @@ -3,14 +3,74 @@ import { NextResponse } from 'next/server' export async function GET(req: Request) { try { - const products = await prisma.product.findMany({ - include: { - brand: true, - categories: true, + const { searchParams } = new URL(req.url) + + const search = searchParams.get('search') || '' + const brand = searchParams.get('brand') || undefined + const category = searchParams.get('category') || null + const page = Number(searchParams.get('page') || 1) + const minPrice = searchParams.get('minPrice') + const maxPrice = searchParams.get('maxPrice') + const isAvailable = searchParams.get('isAvailable') + const sort = searchParams.get('sort') // optional + + // Split category to ["cat1", "cat2"] + const categoryList = category ? category.split('+').filter(Boolean) : null + + // Build WHERE clause once + const where = { + title: { + contains: search, + mode: 'insensitive' as const, }, - }) + isAvailable: isAvailable == 'true' || sort ? true : undefined, + brand: { + id: brand || undefined, + }, + price: { + gte: minPrice ? Number(minPrice) : undefined, + lte: maxPrice ? Number(maxPrice) : undefined, + }, + categories: categoryList + ? { + some: { + OR: categoryList.map((c) => ({ + title: { + contains: c, + mode: 'insensitive' as const, + }, + })), + }, + } + : undefined, + } + + const pageSize = 12 + + // Fetch products + total count in parallel + const [products, totalItems] = await Promise.all([ + prisma.product.findMany({ + where, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + brand: true, + categories: true, + }, + }), + prisma.product.count({ where }), + ]) - return NextResponse.json(products) + const totalPages = Math.ceil(totalItems / pageSize) + const pagesLeft = Math.max(totalPages - page, 0) + + return NextResponse.json({ + products, + totalItems, + totalPages, + pagesLeft, + currentPage: page, + }) } catch (error) { console.error('[PRODUCT_GET]', error) return new NextResponse('Internal error', { status: 500 }) diff --git a/apps/storefront/src/components/native/Product.tsx b/apps/storefront/src/components/native/Product.tsx index 6c87baeb..203f885c 100644 --- a/apps/storefront/src/components/native/Product.tsx +++ b/apps/storefront/src/components/native/Product.tsx @@ -1,13 +1,6 @@ import { ImageSkeleton } from '@/components/native/icons' import { Badge } from '@/components/ui/badge' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' +import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { ProductWithIncludes } from '@/types/prisma' import Image from 'next/image' import Link from 'next/link' diff --git a/apps/storefront/src/components/native/heading.tsx b/apps/storefront/src/components/native/heading.tsx index 603042af..f926f2d6 100644 --- a/apps/storefront/src/components/native/heading.tsx +++ b/apps/storefront/src/components/native/heading.tsx @@ -1,13 +1,23 @@ interface HeadingProps { title: string description: string + specialDiv?: React.ReactNode } -export const Heading: React.FC = ({ title, description }) => { +export const Heading: React.FC = ({ + title, + description, + specialDiv, +}) => { return ( -
-

{title}

-

{description}

+
+
+

+ {title} +

+

{description}

+
+ {specialDiv}
) } diff --git a/apps/storefront/src/components/ui/command.tsx b/apps/storefront/src/components/ui/command.tsx index 40021027..c466c341 100644 --- a/apps/storefront/src/components/ui/command.tsx +++ b/apps/storefront/src/components/ui/command.tsx @@ -1,12 +1,11 @@ 'use client' -import * as React from 'react' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { cn } from '@/lib/utils' import { DialogProps } from '@radix-ui/react-dialog' -import { SearchIcon } from 'lucide-react' import { Command as CommandPrimitive } from 'cmdk' - -import { cn } from '@/lib/utils' -import { Dialog, DialogContent } from '@/components/ui/dialog' +import { SearchIcon } from 'lucide-react' +import * as React from 'react' const Command = React.forwardRef< React.ElementRef, @@ -28,7 +27,7 @@ interface CommandDialogProps extends DialogProps {} const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( - + {children} @@ -120,7 +119,7 @@ const CommandItem = React.forwardRef< (value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/apps/storefront/src/hooks/useUpdateQueryParam.tsx b/apps/storefront/src/hooks/useUpdateQueryParam.tsx new file mode 100644 index 00000000..c2c98b13 --- /dev/null +++ b/apps/storefront/src/hooks/useUpdateQueryParam.tsx @@ -0,0 +1,49 @@ +'use client' + +import { usePathname, useRouter, useSearchParams } from 'next/navigation' + +export function useUpdateQueryParam() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + function updateQuery(key: string, value?: string | null) { + const current = new URLSearchParams(window.location.search) + + const cleaned = typeof value === 'string' ? value.trim() : (value ?? '') + + if (cleaned) { + current.set(key, cleaned) + } else { + current.delete(key) + } + + const search = current.toString() + const query = search ? `?${search}` : '' + + router.replace(`${pathname}${query}`, { scroll: false }) + } + + function clearAllQueries() { + // Navigate to pathname without any query string + router.replace(pathname, { scroll: false }) + } + function updateMany(updates: Record) { + const current = new URLSearchParams(searchParams.toString()) + + for (const key in updates) { + const raw = updates[key] + const cleaned = typeof raw === 'string' ? raw.trim() : (raw ?? '') + + if (cleaned) { + current.set(key, cleaned) + } else { + current.delete(key) + } + } + + const qs = current.toString() + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }) + } + + return { updateQuery, clearAllQueries, updateMany } +} From 0bb8ae5c1e9b7b9363da093734640946d59f59d3 Mon Sep 17 00:00:00 2001 From: Harold Graphs <44398016+haroldcastillo@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:57:09 +0800 Subject: [PATCH 06/27] feat(install):added shadcn pagination component --- apps/storefront/bun.lockb | Bin 312882 -> 313886 bytes apps/storefront/package.json | 2 +- .../app/(store)/(routes)/products/page.tsx | 25 ++++ .../src/components/ui/pagination.tsx | 116 ++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 apps/storefront/src/components/ui/pagination.tsx diff --git a/apps/storefront/bun.lockb b/apps/storefront/bun.lockb index f6fb93c26c4bfeb7125687c933715b2ef9cd7564..c4c7b40d9374fe37fbf9655756add39302ed672e 100644 GIT binary patch delta 32418 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 MQYL-ovS7&n0p@U}TmS$7 delta 31929 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 diff --git a/apps/storefront/package.json b/apps/storefront/package.json index 224ded9d..05b374f6 100644 --- a/apps/storefront/package.json +++ b/apps/storefront/package.json @@ -40,7 +40,7 @@ "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", diff --git a/apps/storefront/src/app/(store)/(routes)/products/page.tsx b/apps/storefront/src/app/(store)/(routes)/products/page.tsx index 81d69fd2..ed2e3e18 100644 --- a/apps/storefront/src/app/(store)/(routes)/products/page.tsx +++ b/apps/storefront/src/app/(store)/(routes)/products/page.tsx @@ -1,6 +1,15 @@ import { ProductGrid, ProductSkeletonGrid } from '@/components/native/Product' import { Heading } from '@/components/native/heading' import { Separator } from '@/components/native/separator' +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination' import prisma from '@/lib/prisma' import { isVariableValid } from '@/lib/utils' @@ -88,6 +97,22 @@ export default async function Products({ searchParams }) { ) : ( )} + + + + + + + 1 + + + + + + + + + ) } diff --git a/apps/storefront/src/components/ui/pagination.tsx b/apps/storefront/src/components/ui/pagination.tsx new file mode 100644 index 00000000..484e5ce7 --- /dev/null +++ b/apps/storefront/src/components/ui/pagination.tsx @@ -0,0 +1,116 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +
} /> - {isVariableValid(products) ? ( ) : ( )} - - - - - - - 1 - - - - - - - - - + ) } @@ -154,3 +110,90 @@ function getOrderBy(sort) { return orderBy } + +export async function getPaginatedProducts({ + search, + isAvailable, + sort, + brand, + minPrice, + maxPrice, + categoryList, + page = 1, + pageSize = 10, + orderBy, +}: { + search?: string + isAvailable?: string + sort?: string + brand?: string + minPrice?: string + maxPrice?: string + categoryList?: string[] + page?: number + pageSize?: number + orderBy?: any +}) { + const where = { + title: search + ? { + contains: search, + mode: 'insensitive' as const, + } + : undefined, + + isAvailable: isAvailable == 'true' || sort ? true : undefined, + + brand: brand + ? { + id: brand, + } + : undefined, + + price: + minPrice || maxPrice + ? { + gte: minPrice ? Number(minPrice) : undefined, + lte: maxPrice ? Number(maxPrice) : undefined, + } + : undefined, + + categories: categoryList + ? { + some: { + OR: categoryList.map((c) => ({ + title: { + contains: c, + mode: 'insensitive' as const, + }, + })), + }, + } + : undefined, + } + + const totalCount = await prisma.product.count({ where }) + + const totalPages = Math.ceil(totalCount / pageSize) + + const products = await prisma.product.findMany({ + where, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + brand: true, + categories: true, + }, + }) + + return { + products, + pagination: { + totalCount, + totalPages, + currentPage: page, + pageSize, + }, + } +} diff --git a/apps/storefront/src/components/ui/pagination.tsx b/apps/storefront/src/components/ui/pagination.tsx index 484e5ce7..738607fc 100644 --- a/apps/storefront/src/components/ui/pagination.tsx +++ b/apps/storefront/src/components/ui/pagination.tsx @@ -1,116 +1,192 @@ -import * as React from "react" -import { cn } from "@/lib/utils" -import { ButtonProps, buttonVariants } from "@/components/ui/button" -import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +'use client' -const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( -