npx create-next-app@latest store
npm run dev
- in globals.css remove all code after directives
- page.tsx
function HomePage() {
return <h1 className="text-3xl">HomePage</h1>
}
export default HomePage
- layout.tsx
export const metadata: Metadata = {
title: 'Next Store',
description: 'A nifty store built with Next.js',
}
- get a hold of the README.MD
-
about
-
admin
-
cart
-
favorites
-
orders
-
products
-
reviews
-
new file - pageName/page.tsx
function AboutPage() {
return <div>AboutPage</div>
}
export default AboutPage
npx shadcn-ui@latest init
- New York
- Zinc
- CSS variables:YES
npx shadcn-ui@latest add button
import { Button } from '@/components/ui/button'
function HomePage() {
return (
<div>
<h1 className="text-3xl">HomePage</h1>
<Button variant="outline" size="lg" className="capitalize m-8">
Click me
</Button>
</div>
)
}
export default HomePage
npx shadcn-ui@latest add breadcrumb card checkbox dropdown-menu input label popover select separator table textarea toast skeleton carousel
- components
- ui
- cart
- form
- global
- home
- navbar
- products
- single-product
-
create
-
navbar
- CartButton
- DarkMode
- LinksDropdown
- Logo
- Navbar
- NavSearch
- SignOutLink
- UserIcon
- create globals/Container.tsx
import { cn } from '@/lib/utils'
function Container({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<div className={cn('mx-auto max-w-6xl xl:max-w-7xl px-8', className)}>
{children}
</div>
)
}
export default Container
cn() function takes any number of arguments (which are expected to be strings or falsy values), filters out any falsy values (like false, null, undefined, 0, NaN, and empty string ""), and then joins the remaining strings into a single string with spaces in between.
import Logo from './Logo'
import LinksDropdown from './LinksDropdown'
import DarkMode from './DarkMode'
import CartButton from './CartButton'
import NavSearch from './NavSearch'
import Container from '../global/Container'
function Navbar() {
return (
<nav className="border-b ">
<Container className="flex flex-col sm:flex-row sm:justify-between sm:items-center flex-wrap gap-4 py-8">
<Logo />
<NavSearch />
<div className="flex gap-4 items-center ">
<CartButton />
<DarkMode />
<LinksDropdown />
</div>
</Container>
</nav>
)
}
export default Navbar
- layout.tsx
import Navbar from '@/components/navbar/Navbar'
import Container from '@/components/global/Container'
return (
<html lang="en">
<body className={inter.className}>
<Navbar />
<Container className="py-20">{children}</Container>
</body>
</html>
)
npm install react-icons
Logo.tsx
import Link from 'next/link'
import { Button } from '../ui/button'
import { VscCode } from 'react-icons/vsc'
function Logo() {
return (
<Button size="icon" asChild>
<Link href="/">
<VscCode className="w-6 h-6" />
</Link>
</Button>
)
}
export default Logo
import { Input } from '../ui/input'
function NavSearch() {
return (
<Input
type="search"
placeholder="search product..."
className="max-w-xs dark:bg-muted "
/>
)
}
export default NavSearch
import { Button } from '@/components/ui/button'
import { LuShoppingCart } from 'react-icons/lu'
import Link from 'next/link'
async function CartButton() {
// temp
const numItemsInCart = 9
return (
<Button
asChild
variant="outline"
size="icon"
className="flex justify-center items-center relative"
>
<Link href="/cart">
<LuShoppingCart />
<span className="absolute -top-3 -right-3 bg-primary text-white rounded-full h-6 w-6 flex items-center justify-center text-xs">
{numItemsInCart}
</span>
</Link>
</Button>
)
}
export default CartButton
- replace css variables in in globals.css
- create app/providers.tsx
'use client'
function Providers({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
export default Providers
layout.tsx
import Providers from './providers'
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<Navbar />
<Container className="py-20">{children}</Container>
</Providers>
</body>
</html>
)
npm install next-themes
- create app/theme-provider.tsx
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
providers.tsx
'use client'
import { ThemeProvider } from './theme-provider'
function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}
export default Providers
- make sure you export as default !!!
'use client'
import * as React from 'react'
import { MoonIcon, SunIcon } from '@radix-ui/react-icons'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export default function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
- create utils/links.ts
type NavLink = {
href: string
label: string
}
export const links: NavLink[] = [
{ href: '/', label: 'home' },
{ href: '/about', label: 'about' },
{ href: '/products', label: 'products' },
{ href: '/favorites', label: 'favorites' },
{ href: '/cart', label: 'cart' },
{ href: '/orders', label: 'orders' },
]
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { LuAlignLeft } from 'react-icons/lu'
import Link from 'next/link'
import { Button } from '../ui/button'
import { links } from '@/utils/links'
function LinksDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex gap-4 max-w-[100px]">
<LuAlignLeft className="w-6 h-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="start" sideOffset={10}>
{links.map((link) => {
return (
<DropdownMenuItem key={link.href}>
<Link href={link.href} className="capitalize w-full">
{link.label}
</Link>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default LinksDropdown
- create account and organization
- create project
- setup password in .env (optional)
- add .env to .gitignore !!!
- it will take few minutes
- install prisma vs-code extension
Prisma ORM is a database toolkit that simplifies database access in web applications. It allows developers to interact with databases using a type-safe and auto-generated API, making database operations easier and more secure.
- Prisma server: A standalone infrastructure component sitting on top of your database.
- Prisma client: An auto-generated library that connects to the Prisma server and lets you read, write and stream data in your database. It is used for data access in your applications.
npm install prisma --save-dev
npm install @prisma/client
npx prisma init
In development, the command next dev clears Node.js cache on run. This in turn initializes a new PrismaClient instance each time due to hot reloading that creates a connection to the database. This can quickly exhaust the database connections as each PrismaClient instance holds its own connection pool.
(Prisma Instance)[https://www.prisma.io/docs/guides/other/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices#solution]
- create utils/db.ts
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined
}
const prisma = globalForPrisma.prisma ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
- add to .env
DATABASE_URL=""
DIRECT_URL=""
- DATABASE_URL : Transaction + Password + "?pgbouncer=true&connection_limit=1"
- DIRECT_URL : Session + Password
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
generator client {
provider = "prisma-client-js"
}
model TestProfile {
id String @id @default(uuid())
name String
- npx prisma migrate dev --name init
- npx prisma db push
npx prisma migrate dev --name init creates a new migration for your database schema changes and applies it, while npx prisma db push directly updates the database schema without creating a migration. In the context of databases, a migration is set of operations, that modify the database schema, helping it evolve over time while preserving existing data.
npx prisma db push
npx prisma studio
- Create Single Record
const task = await prisma.task.create({
data: {
content: 'some task',
},
})
- Get All Records
const tasks = await prisma.task.findMany()
- Get record by ID or unique identifier
// By unique identifier
const user = await prisma.user.findUnique({
where: {
email: '[email protected]',
},
})
// By ID
const task = await prisma.task.findUnique({
where: {
id: id,
},
})
- Update Record
const updateTask = await prisma.task.update({
where: {
id: id,
},
data: {
content: 'updated task',
},
})
- Update or create records
const upsertTask = await prisma.task.upsert({
where: {
id: id,
},
update: {
content: 'some value',
},
create: {
content: 'some value',
},
})
- Delete a single record
const deleteTask = await prisma.task.delete({
where: {
id: id,
},
})
about/page.tsx
import db from '@/utils/db'
async function AboutPage() {
const profile = await db.testProfile.create({
data: {
name: 'random name',
},
})
const users = await db.testProfile.findMany()
return (
<div>
{users.map((user) => {
return (
<h2 key={user.id} className="text-2xl font-bold">
{user.name}
</h2>
)
})}
</div>
)
}
export default AboutPage
model Product {
id String @id @default(uuid())
name String
company String
description String
featured Boolean
image String
price Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clerkId String
}
- stop server
npx prisma db push
npx prisma studio
npm run dev
- create prisma/products.json
[
{
"name": "avant-garde lamp",
"company": "Modenza",
"description": "Cloud bread VHS hell of banjo bicycle rights jianbing umami mumblecore etsy 8-bit pok pok +1 wolf. Vexillologist yr dreamcatcher waistcoat, authentic chillwave trust fund. Viral typewriter fingerstache pinterest pork belly narwhal. Schlitz venmo everyday carry kitsch pitchfork chillwave iPhone taiyaki trust fund hashtag kinfolk microdosing gochujang live-edge",
"featured": true,
"image": "https://images.pexels.com/photos/943150/pexels-photo-943150.jpeg?auto=compress&cs=tinysrgb&w=1600",
"price": 100,
"clerkId": "clerkId"
},
{
"name": "chic chair",
"company": "Luxora",
"description": "Cloud bread VHS hell of banjo bicycle rights jianbing umami mumblecore etsy 8-bit pok pok +1 wolf. Vexillologist yr dreamcatcher waistcoat, authentic chillwave trust fund. Viral typewriter fingerstache pinterest pork belly narwhal. Schlitz venmo everyday carry kitsch pitchfork chillwave iPhone taiyaki trust fund hashtag kinfolk microdosing gochujang live-edge",
"featured": true,
"image": "https://images.pexels.com/photos/5705090/pexels-photo-5705090.jpeg?auto=compress&cs=tinysrgb&w=1600",
"price": 200,
"clerkId": "clerkId"
},
{
"name": "comfy bed",
"company": "Homestead",
"description": "Cloud bread VHS hell of banjo bicycle rights jianbing umami mumblecore etsy 8-bit pok pok +1 wolf. Vexillologist yr dreamcatcher waistcoat, authentic chillwave trust fund. Viral typewriter fingerstache pinterest pork belly narwhal. Schlitz venmo everyday carry kitsch pitchfork chillwave iPhone taiyaki trust fund hashtag kinfolk microdosing gochujang live-edge",
"featured": true,
"image": "https://images.pexels.com/photos/1034584/pexels-photo-1034584.jpeg?auto=compress&cs=tinysrgb&w=1600",
"price": 300,
"clerkId": "clerkId"
},
{
"name": "contemporary sofa",
"company": "Comfora",
"description": "Cloud bread VHS hell of banjo bicycle rights jianbing umami mumblecore etsy 8-bit pok pok +1 wolf. Vexillologist yr dreamcatcher waistcoat, authentic chillwave trust fund. Viral typewriter fingerstache pinterest pork belly narwhal. Schlitz venmo everyday carry kitsch pitchfork chillwave iPhone taiyaki trust fund hashtag kinfolk microdosing gochujang live-edge",
"featured": false,
"image": "https://images.pexels.com/photos/1571459/pexels-photo-1571459.jpeg?auto=compress&cs=tinysrgb&w=1600",
"price": 400,
"clerkId": "clerkId"
}
]
- create prisma/seed.js
const { PrismaClient } = require('@prisma/client')
const products = require('./products.json')
const prisma = new PrismaClient()
async function main() {
for (const product of products) {
await prisma.product.create({
data: product,
})
}
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
node prisma/seed
- check prisma studio
-
global
- EmptyList
- SectionTitle
- LoadingContainer
-
home
- FeaturedProducts
- Hero
- HeroCarousel
-
products
- FavoriteToggleButton
- FavoriteToggleForm
- ProductsContainer
- ProductsGrid
- ProductsList
import FeaturedProducts from '@/components/home/FeaturedProducts'
import Hero from '@/components/home/Hero'
function HomPage() {
return (
<>
<Hero />
<FeaturedProducts />
</>
)
}
export default HomPage
import { Separator } from '@/components/ui/separator'
function SectionTitle({ text }: { text: string }) {
return (
<div>
<h2 className="text-3xl font-medium tracking-wider capitalize mb-8">
{text}
</h2>
<Separator />
</div>
)
}
export default SectionTitle
import { cn } from '@/lib/utils'
function EmptyList({
heading = 'No items found.',
className,
}: {
heading?: string
className?: string
}) {
return <h2 className={cn('text-xl ', className)}>{heading}</h2>
}
export default EmptyList
- create utils/actions.ts
import db from '@/utils/db'
export const fetchFeaturedProducts = async () => {
const products = await db.product.findMany({
where: {
featured: true,
},
})
return products
}
export const fetchAllProducts = () => {
return db.product.findMany({
orderBy: {
createdAt: 'desc',
},
})
}
import { fetchFeaturedProducts } from '@/utils/actions'
import EmptyList from '../global/EmptyList'
import SectionTitle from '../global/SectionTitle'
import ProductsGrid from '../products/ProductsGrid'
async function FeaturedProducts() {
const products = await fetchFeaturedProducts()
if (products.length === 0) return <EmptyList />
return (
<section className="pt-24">
<SectionTitle text="featured products" />
<ProductsGrid products={products} />
</section>
)
}
export default FeaturedProducts
- utils/format.ts
export const formatCurrency = (amount: number | null) => {
const value = amount || 0
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value)
}
import { FaHeart } from 'react-icons/fa'
import { Button } from '@/components/ui/button'
function FavoriteToggleButton({ productId }: { productId: string }) {
return (
<Button size="icon" variant="outline" className="p-2 cursor-pointer">
<FaHeart />
</Button>
)
}
export default FavoriteToggleButton
import { Product } from '@prisma/client'
import { formatCurrency } from '@/utils/format'
import { Card, CardContent } from '@/components/ui/card'
import Link from 'next/link'
import Image from 'next/image'
import FavoriteToggleButton from './FavoriteToggleButton'
function ProductsGrid({ products }: { products: Product[] }) {
return (
<div className="pt-12 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{products.map((product) => {
const { name, price, image } = product
const productId = product.id
const dollarsAmount = formatCurrency(price)
return (
<article key={productId} className="group relative">
<Link href={`/products/${productId}`}>
<Card className="transform group-hover:shadow-xl transition-shadow duration-500">
<CardContent className="p-4">
<div className="relative h-64 md:h-48 rounded overflow-hidden ">
<Image
src={image}
alt={name}
fill
sizes="(max-width:768px) 100vw,(max-width:1200px) 50vw,33vw"
priority
className="rounded w-full object-cover transform group-hover:scale-110 transition-transform duration-500"
/>
</div>
<div className="mt-4 text-center">
<h2 className="text-lg capitalize">{name}</h2>
<p className="text-muted-foreground mt-2">
{dollarsAmount}
</p>
</div>
</CardContent>
</Card>
</Link>
<div className="absolute top-7 right-7 z-5">
<FavoriteToggleButton productId={productId} />
</div>
</article>
)
})}
</div>
)
}
export default ProductsGrid
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.pexels.com',
},
],
},
}
export default nextConfig
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import HeroCarousel from './HeroCarousel'
function Hero() {
return (
<section className="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
<div>
<h1 className="max-w-2xl font-bold text-4xl tracking-tight sm:text-6xl">
We are changing the way people shop
</h1>
<p className="mt-8 max-w-xl text-lg leading-8 text-muted-foreground">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Cumque et
voluptas saepe in quae voluptate, laborum maiores possimus illum
reprehenderit aut delectus veniam cum perferendis unde sint doloremque
non nam.
</p>
<Button asChild size="lg" className="mt-10">
<Link href="/products">Our Products</Link>
</Button>
</div>
<HeroCarousel />
</section>
)
}
export default Hero
HeroCarousel
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel'
import { Card, CardContent } from '@/components/ui/card'
import Image from 'next/image'
import hero1 from '@/public/images/hero1.jpg'
import hero2 from '@/public/images/hero2.jpg'
import hero3 from '@/public/images/hero3.jpg'
import hero4 from '@/public/images/hero4.jpg'
const carouselImages = [hero1, hero2, hero3, hero4]
function HeroCarousel() {
return (
<div className="hidden lg:block">
<Carousel>
<CarouselContent>
{carouselImages.map((image, index) => {
return (
<CarouselItem key={index}>
<Card>
<CardContent className="p-2">
<Image
src={image}
alt="hero"
className="w-full h-[24rem] rounded-md object-cover"
/>
</CardContent>
</Card>
</CarouselItem>
)
})}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)
}
export default HeroCarousel
function AboutPage() {
return (
<section>
<h1 className="flex flex-wrap gap-2 sm:gap-x-6 items-center justify-center text-4xl font-bold leading-none tracking-wide sm:text-6xl">
We love
<span className="bg-primary py-2 px-4 rounded-lg tracking-widest text-white">
store
</span>
</h1>
<p className="mt-6 text-lg tracking-wide leading-8 max-w-2xl mx-auto text-muted-foreground">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Vero hic
distinctio ducimus temporibus nobis autem laboriosam repellat, magni
fugiat minima excepturi neque, tenetur possimus nihil atque! Culpa nulla
labore nam?
</p>
</section>
)
}
export default AboutPage
app/page.tsx
import FeaturedProducts from '@/components/home/FeaturedProducts'
import Hero from '@/components/home/Hero'
import LoadingContainer from '@/components/global/LoadingContainer'
import { Suspense } from 'react'
function HomPage() {
return (
<>
<Hero />
<Suspense fallback={<LoadingContainer />}>
<FeaturedProducts />
</Suspense>
</>
)
}
export default HomPage
import { Skeleton } from '../ui/skeleton'
import { Card, CardContent } from '../ui/card'
function LoadingContainer() {
return (
<div className="pt-12 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<LoadingProduct />
<LoadingProduct />
<LoadingProduct />
</div>
)
}
function LoadingProduct() {
return (
<Card>
<CardContent className="p-4">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-4 w-3/4 mt-4" />
<Skeleton className="h-4 w-1/4 mt-4" />
</CardContent>
</Card>
)
}
export default LoadingContainer
- create app/products/loading.tsx
'use client'
import LoadingContainer from '@/components/global/LoadingContainer'
function loading() {
return <LoadingContainer />
}
export default loading
import ProductsContainer from '@/components/products/ProductsContainer'
async function ProductsPage({
searchParams,
}: {
searchParams: { layout?: string; search?: string }
}) {
const layout = searchParams.layout || 'grid'
const search = searchParams.search || ''
return (
<>
<ProductsContainer layout={layout} search={search} />
</>
)
}
export default ProductsPage
import ProductsGrid from './ProductsGrid'
import ProductsList from './ProductsList'
import { LuLayoutGrid, LuList } from 'react-icons/lu'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { fetchAllProducts } from '@/utils/actions'
import Link from 'next/link'
async function ProductsContainer({
layout,
search,
}: {
layout: string
search: string
}) {
const products = await fetchAllProducts()
const totalProducts = products.length
const searchTerm = search ? `&search=${search}` : ''
return (
<>
{/* HEADER */}
<section>
<div className="flex justify-between items-center">
<h4 className="font-medium text-lg">
{totalProducts} product{totalProducts > 1 && 's'}
</h4>
<div className="flex gap-x-4">
<Button
variant={layout === 'grid' ? 'default' : 'ghost'}
size="icon"
asChild
>
<Link href={`/products?layout=grid${searchTerm}`}>
<LuLayoutGrid />
</Link>
</Button>
<Button
variant={layout === 'list' ? 'default' : 'ghost'}
size="icon"
asChild
>
<Link href={`/products?layout=list${searchTerm}`}>
<LuList />
</Link>
</Button>
</div>
</div>
<Separator className="mt-4" />
</section>
{/* PRODUCTS */}
<div>
{totalProducts === 0 ? (
<h5 className="text-2xl mt-16">
Sorry, no products matched your search...
</h5>
) : layout === 'grid' ? (
<ProductsGrid products={products} />
) : (
<ProductsList products={products} />
)}
</div>
</>
)
}
export default ProductsContainer
import { formatCurrency } from '@/utils/format'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Product } from '@prisma/client'
import Image from 'next/image'
import FavoriteToggleButton from './FavoriteToggleButton'
function ProductsList({ products }: { products: Product[] }) {
return (
<div className="mt-12 grid gap-y-8">
{products.map((product) => {
const { name, price, image, company } = product
const dollarsAmount = formatCurrency(price)
const productId = product.id
return (
<article key={productId} className="group relative">
<Link href={`/products/${productId}`}>
<Card className="transform group-hover:shadow-xl transition-shadow duration-500">
<CardContent className="p-8 gap-y-4 grid md:grid-cols-3">
<div className="relative h-64 md:h-48 md:w-48">
<Image
src={image}
alt={name}
fill
sizes="(max-width:768px) 100vw,(max-width:1200px) 50vw,33vw"
priority
className="w-full rounded-md object-cover"
/>
</div>
<div>
<h2 className="text-xl font-semibold capitalize">{name}</h2>
<h4 className="text-muted-foreground">{company}</h4>
</div>
<p className="text-muted-foreground text-lg md:ml-auto">
{dollarsAmount}
</p>
</CardContent>
</Card>
</Link>
<div className="absolute bottom-8 right-8 z-5">
<FavoriteToggleButton productId={productId} />
</div>
</article>
)
})}
</div>
)
}
export default ProductsList
- install use-debounce
npm i use-debounce
'use client'
import { Input } from '../ui/input'
import { useSearchParams, useRouter } from 'next/navigation'
import { useDebouncedCallback } from 'use-debounce'
import { useState, useEffect } from 'react'
function NavSearch() {
const searchParams = useSearchParams()
const { replace } = useRouter()
const [search, setSearch] = useState(
searchParams.get('search')?.toString() || ''
)
const handleSearch = useDebouncedCallback((value: string) => {
const params = new URLSearchParams(searchParams)
if (value) {
params.set('search', value)
} else {
params.delete('search')
}
replace(`/products?${params.toString()}`)
}, 300)
useEffect(() => {
if (!searchParams.get('search')) {
setSearch('')
}
}, [searchParams.get('search')])
return (
<Input
type="search"
placeholder="search product..."
className="max-w-xs dark:bg-muted "
onChange={(e) => {
setSearch(e.target.value)
handleSearch(e.target.value)
}}
value={search}
/>
)
}
export default NavSearch
- refactor
ProductsContainer.tsx
const products = await fetchAllProducts({ search })
- actions
export const fetchAllProducts = ({ search = '' }: { search: string }) => {
return db.product.findMany({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ company: { contains: search, mode: 'insensitive' } },
],
},
orderBy: {
createdAt: 'desc',
},
})
}
Navbar.tsx
import { Suspense } from 'react'
return (
<>
<Suspense>
<NavSearch />
</Suspense>
</>
)
- actions.ts
import { redirect } from 'next/navigation'
export const fetchSingleProduct = async (productId: string) => {
const product = await db.product.findUnique({
where: {
id: productId,
},
})
if (!product) {
redirect('/products')
}
return product
}
- create components/single-product
- AddToCart
- BreadCrumbs
- ProductRating
AddToCart.tsx
import { Button } from '../ui/button'
function AddToCart({ productId }: { productId: string }) {
return (
<Button className="capitalize mt-8" size="lg">
add to cart
</Button>
)
}
export default AddToCart
BreadCrumbs.tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
function BreadCrumbs({ name }: { name: string }) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/" className="capitalize text-lg">
home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/products" className="capitalize text-lg">
products
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="capitalize text-lg">{name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
export default BreadCrumbs
ProductRating.tsx
import { FaStar } from 'react-icons/fa'
async function ProductRating({ productId }: { productId: string }) {
const rating = 4.2
const count = 25
const className = `flex gap-1 items-center text-md mt-1 mb-4`
const countValue = `(${count}) reviews`
return (
<span className={className}>
<FaStar className="w-3 h-3" />
{rating} {countValue}
</span>
)
}
export default ProductRating
- create app/products/[id]/page.tsx
import BreadCrumbs from '@/components/single-product/BreadCrumbs'
import { fetchSingleProduct } from '@/utils/actions'
import Image from 'next/image'
import { formatCurrency } from '@/utils/format'
import FavoriteToggleButton from '@/components/products/FavoriteToggleButton'
import AddToCart from '@/components/single-product/AddToCart'
import ProductRating from '@/components/single-product/ProductRating'
async function SingleProductPage({ params }: { params: { id: string } }) {
const product = await fetchSingleProduct(params.id)
const { name, image, company, description, price } = product
const dollarsAmount = formatCurrency(price)
return (
<section>
<BreadCrumbs name={product.name} />
<div className="mt-6 grid gap-y-8 lg:grid-cols-2 lg:gap-x-16">
{/* IMAGE FIRST COL */}
<div className="relative h-full">
<Image
src={image}
alt={name}
fill
sizes="(max-width:768px) 100vw,(max-width:1200px) 50vw,33vw"
priority
className="w-full rounded-md object-cover"
/>
</div>
{/* PRODUCT INFO SECOND COL */}
<div>
<div className="flex gap-x-8 items-center">
<h1 className="capitalize text-3xl font-bold">{name}</h1>
<FavoriteToggleButton productId={params.id} />
</div>
<ProductRating productId={params.id} />
<h4 className="text-xl mt-2">{company}</h4>
<p className="mt-3 text-md bg-muted inline-block p-2 rounded-md">
{dollarsAmount}
</p>
<p className="mt-6 leading-8 text-muted-foreground">{description}</p>
<AddToCart productId={params.id} />
</div>
</div>
</section>
)
}
export default SingleProductPage
- create vercel account Vercel
- create github repository
- double check .gitignore
- update package.json
"scripts": {
"dev": "next dev",
"build": "npx prisma generate && next build",
"start": "next start",
"lint": "next lint"
},
- push it up to github
git init
git add .
git commit -m "first commit"
- deploy on vercel
- setup env variables
providers.tsx
'use client'
import { ThemeProvider } from './theme-provider'
import { Toaster } from '@/components/ui/toaster'
function Providers({ children }: { children: React.ReactNode }) {
return (
<>
<Toaster />
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</>
)
}
export default Providers
Clerk Docs Clerk + Next.js Setup
- create new application
npm install @clerk/nextjs
- create .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
In Next.js, environment variables that start with NEXTPUBLIC are exposed to the browser. This means they can be accessed in your front-end code.
For example, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY can be used in both server-side and client-side code.
On the other hand, CLERK_SECRET_KEY is a server-side environment variable. It's not exposed to the browser, making it suitable for storing sensitive data like API secrets.
layout.tsx
import { ClerkProvider } from '@clerk/nextjs'
return (
<ClerkProvider>
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<Navbar />
<Container className="py-20">{children}</Container>
</Providers>
</body>
</html>
</ClerkProvider>
)
- create middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublicRoute = createRouteMatcher(['/', '/products(.*)', '/about'])
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) auth().protect()
})
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
- restart dev server
- customization
- avatars
'use client'
import { SignOutButton } from '@clerk/nextjs'
import { useToast } from '../ui/use-toast'
import Link from 'next/link'
function SignOutLink() {
const { toast } = useToast()
const handleLogout = () => {
toast({ description: 'Logging Out...' })
}
return (
<SignOutButton>
<Link href="/" className="w-full text-left" onClick={handleLogout}>
Logout
</Link>
</SignOutButton>
)
}
export default SignOutLink
import { LuUser2 } from 'react-icons/lu'
import { currentUser } from '@clerk/nextjs/server'
async function UserIcon() {
const user = await currentUser()
const profileImage = user?.imageUrl
if (profileImage)
return (
<img src={profileImage} className="w-6 h-6 rounded-full object-cover" />
)
return <LuUser2 className="w-6 h-6 bg-primary rounded-full text-white" />
}
export default UserIcon
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { LuAlignLeft } from 'react-icons/lu'
import Link from 'next/link'
import { Button } from '../ui/button'
import { links } from '@/utils/links'
import UserIcon from './UserIcon'
import SignOutLink from './SignOutLink'
import { SignInButton, SignUpButton, SignedIn, SignedOut } from '@clerk/nextjs'
function LinksDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex gap-4 max-w-[100px]">
<LuAlignLeft className="w-6 h-6" />
<UserIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" align="start" sideOffset={10}>
<SignedOut>
<DropdownMenuItem>
<SignInButton mode="modal">
<button className="w-full text-left">Login</button>
</SignInButton>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SignUpButton mode="modal">
<button className="w-full text-left">Register</button>
</SignUpButton>
</DropdownMenuItem>
</SignedOut>
<SignedIn>
{links.map((link) => {
return (
<DropdownMenuItem key={link.href}>
<Link href={link.href} className="capitalize w-full">
{link.label}
</Link>
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem>
<SignOutLink />
</DropdownMenuItem>
</SignedIn>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default LinksDropdown
- utils/links.ts
type NavLink = {
href: string
label: string
}
export const links: NavLink[] = [
{ href: '/', label: 'home' },
{ href: '/about', label: 'about' },
{ href: '/products', label: 'products' },
{ href: '/favorites', label: 'favorites' },
{ href: '/cart', label: 'cart' },
{ href: '/orders', label: 'orders' },
{ href: '/admin/sales', label: 'dashboard' },
]
export const adminLinks: NavLink[] = [
{ href: '/admin/sales', label: 'sales' },
{ href: '/admin/products', label: 'my products' },
{ href: '/admin/products/create', label: 'create product' },
]
-
remove existing page.tsx
-
admin
- products
- [id]/edit/page.tsx
- create/page.tsx
- page.tsx
- sales/page.tsx
- layout.tsx
- Sidebar.tsx
- products
Sidebar.tsx
'use client'
import { adminLinks } from '@/utils/links'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button'
function Sidebar() {
const pathname = usePathname()
return (
<aside>
{adminLinks.map((link) => {
const isActivePage = pathname === link.href
const variant = isActivePage ? 'default' : 'ghost'
return (
<Button
asChild
className="w-full mb-2 capitalize font-normal justify-start"
variant={variant}
>
<Link key={link.href} href={link.href}>
{link.label}
</Link>
</Button>
)
})}
</aside>
)
}
export default Sidebar
layout.tsx
import { Separator } from '@/components/ui/separator'
import Sidebar from './Sidebar'
function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<>
<h2 className="text-2xl pl-4">Dashboard</h2>
<Separator className="mt-2" />
<section className="grid lg:grid-cols-12 gap-12 mt-12">
<div className="lg:col-span-2">
<Sidebar />
</div>
<div className="lg:col-span-10 px-4">{children}</div>
</section>
</>
)
}
export default DashboardLayout
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
const isPublicRoute = createRouteMatcher(['/', '/products(.*)', '/about'])
const isAdminRoute = createRouteMatcher(['/admin(.*)'])
export default clerkMiddleware(async (auth, req) => {
// console.log(auth().userId);
const isAdminUser = auth().userId === process.env.ADMIN_USER_ID
if (isAdminRoute(req) && !isAdminUser) {
return NextResponse.redirect(new URL('/', req.url))
}
if (!isPublicRoute(req)) auth().protect()
})
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
- add userId to .env
ADMIN_USER_ID=
import { auth } from '@clerk/nextjs/server'
function LinksDropdown() {
const { userId } = auth()
const isAdmin = userId === process.env.ADMIN_USER_ID
return (
<>
{links.map((link) => {
if (link.label === 'dashboard' && !isAdmin) return null
return (
<DropdownMenuItem key={link.href}>
<Link href={link.href} className="capitalize w-full">
{link.label}
</Link>
</DropdownMenuItem>
)
})}
</>
)
}
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const createProductAction = async (formData: FormData) => {
'use server'
const name = formData.get('name') as string
console.log(name)
}
function CreateProductPage() {
return (
<section>
<h1 className="text-2xl font-semibold mb-8 capitalize">create product</h1>
<div className="border p-8 rounded-md">
<form action={createProductAction}>
<div className="mb-2">
<Label htmlFor="name">Product Name</Label>
<Input id="name" name="name" type="text" />
</div>
<Button type="submit" size="lg">
Submit
</Button>
</form>
</div>
</section>
)
}
export default CreateProductPage
npm install @faker-js/faker --save-dev
import { faker } from '@faker-js/faker'
function CreateProductPage() {
const name = faker.commerce.productName()
const company = faker.company.name()
const description = faker.lorem.paragraph({ min: 10, max: 12 })
return <Input id="name" name="name" type="text" defaultValue={name} />
}
export default CreateProductPage
- components/form
- Buttons
- CheckBoxInput
- FormContainer
- FormInput
- ImageInput
- ImageInputContainer
- PriceInput
- TextAreaInput
FormInput.tsx
import { Label } from '../ui/label'
import { Input } from '../ui/input'
type FormInputProps = {
name: string
type: string
label?: string
defaultValue?: string
placeholder?: string
}
function FormInput({
label,
name,
type,
defaultValue,
placeholder,
}: FormInputProps) {
return (
<div className="mb-2">
<Label htmlFor={name} className="capitalize">
{label || name}
</Label>
<Input
id={name}
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required
/>
</div>
)
}
export default FormInput
import { Label } from '../ui/label'
import { Input } from '../ui/input'
const name = 'price'
type FormInputNumberProps = {
defaultValue?: number
}
function PriceInput({ defaultValue }: FormInputNumberProps) {
return (
<div className="mb-2">
<Label htmlFor="price" className="capitalize">
Price ($)
</Label>
<Input
id={name}
type="number"
name={name}
min={0}
defaultValue={defaultValue || 100}
required
/>
</div>
)
}
export default PriceInput
import { Label } from '../ui/label'
import { Input } from '../ui/input'
function ImageInput() {
const name = 'image'
return (
<div className="mb-2">
<Label htmlFor={name} className="capitalize">
Image
</Label>
<Input id={name} name={name} type="file" required accept="image/*" />
</div>
)
}
export default ImageInput
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
type TextAreaInputProps = {
name: string
labelText?: string
defaultValue?: string
}
function TextAreaInput({ name, labelText, defaultValue }: TextAreaInputProps) {
return (
<div className="mb-2">
<Label htmlFor={name} className="capitalize">
{labelText || name}
</Label>
<Textarea
id={name}
name={name}
defaultValue={defaultValue}
rows={5}
required
className="leading-loose"
/>
</div>
)
}
export default TextAreaInput
'use client'
import { Checkbox } from '@/components/ui/checkbox'
type CheckboxInputProps = {
name: string
label: string
defaultChecked?: boolean
}
export default function CheckboxInput({
name,
label,
defaultChecked = false,
}: CheckboxInputProps) {
return (
<div className="flex items-center space-x-2">
<Checkbox id={name} name={name} defaultChecked={defaultChecked} />
<label
htmlFor={name}
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 capitalize"
>
{label}
</label>
</div>
)
}
components/form/Buttons.tsx
'use client'
import { ReloadIcon } from '@radix-ui/react-icons'
import { useFormStatus } from 'react-dom'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { SignInButton } from '@clerk/nextjs'
import { FaRegHeart, FaHeart } from 'react-icons/fa'
import { LuTrash2, LuPenSquare } from 'react-icons/lu'
type btnSize = 'default' | 'lg' | 'sm'
type SubmitButtonProps = {
className?: string
text?: string
size?: btnSize
}
export function SubmitButton({
className = '',
text = 'submit',
size = 'lg',
}: SubmitButtonProps) {
const { pending } = useFormStatus()
return (
<Button
type="submit"
disabled={pending}
className={cn('capitalize', className)}
size={size}
>
{pending ? (
<>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Please wait...
</>
) : (
text
)}
</Button>
)
}
- create utils/types.ts
export type actionFunction = (
prevState: any,
formData: FormData
) => Promise<{ message: string }>
export type CartItem = {
productId: string
image: string
title: string
price: string
amount: number
company: string
}
export type CartState = {
cartItems: CartItem[]
numItemsInCart: number
cartTotal: number
shipping: number
tax: number
orderTotal: number
}
FormContainer.tsx
'use client'
import { useFormState } from 'react-dom'
import { useEffect } from 'react'
import { useToast } from '@/components/ui/use-toast'
import { actionFunction } from '@/utils/types'
const initialState = {
message: '',
}
function FormContainer({
action,
children,
}: {
action: actionFunction
children: React.ReactNode
}) {
const [state, formAction] = useFormState(action, initialState)
const { toast } = useToast()
useEffect(() => {
if (state.message) {
toast({ description: state.message })
}
}, [state])
return <form action={formAction}>{children}</form>
}
export default FormContainer
- actions.ts
'use server'
export const createProductAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
return { message: 'product created' }
}
page.tsx
import FormInput from '@/components/form/FormInput'
import { SubmitButton } from '@/components/form/Buttons'
import FormContainer from '@/components/form/FormContainer'
import { createProductAction } from '@/utils/actions'
import ImageInput from '@/components/form/ImageInput'
import PriceInput from '@/components/form/PriceInput'
import TextAreaInput from '@/components/form/TextAreaInput'
import { faker } from '@faker-js/faker'
import CheckboxInput from '@/components/form/CheckboxInput'
function CreateProduct() {
const name = faker.commerce.productName()
const company = faker.company.name()
// const description = faker.commerce.productDescription();
const description = faker.lorem.paragraph({ min: 10, max: 12 })
return (
<section>
<h1 className="text-2xl font-semibold mb-8 capitalize">create product</h1>
<div className="border p-8 rounded-md">
<FormContainer action={createProductAction}>
<div className="grid gap-4 md:grid-cols-2 my-4">
<FormInput
type="text"
name="name"
label="product name"
defaultValue={name}
/>
<FormInput
type="text"
name="company"
label="company"
defaultValue={company}
/>
<PriceInput />
<ImageInput />
</div>
<TextAreaInput
name="description"
labelText="product description"
defaultValue={description}
/>
<div className="mt-6">
<CheckboxInput name="featured" label="featured" />
</div>
<SubmitButton text="Create Product" className="mt-8" />
</FormContainer>
</div>
</section>
)
}
export default CreateProduct
- actions.ts
import { auth, currentUser } from '@clerk/nextjs/server'
const renderError = (error: unknown): { message: string } => {
console.log(error)
return {
message: error instanceof Error ? error.message : 'An error occurred',
}
}
const getAuthUser = async () => {
const user = await currentUser()
if (!user) {
throw new Error('You must be logged in to access this route')
}
return user
}
- get/store product images in public/images
export const createProductAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser()
try {
const name = formData.get('name') as string
const company = formData.get('company') as string
const price = Number(formData.get('price') as string)
const image = formData.get('image') as File
const description = formData.get('description') as string
const featured = Boolean(formData.get('featured') as string)
await db.product.create({
data: {
name,
company,
price,
image: '/images/product-1.jpg',
description,
featured,
clerkId: user.id,
},
})
return { message: 'product created' }
} catch (error) {
return renderError(error)
}
}
- lots of code code just to access input values
- no validation (only html one)
Zod is a JavaScript library for building schemas and validating data, providing type safety and error handling.
npm install zod
- setup utils/schemas.ts
import { z, ZodSchema } from 'zod'
export const productSchema = z.object({
name: z.string().min(4),
company: z.string().min(4),
price: z.coerce.number().int().min(0),
description: z.string(),
featured: z.coerce.boolean(),
})
- actions.ts
export const createProductAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser()
try {
const rawData = Object.fromEntries(formData)
const validatedFields = productSchema.parse(rawData)
await db.product.create({
data: {
...validatedFields,
image: '/images/product-1.jpg',
clerkId: user.id,
},
})
return { message: 'product created' }
} catch (error) {
return renderError(error)
}
}
- error messages are not user friendly
schemas.ts
import { z, ZodSchema } from 'zod'
export const productSchema = z.object({
name: z
.string()
.min(2, {
message: 'name must be at least 2 characters.',
})
.max(100, {
message: 'name must be less than 100 characters.',
}),
company: z.string(),
featured: z.coerce.boolean(),
price: z.coerce.number().int().min(0, {
message: 'price must be a positive number.',
}),
description: z.string().refine(
(description) => {
const wordCount = description.split(' ').length
return wordCount >= 10 && wordCount <= 1000
},
{
message: 'description must be between 10 and 1000 words.',
}
),
})
try {
const rawData = Object.fromEntries(formData);
const validatedFields = productSchema.safeParse(rawData);
if (!validatedFields.success) {
const errors = validatedFields.error.errors.map((error) => error.message);
throw new Error(errors.join(','));
}
await db.product.create({
data: {
...validatedFields.data,
image: '/images/product-1.jpg',
clerkId: user.id,
},
});
return { message: 'product created' };
}
schemas.ts
export function validateWithZodSchema<T>(
schema: ZodSchema<T>,
data: unknown
): T {
const result = schema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((error) => error.message)
throw new Error(errors.join(','))
}
return result.data
}
actions.ts
try {
const rawData = Object.fromEntries(formData);
const validatedFields = validateWithZodSchema(productSchema, rawData);
await db.product.create({
data: {
...validatedFields,
image: '/images/product-1.jpg',
clerkId: user.id,
},
});
return { message: 'product created' };
}
schemas.ts
export const imageSchema = z.object({
image: validateImageFile(),
})
function validateImageFile() {
const maxUploadSize = 1024 * 1024
const acceptedFileTypes = ['image/']
return z
.instanceof(File)
.refine((file) => {
return !file || file.size <= maxUploadSize
}, `File size must be less than 1 MB`)
.refine((file) => {
return (
!file || acceptedFileTypes.some((type) => file.type.startsWith(type))
)
}, 'File must be an image')
}
actions.ts
try {
const rawData = Object.fromEntries(formData);
const file = formData.get('image') as File;
const validatedFields = validateWithZodSchema(productSchema, rawData);
const validatedFile = validateWithZodSchema(imageSchema, { image: file });
console.log(validatedFile);
await db.product.create({
data: {
...validatedFields,
image: '/images/product-1.jpg',
clerkId: user.id,
},
});
return { message: 'product created' };
}
SUPABASE_URL=
SUPABASE_KEY=
npm install @supabase/supabase-js
utils/supabase.ts
import { createClient } from '@supabase/supabase-js'
const bucket = 'your-bucket-name'
// Create a single supabase client for interacting with your database
export const supabase = createClient(
process.env.SUPABASE_URL as string,
process.env.SUPABASE_KEY as string
)
export const uploadImage = async (image: File) => {
const timestamp = Date.now()
// const newName = `/users/${timestamp}-${image.name}`;
const newName = `${timestamp}-${image.name}`
const { data, error } = await supabase.storage
.from(bucket)
.upload(newName, image, {
cacheControl: '3600',
})
if (!data) throw new Error('Image upload failed')
return supabase.storage.from(bucket).getPublicUrl(newName).data.publicUrl
}
- actions.ts
export const createProductAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
const user = await getAuthUser()
try {
const rawData = Object.fromEntries(formData)
const file = formData.get('image') as File
const validatedFields = validateWithZodSchema(productSchema, rawData)
const validatedFile = validateWithZodSchema(imageSchema, { image: file })
const fullPath = await uploadImage(validatedFile.image)
await db.product.create({
data: {
...validatedFields,
image: fullPath,
clerkId: user.id,
},
})
} catch (error) {
return renderError(error)
}
redirect('/admin/products')
}
- add supabase url to remote patterns
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.pexels.com',
},
{
protocol: 'https',
hostname: 'pldbjxhkrlailuixuvhz.supabase.co',
},
],
},
}
export default nextConfig
- actions.ts
const getAdminUser = async () => {
const user = await getAuthUser()
if (user.id !== process.env.ADMIN_USER_ID) redirect('/')
return user
}
// refactor createProductAction
export const fetchAdminProducts = async () => {
await getAdminUser()
const products = await db.product.findMany({
orderBy: {
createdAt: 'desc',
},
})
return products
}
- app/admin/products/page.tsx
import EmptyList from '@/components/global/EmptyList'
import { fetchAdminProducts } from '@/utils/actions'
import Link from 'next/link'
import { formatCurrency } from '@/utils/format'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
async function ItemsPage() {
const items = await fetchAdminProducts()
if (items.length === 0) return <EmptyList />
return (
<section>
<Table>
<TableCaption className="capitalize">
total products : {items.length}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Product Name</TableHead>
<TableHead>Company</TableHead>
<TableHead>Price</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const { id: productId, name, company, price } = item
return (
<TableRow key={productId}>
<TableCell>
<Link
href={`/products/${productId}`}
className="underline text-muted-foreground tracking-wide capitalize"
>
{name}
</Link>
</TableCell>
<TableCell>{company}</TableCell>
<TableCell>{formatCurrency(price)}</TableCell>
<TableCell className="flex items-center gap-x-2"></TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</section>
)
}
export default ItemsPage
type actionType = 'edit' | 'delete'
export const IconButton = ({ actionType }: { actionType: actionType }) => {
const { pending } = useFormStatus()
const renderIcon = () => {
switch (actionType) {
case 'edit':
return <LuPenSquare />
case 'delete':
return <LuTrash2 />
default:
const never: never = actionType
throw new Error(`Invalid action type: ${never}`)
}
}
return (
<Button
type="submit"
size="icon"
variant="link"
className="p-2 cursor-pointer"
>
{pending ? <ReloadIcon className=" animate-spin" /> : renderIcon()}
</Button>
)
}
- actions.ts
import { revalidatePath } from 'next/cache'
export const deleteProductAction = async (prevState: { productId: string }) => {
const { productId } = prevState
await getAdminUser()
try {
await db.product.delete({
where: {
id: productId,
},
})
revalidatePath('/admin/products')
return { message: 'product removed' }
} catch (error) {
return renderError(error)
}
}
import FormContainer from '@/components/form/FormContainer'
import { IconButton } from '@/components/form/Buttons'
import { deleteProductAction } from '@/utils/actions'
return (
<>
<TableCell className="flex items-center gap-x-2">
<Link href={`/admin/products/${productId}/edit`}>
<IconButton actionType="edit"></IconButton>
</Link>
<DeleteProduct productId={productId} />
</TableCell>
</>
)
function DeleteProduct({ productId }: { productId: string }) {
const deleteProduct = deleteProductAction.bind(null, { productId })
return (
<FormContainer action={deleteProduct}>
<IconButton actionType="delete" />
</FormContainer>
)
}
- utils/supabase.ts
export const deleteImage = (url: string) => {
const imageName = url.split('/').pop()
if (!imageName) throw new Error('Invalid URL')
return supabase.storage.from(bucket).remove([imageName])
}
export const deleteProductAction = async (prevState: { productId: string }) => {
const { productId } = prevState
await getAdminUser()
try {
const product = await db.product.delete({
where: {
id: productId,
},
})
await deleteImage(product.image)
revalidatePath('/admin/products')
return { message: 'product removed' }
} catch (error) {
return renderError(error)
}
}
- actions.ts
export const fetchAdminProductDetails = async (productId: string) => {
await getAdminUser()
const product = await db.product.findUnique({
where: {
id: productId,
},
})
if (!product) redirect('/admin/products')
return product
}
export const updateProductAction = async (
prevState: any,
formData: FormData
) => {
return { message: 'Product updated successfully' }
}
export const updateProductImageAction = async (
prevState: any,
formData: FormData
) => {
return { message: 'Product Image updated successfully' }
}
- app/admin/products/[id]/edit/page.tsx
import { fetchAdminProductDetails, updateProductAction } from '@/utils/actions'
import FormContainer from '@/components/form/FormContainer'
import FormInput from '@/components/form/FormInput'
import PriceInput from '@/components/form/PriceInput'
import TextAreaInput from '@/components/form/TextAreaInput'
import { SubmitButton } from '@/components/form/Buttons'
import CheckboxInput from '@/components/form/CheckboxInput'
async function EditProductPage({ params }: { params: { id: string } }) {
const { id } = params
const product = await fetchAdminProductDetails(id)
const { name, company, description, featured, price } = product
return (
<section>
<h1 className="text-2xl font-semibold mb-8 capitalize">update product</h1>
<div className="border p-8 rounded-md">
{/* Image Input Container */}
<FormContainer action={updateProductAction}>
<div className="grid gap-4 md:grid-cols-2 my-4">
<input type="hidden" name="id" value={id} />
<FormInput
type="text"
name="name"
label="product name"
defaultValue={name}
/>
<FormInput
type="text"
name="company"
label="company"
defaultValue={company}
/>
<PriceInput defaultValue={price} />
</div>
<TextAreaInput
name="description"
labelText="product description"
defaultValue={description}
/>
<div className="mt-6">
<CheckboxInput
name="featured"
label="featured"
defaultChecked={featured}
/>
</div>
<SubmitButton text="update product" className="mt-8" />
</FormContainer>
</div>
</section>
)
}
export default EditProductPage
actions.ts
export const updateProductAction = async (
prevState: any,
formData: FormData
) => {
await getAdminUser()
try {
const productId = formData.get('id') as string
const rawData = Object.fromEntries(formData)
const validatedFields = validateWithZodSchema(productSchema, rawData)
await db.product.update({
where: {
id: productId,
},
data: {
...validatedFields,
},
})
revalidatePath(`/admin/products/${productId}/edit`)
return { message: 'Product updated successfully' }
} catch (error) {
return renderError(error)
}
}
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { Button } from '../ui/button'
import FormContainer from './FormContainer'
import ImageInput from './ImageInput'
import { SubmitButton } from './Buttons'
import { type actionFunction } from '@/utils/types'
type ImageInputContainerProps = {
image: string
name: string
action: actionFunction
text: string
children?: React.ReactNode
}
function ImageInputContainer(props: ImageInputContainerProps) {
const { image, name, action, text } = props
const [isUpdateFormVisible, setUpdateFormVisible] = useState(false)
return (
<div className="mb-8">
<Image
src={image}
width={200}
height={200}
className="rounded-md object-cover mb-4 w-[200px] h-[200px]"
alt={name}
/>
<Button
variant="outline"
size="sm"
onClick={() => setUpdateFormVisible((prev) => !prev)}
>
{text}
</Button>
{isUpdateFormVisible && (
<div className="max-w-md mt-4">
<FormContainer action={action}>
{props.children}
<ImageInput />
<SubmitButton size="sm" />
</FormContainer>
</div>
)}
</div>
)
}
export default ImageInputContainer
EditProductPage.tsx
return (
<div className="border p-8 rounded-md">
{/* Image Input Container */}
<ImageInputContainer
action={updateProductImageAction}
name={name}
image={product.image}
text="update image"
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="url" value={product.image} />
</ImageInputContainer>
</div>
)
- actions.ts
export const updateProductImageAction = async (
prevState: any,
formData: FormData
) => {
await getAuthUser()
try {
const image = formData.get('image') as File
const productId = formData.get('id') as string
const oldImageUrl = formData.get('url') as string
const validatedFile = validateWithZodSchema(imageSchema, { image })
const fullPath = await uploadImage(validatedFile.image)
await deleteImage(oldImageUrl)
await db.product.update({
where: {
id: productId,
},
data: {
image: fullPath,
},
})
revalidatePath(`/admin/products/${productId}/edit`)
return { message: 'Product Image updated successfully' }
} catch (error) {
return renderError(error)
}
}
- create components/global/LoadingTable.tsx
import { Skeleton } from '../ui/skeleton'
function LoadingTable({ rows = 5 }: { rows?: number }) {
const tableRows = Array.from({ length: rows }, (_, index) => {
return (
<div className="mb-4" key={index}>
<Skeleton className="w-full h-8 rounded" />
</div>
)
})
return <>{tableRows}</>
}
export default LoadingTable
- create admin/products/loading.tsx
'use client'
import LoadingTable from '@/components/global/LoadingTable'
function loading() {
return <LoadingTable />
}
export default loading
model Product {
favorites Favorite[]
}
model Favorite {
id String @id @default(uuid())
clerkId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
npx prisma db push
- restart server
- components/form/Buttons.tsx
export const CardSignInButton = () => {
return (
<SignInButton mode="modal">
<Button
type="button"
size="icon"
variant="outline"
className="p-2 cursor-pointer"
asChild
>
<FaRegHeart />
</Button>
</SignInButton>
)
}
export const CardSubmitButton = ({ isFavorite }: { isFavorite: boolean }) => {
const { pending } = useFormStatus()
return (
<Button
type="submit"
size="icon"
variant="outline"
className=" p-2 cursor-pointer"
>
{pending ? (
<ReloadIcon className=" animate-spin" />
) : isFavorite ? (
<FaHeart />
) : (
<FaRegHeart />
)}
</Button>
)
}
- actions.ts
export const fetchFavoriteId = async ({ productId }: { productId: string }) => {
const user = await getAuthUser()
const favorite = await db.favorite.findFirst({
where: {
productId,
clerkId: user.id,
},
select: {
id: true,
},
})
return favorite?.id || null
}
export const toggleFavoriteAction = async () => {
return { message: 'toggle favorite action' }
}
- components/products/FavoriteToggleButton.tsx
import { auth } from '@clerk/nextjs/server'
import { CardSignInButton } from '../form/Buttons'
import { fetchFavoriteId } from '@/utils/actions'
import FavoriteToggleForm from './FavoriteToggleForm'
async function FavoriteToggleButton({ productId }: { productId: string }) {
const { userId } = auth()
if (!userId) return <CardSignInButton />
const favoriteId = await fetchFavoriteId({ productId })
return <FavoriteToggleForm favoriteId={favoriteId} productId={productId} />
}
export default FavoriteToggleButton
'use client'
import { usePathname } from 'next/navigation'
import FormContainer from '../form/FormContainer'
import { toggleFavoriteAction } from '@/utils/actions'
import { CardSubmitButton } from '../form/Buttons'
type FavoriteToggleFormProps = {
productId: string
favoriteId: string | null
}
function FavoriteToggleForm({
productId,
favoriteId,
}: FavoriteToggleFormProps) {
const pathname = usePathname()
const toggleAction = toggleFavoriteAction.bind(null, {
productId,
favoriteId,
pathname,
})
return (
<FormContainer action={toggleAction}>
<CardSubmitButton isFavorite={favoriteId ? true : false} />
</FormContainer>
)
}
export default FavoriteToggleForm
- actions.ts
export const toggleFavoriteAction = async (prevState: {
productId: string
favoriteId: string | null
pathname: string
}) => {
const user = await getAuthUser()
const { productId, favoriteId, pathname } = prevState
try {
if (favoriteId) {
await db.favorite.delete({
where: {
id: favoriteId,
},
})
} else {
await db.favorite.create({
data: {
productId,
clerkId: user.id,
},
})
}
revalidatePath(pathname)
return { message: favoriteId ? 'Removed from Faves' : 'Added to Faves' }
} catch (error) {
return renderError(error)
}
}
- test in home, products and single product page
export const fetchUserFavorites = async () => {
const user = await getAuthUser()
const favorites = await db.favorite.findMany({
where: {
clerkId: user.id,
},
include: {
product: true,
},
})
return favorites
}
- favorites/loading.tsx
'use client'
import LoadingContainer from '@/components/global/LoadingContainer'
function loading() {
return <LoadingContainer />
}
export default loading
page.tsx
import { fetchUserFavorites } from '@/utils/actions'
import SectionTitle from '@/components/global/SectionTitle'
import ProductsGrid from '@/components/products/ProductsGrid'
async function FavoritesPage() {
const favorites = await fetchUserFavorites()
if (favorites.length === 0)
return <SectionTitle text="You have no favorites yet." />
return (
<div>
<SectionTitle text="Favorites" />
<ProductsGrid products={favorites.map((favorite) => favorite.product)} />
</div>
)
}
export default FavoritesPage
npm i react-share
- create NEXT_PUBLIC_WEBSITE_URL in .env
- get url from vercel
components/single-product/ShareButton.tsx
'use client'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '../ui/button'
import { LuShare2 } from 'react-icons/lu'
import {
TwitterShareButton,
EmailShareButton,
LinkedinShareButton,
TwitterIcon,
EmailIcon,
LinkedinIcon,
} from 'react-share'
function ShareButton({ productId, name }: { productId: string; name: string }) {
const url = process.env.NEXT_PUBLIC_WEBSITE_URL
const shareLink = `${url}/products/${productId}`
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="p-2">
<LuShare2 />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={10}
className="flex items-center gap-x-2 justify-center w-full"
>
<TwitterShareButton url={shareLink} title={name}>
<TwitterIcon size={32} round />
</TwitterShareButton>
<LinkedinShareButton url={shareLink} title={name}>
<LinkedinIcon size={32} round />
</LinkedinShareButton>
<EmailShareButton url={shareLink} subject={name}>
<EmailIcon size={32} round />
</EmailShareButton>
</PopoverContent>
</Popover>
)
}
export default ShareButton
- products/[id]/page.tsx
import ShareButton from '@/components/single-product/ShareButton'
return (
<div className="flex gap-x-8 items-center">
<h1 className="capitalize text-3xl font-bold">{name}</h1>
<div className="flex items-center gap-x-2">
<FavoriteToggleButton productId={params.id} />
<ShareButton name={product.name} productId={params.id} />
</div>
</div>
)
model Product {
reviews Review []
}
model Review {
id String @id @default(uuid())
clerkId String
rating Int
comment String
authorName String
authorImageUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
}
npx prisma db push
- restar the server
- actions.ts
export const createReviewAction = async (
prevState: any,
formData: FormData
) => {
return { message: 'review submitted successfully' }
}
export const fetchProductReviews = async () => {}
export const fetchProductReviewsByUser = async () => {}
export const deleteReviewAction = async () => {}
export const findExistingReview = async () => {}
export const fetchProductRating = async () => {}
- components/reviews
- RatingInput.tsx
- Comment.tsx
- ProductReviews.tsx
- Rating.tsx
- ReviewCard.tsx
- SubmitReview.tsx
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const RatingInput = ({
name,
labelText,
}: {
name: string
labelText?: string
}) => {
const numbers = Array.from({ length: 5 }, (_, i) => {
const value = i + 1
return value.toString()
}).reverse()
return (
<div className="mb-2 max-w-xs">
<Label htmlFor={name} className="capitalize">
{labelText || name}
</Label>
<Select defaultValue={numbers[0]} name={name} required>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{numbers.map((number) => {
return (
<SelectItem key={number} value={number}>
{number}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
}
export default RatingInput
'use client'
import { useState } from 'react'
import { SubmitButton } from '@/components/form/Buttons'
import FormContainer from '@/components/form/FormContainer'
import { Card } from '@/components/ui/card'
import RatingInput from '@/components/reviews/RatingInput'
import TextAreaInput from '@/components/form/TextAreaInput'
import { Button } from '@/components/ui/button'
import { createReviewAction } from '@/utils/actions'
import { useUser } from '@clerk/nextjs'
function SubmitReview({ productId }: { productId: string }) {
const [isReviewFormVisible, setIsReviewFormVisible] = useState(false)
const { user } = useUser()
return (
<div>
<Button
size="lg"
className="capitalize"
onClick={() => setIsReviewFormVisible((prev) => !prev)}
>
leave review
</Button>
{isReviewFormVisible && (
<Card className="p-8 mt-8">
<FormContainer action={createReviewAction}>
<input type="hidden" name="productId" value={productId} />
<input
type="hidden"
name="authorName"
value={user?.firstName || 'user'}
/>
<input
type="hidden"
name="authorImageUrl"
value={user?.imageUrl || ''}
/>
<RatingInput name="rating" />
<TextAreaInput
name="comment"
labelText="feedback"
defaultValue="Outstanding product!!!"
/>
<SubmitButton className="mt-4" />
</FormContainer>
</Card>
)}
</div>
)
}
export default SubmitReview
- render in app/products/[id]/page.tsx after second column
import SubmitReview from '@/components/reviews/SubmitReview'
import ProductReviews from '@/components/reviews/ProductReviews'
return (
<>
<ProductReviews productId={params.id} />
<SubmitReview productId={params.id} />
</>
)
- schemas.ts
export const reviewSchema = z.object({
productId: z.string().refine((value) => value !== '', {
message: 'Product ID cannot be empty',
}),
authorName: z.string().refine((value) => value !== '', {
message: 'Author name cannot be empty',
}),
authorImageUrl: z.string().refine((value) => value !== '', {
message: 'Author image URL cannot be empty',
}),
rating: z.coerce
.number()
.int()
.min(1, { message: 'Rating must be at least 1' })
.max(5, { message: 'Rating must be at most 5' }),
comment: z
.string()
.min(10, { message: 'Comment must be at least 10 characters long' })
.max(1000, { message: 'Comment must be at most 1000 characters long' }),
})
- actions.ts
export const createReviewAction = async (
prevState: any,
formData: FormData
) => {
const user = await getAuthUser()
try {
const rawData = Object.fromEntries(formData)
const validatedFields = validateWithZodSchema(reviewSchema, rawData)
await db.review.create({
data: {
...validatedFields,
clerkId: user.id,
},
})
revalidatePath(`/products/${validatedFields.productId}`)
return { message: 'Review submitted successfully' }
} catch (error) {
return renderError(error)
}
}
import { FaStar, FaRegStar } from 'react-icons/fa'
function Rating({ rating }: { rating: number }) {
// rating = 2
// 1 <= 2 true
// 2 <= 2 true
// 3 <= 2 false
// ....
const stars = Array.from({ length: 5 }, (_, i) => i + 1 <= rating)
return (
<div className="flex items-center gap-x-1">
{stars.map((isFilled, i) => {
const className = `w-3 h-3 ${
isFilled ? 'text-primary' : 'text-gray-400'
}`
return isFilled ? (
<FaStar className={className} key={i} />
) : (
<FaRegStar className={className} key={i} />
)
})}
</div>
)
}
export default Rating
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
function Comment({ comment }: { comment: string }) {
const [isExpanded, setIsExpanded] = useState(false)
const toggleExpanded = () => {
setIsExpanded(!isExpanded)
}
const longComment = comment.length > 130
const displayComment =
longComment && !isExpanded ? `${comment.slice(0, 130)}...` : comment
return (
<div>
<p className="text-sm">{displayComment}</p>
{longComment && (
<Button
variant="link"
className="pl-0 text-muted-foreground"
onClick={toggleExpanded}
>
{isExpanded ? 'Show Less' : 'Show More'}
</Button>
)}
</div>
)
}
export default Comment
export const fetchProductReviews = async (productId: string) => {
const reviews = await db.review.findMany({
where: {
productId,
},
orderBy: {
createdAt: 'desc',
},
})
return reviews
}
import { fetchProductReviews } from '@/utils/actions'
import ReviewCard from './ReviewCard'
import SectionTitle from '../global/SectionTitle'
async function ProductReviews({ productId }: { productId: string }) {
const reviews = await fetchProductReviews(productId)
return (
<div className="mt-16">
<SectionTitle text="product reviews" />
<div className="grid md:grid-cols-2 gap-8 my-8">
{reviews.map((review) => {
const { comment, rating, authorImageUrl, authorName } = review
const reviewInfo = {
comment,
rating,
image: authorImageUrl,
name: authorName,
}
return <ReviewCard key={review.id} reviewInfo={reviewInfo} />
})}
</div>
</div>
)
}
export default ProductReviews
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import Rating from './Rating'
import Comment from './Comment'
import Image from 'next/image'
type ReviewCardProps = {
reviewInfo: {
comment: string
rating: number
name: string
image: string
}
children?: React.ReactNode
}
function ReviewCard({ reviewInfo, children }: ReviewCardProps) {
return (
<Card className="relative">
<CardHeader>
<div className="flex items-center">
<Image
src={reviewInfo.image}
alt={reviewInfo.name}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
/>
<div className="ml-4">
<h3 className="text-sm font-bold capitalize mb-1">
{reviewInfo.name}
</h3>
<Rating rating={reviewInfo.rating} />
</div>
</div>
</CardHeader>
<CardContent>
<Comment comment={reviewInfo.comment} />
</CardContent>
<div className="absolute top-3 right-3">{children}</div>
</Card>
)
}
export default ReviewCard
- next.config.mjs
{
protocol: 'https',
hostname: 'img.clerk.com',
},
export const fetchProductRating = async (productId: string) => {
const result = await db.review.groupBy({
by: ['productId'],
_avg: {
rating: true,
},
_count: {
rating: true,
},
where: {
productId,
},
})
// empty array if no reviews
return {
rating: result[0]?._avg.rating?.toFixed(1) ?? 0,
count: result[0]?._count.rating ?? 0,
}
}
- components/single-product/ProductRating.tsx
const { rating, count } = await fetchProductRating(productId)
export const fetchProductReviewsByUser = async () => {
const user = await getAuthUser()
const reviews = await db.review.findMany({
where: {
clerkId: user.id,
},
select: {
id: true,
rating: true,
comment: true,
product: {
select: {
image: true,
name: true,
},
},
},
})
return reviews
}
export const deleteReviewAction = async (prevState: { reviewId: string }) => {
const { reviewId } = prevState
const user = await getAuthUser()
try {
await db.review.delete({
where: {
id: reviewId,
clerkId: user.id,
},
})
revalidatePath('/reviews')
return { message: 'Review deleted successfully' }
} catch (error) {
return renderError(error)
}
}
- setup "reviews" link in utils/links.ts
- create app/reviews/page.tsx and app/reviews/loading.tsx
page.tsx
import { deleteReviewAction, fetchProductReviewsByUser } from '@/utils/actions'
import ReviewCard from '@/components/reviews/ReviewCard'
import SectionTitle from '@/components/global/SectionTitle'
import FormContainer from '@/components/form/FormContainer'
import { IconButton } from '@/components/form/Buttons'
async function ReviewsPage() {
const reviews = await fetchProductReviewsByUser()
if (reviews.length === 0)
return <SectionTitle text="you have no reviews yet" />
return (
<>
<SectionTitle text="Your Reviews" />
<section className="grid md:grid-cols-2 gap-8 mt-4 ">
{reviews.map((review) => {
const { comment, rating } = review
const { name, image } = review.product
const reviewInfo = {
comment,
rating,
name,
image,
}
return (
<ReviewCard key={review.id} reviewInfo={reviewInfo}>
<DeleteReview reviewId={review.id} />
</ReviewCard>
)
})}
</section>
</>
)
}
const DeleteReview = ({ reviewId }: { reviewId: string }) => {
const deleteReview = deleteReviewAction.bind(null, { reviewId })
return (
<FormContainer action={deleteReview}>
<IconButton actionType="delete" />
</FormContainer>
)
}
export default ReviewsPage
loading.tsx
'use client'
import { Card, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
function loading() {
return (
<section className="grid md:grid-cols-2 gap-8 mt-4 ">
<ReviewLoadingCard />
<ReviewLoadingCard />
</section>
)
}
const ReviewLoadingCard = () => {
return (
<Card>
<CardHeader>
<div className="flex items-center">
<Skeleton className="w-12 h-12 rounded-full" />
<div className="ml-4">
<Skeleton className="w-[150px] h-4 mb-2" />
<Skeleton className="w-[100px] h-4" />
</div>
</div>
</CardHeader>
</Card>
)
}
export default loading
actions.ts
export const findExistingReview = async (userId: string, productId: string) => {
return db.review.findFirst({
where: {
clerkId: userId,
productId,
},
})
}
- app/products/[id]/page.tsx
import { fetchSingleProduct, findExistingReview } from '@/utils/actions'
import { auth } from '@clerk/nextjs/server'
async function SingleProductPage({ params }: { params: { id: string } }) {
const { userId } = auth()
const reviewDoesNotExist =
userId && !(await findExistingReview(userId, product.id))
return (
<>
<ProductReviews productId={params.id} />
{reviewDoesNotExist && <SubmitReview productId={params.id} />}
</>
)
}
- prisma/schema.prisma
model Product{
cartItems CartItem[]
}
model Cart {
id String @id @default(uuid())
clerkId String
cartItems CartItem[]
numItemsInCart Int @default(0)
cartTotal Int @default(0)
shipping Int @default(5)
tax Int @default(0)
taxRate Float @default(0.1)
orderTotal Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CartItem {
id String @id @default(uuid())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
cartId String
amount Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- actions.ts
export const fetchCartItems = async () => {}
const fetchProduct = async () => {}
export const fetchOrCreateCart = async () => {}
const updateOrCreateCartItem = async () => {}
export const updateCart = async () => {}
export const addToCartAction = async () => {}
export const removeCartItemAction = async () => {}
export const updateCartItemAction = async () => {}
- actions.ts
export const fetchCartItems = async () => {
const { userId } = auth()
const cart = await db.cart.findFirst({
where: {
clerkId: userId ?? '',
},
select: {
numItemsInCart: true,
},
})
return cart?.numItemsInCart || 0
}
- components/navbar/CartButton.tsx
async function CartButton() {
const numItemsInCart = await fetchCartItems()
}
- components/form/Buttons.tsx
export const ProductSignInButton = () => {
return (
<SignInButton mode="modal">
<Button type="button" size="default" className="mt-8">
Please Sign In
</Button>
</SignInButton>
)
}
- create components/single-product/SelectProductAmount.tsx
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export enum Mode {
SingleProduct = 'singleProduct',
CartItem = 'cartItem',
}
type SelectProductAmountProps = {
mode: Mode.SingleProduct
amount: number
setAmount: (value: number) => void
}
type SelectCartItemAmountProps = {
mode: Mode.CartItem
amount: number
setAmount: (value: number) => Promise<void>
isLoading: boolean
}
function SelectProductAmount(
props: SelectProductAmountProps | SelectCartItemAmountProps
) {
const { mode, amount, setAmount } = props
const cartItem = mode === Mode.CartItem
return (
<>
<h4 className="mb-2">Amount : </h4>
<Select
defaultValue={amount.toString()}
onValueChange={(value) => setAmount(Number(value))}
disabled={cartItem ? props.isLoading : false}
>
<SelectTrigger className={cartItem ? 'w-[100px]' : 'w-[150px]'}>
<SelectValue placeholder={amount} />
</SelectTrigger>
<SelectContent>
{Array.from({ length: cartItem ? amount + 10 : 10 }, (_, index) => {
const selectValue = (index + 1).toString()
return (
<SelectItem key={index} value={selectValue}>
{selectValue}
</SelectItem>
)
})}
</SelectContent>
</Select>
</>
)
}
export default SelectProductAmount
'use client'
import { useState } from 'react'
import SelectProductAmount from './SelectProductAmount'
import { Mode } from './SelectProductAmount'
import FormContainer from '../form/FormContainer'
import { SubmitButton } from '../form/Buttons'
import { addToCartAction } from '@/utils/actions'
import { useAuth } from '@clerk/nextjs'
import { ProductSignInButton } from '../form/Buttons'
function AddToCart({ productId }: { productId: string }) {
const [amount, setAmount] = useState(1)
const { userId } = useAuth()
return (
<div className="mt-4">
<SelectProductAmount
mode={Mode.SingleProduct}
amount={amount}
setAmount={setAmount}
/>
{userId ? (
<FormContainer action={addToCartAction}>
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="amount" value={amount} />
<SubmitButton text="add to cart" size="default" className="mt-8" />
</FormContainer>
) : (
<ProductSignInButton />
)}
</div>
)
}
export default AddToCart
- actions.ts
import { Cart } from '@prisma/client'
const fetchProduct = async (productId: string) => {
const product = await db.product.findUnique({
where: {
id: productId,
},
})
if (!product) {
throw new Error('Product not found')
}
return product
}
const includeProductClause = {
cartItems: {
include: {
product: true,
},
},
}
export const fetchOrCreateCart = async ({
userId,
errorOnFailure = false,
}: {
userId: string
errorOnFailure?: boolean
}) => {
let cart = await db.cart.findFirst({
where: {
clerkId: userId,
},
include: includeProductClause,
})
if (!cart && errorOnFailure) {
throw new Error('Cart not found')
}
if (!cart) {
cart = await db.cart.create({
data: {
clerkId: userId,
},
include: includeProductClause,
})
}
return cart
}
const updateOrCreateCartItem = async ({
productId,
cartId,
amount,
}: {
productId: string
cartId: string
amount: number
}) => {
let cartItem = await db.cartItem.findFirst({
where: {
productId,
cartId,
},
})
if (cartItem) {
cartItem = await db.cartItem.update({
where: {
id: cartItem.id,
},
data: {
amount: cartItem.amount + amount,
},
})
} else {
cartItem = await db.cartItem.create({
data: { amount, productId, cartId },
})
}
}
export const updateCart = async (cart: Cart) => {
const cartItems = await db.cartItem.findMany({
where: {
cartId: cart.id,
},
include: {
product: true, // Include the related product
},
})
let numItemsInCart = 0
let cartTotal = 0
for (const item of cartItems) {
numItemsInCart += item.amount
cartTotal += item.amount * item.product.price
}
const tax = cart.taxRate * cartTotal
const shipping = cartTotal ? cart.shipping : 0
const orderTotal = cartTotal + tax + shipping
await db.cart.update({
where: {
id: cart.id,
},
data: {
numItemsInCart,
cartTotal,
tax,
orderTotal,
},
})
}
export const addToCartAction = async (prevState: any, formData: FormData) => {
const user = await getAuthUser()
try {
const productId = formData.get('productId') as string
const amount = Number(formData.get('amount'))
await fetchProduct(productId)
const cart = await fetchOrCreateCart({ userId: user.id })
await updateOrCreateCartItem({ productId, cartId: cart.id, amount })
await updateCart(cart)
} catch (error) {
return renderError(error)
}
redirect('/cart')
}
-
create components/cart
- CartItemColumns.tsx
- CartItemsList.tsx
- CartTotals.tsx
- ThirdColumn.tsx
-
app/cart/page.tsx
import CartItemsList from '@/components/cart/CartItemsList'
import CartTotals from '@/components/cart/CartTotals'
import SectionTitle from '@/components/global/SectionTitle'
import { fetchOrCreateCart, updateCart } from '@/utils/actions'
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
async function CartPage() {
const { userId } = auth()
if (!userId) redirect('/')
const cart = await fetchOrCreateCart({ userId })
await updateCart(cart)
if (cart.numItemsInCart === 0) {
return <SectionTitle text="Empty cart" />
}
return (
<>
<SectionTitle text="Shopping Cart" />
<div className="mt-8 grid gap-4 lg:grid-cols-12">
<div className="lg:col-span-8">
<CartItemsList cartItems={cart.cartItems} />
</div>
<div className="lg:col-span-4 lg:pl-4">
<CartTotals cart={cart} />
</div>
</div>
</>
)
}
export default CartPage
import { Card, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { formatCurrency } from '@/utils/format'
import { createOrderAction } from '@/utils/actions'
import FormContainer from '../form/FormContainer'
import { SubmitButton } from '../form/Buttons'
import { Cart } from '@prisma/client'
function CartTotals({ cart }: { cart: Cart }) {
const { cartTotal, shipping, tax, orderTotal } = cart
return (
<div>
<Card className="p-8 ">
<CartTotalRow label="Subtotal" amount={cartTotal} />
<CartTotalRow label="Shipping" amount={shipping} />
<CartTotalRow label="Tax" amount={tax} />
<CardTitle className="mt-8">
<CartTotalRow label="Order Total" amount={orderTotal} lastRow />
</CardTitle>
</Card>
<FormContainer action={createOrderAction}>
<SubmitButton text="Place Order" className="w-full mt-8" />
</FormContainer>
</div>
)
}
function CartTotalRow({
label,
amount,
lastRow,
}: {
label: string
amount: number
lastRow?: boolean
}) {
return (
<>
<p className="flex justify-between text-sm">
<span>{label}</span>
<span>{formatCurrency(amount)}</span>
</p>
{lastRow ? null : <Separator className="my-2" />}
</>
)
}
export default CartTotals
- cart/CartItemColumns.tsx
import { formatCurrency } from '@/utils/format'
import Image from 'next/image'
import Link from 'next/link'
export const FirstColumn = ({
name,
image,
}: {
image: string
name: string
}) => {
return (
<div className="relative h-24 w-24 sm:h-32 sm:w-32">
<Image
src={image}
alt={name}
fill
sizes="(max-width:768px) 100vw,(max-width:1200px) 50vw,33vw"
priority
className="w-full rounded-md object-cover"
/>
</div>
)
}
export const SecondColumn = ({
name,
company,
productId,
}: {
name: string
company: string
productId: string
}) => {
return (
<div className=" sm:w-48">
<Link href={`/products/${productId}`}>
<h3 className="capitalize font-medium hover:underline">{name}</h3>
</Link>
<h4 className="mt-2 capitalize text-xs">{company}</h4>
</div>
)
}
export const FourthColumn = ({ price }: { price: number }) => {
return <p className="font-medium md:ml-auto">{formatCurrency(price)}</p>
}
- utils/types.ts
import { Prisma } from '@prisma/client'
export type CartItemWithProduct = Prisma.CartItemGetPayload<{
include: { product: true }
}>
import { Card } from '@/components/ui/card'
import { FirstColumn, SecondColumn, FourthColumn } from './CartItemColumns'
import ThirdColumn from './ThirdColumn'
import { CartItemWithProduct } from '@/utils/types'
function CartItemsList({ cartItems }: { cartItems: CartItemWithProduct[] }) {
return (
<div>
{cartItems.map((cartItem) => {
const { id, amount } = cartItem
const { id: productId, image, name, company, price } = cartItem.product
return (
<Card
key={id}
className="flex flex-col gap-y-4 md:flex-row flex-wrap p-6 mb-8 gap-x-4"
>
<FirstColumn image={image} name={name} />
<SecondColumn name={name} company={company} productId={productId} />
<ThirdColumn id={id} quantity={amount} />
<FourthColumn price={price} />
</Card>
)
})}
</div>
)
}
export default CartItemsList
- optional
actions.ts
export const removeCartItemAction = async (
prevState: any,
formData: FormData
) => {
return { message: 'Item removed from cart' }
}
'use client'
import { useState } from 'react'
import SelectProductAmount from '../single-product/SelectProductAmount'
import { Mode } from '../single-product/SelectProductAmount'
import FormContainer from '../form/FormContainer'
import { SubmitButton } from '../form/Buttons'
import { removeCartItemAction, updateCartItemAction } from '@/utils/actions'
import { useToast } from '../ui/use-toast'
function ThirdColumn({ quantity, id }: { quantity: number; id: string }) {
const [amount, setAmount] = useState(quantity)
const handleAmountChange = async (value: number) => {
setAmount(value)
}
return (
<div className="md:ml-8">
<SelectProductAmount
amount={amount}
setAmount={handleAmountChange}
mode={Mode.CartItem}
isLoading={false}
/>
<FormContainer action={removeCartItemAction}>
<input type="hidden" name="id" value={id} />
<SubmitButton size="sm" className="mt-4" text="remove" />
</FormContainer>
</div>
)
}
export default ThirdColumn
- actions.ts
eexport const removeCartItemAction = async (
prevState: any,
formData: FormData
) => {
const user = await getAuthUser();
try {
const cartItemId = formData.get('id') as string;
const cart = await fetchOrCreateCart({
userId: user.id,
errorOnFailure: true,
});
await db.cartItem.delete({
where: {
id: cartItemId,
cartId: cart.id,
},
});
await updateCart(cart);
revalidatePath('/cart');
return { message: 'Item removed from cart' };
} catch (error) {
return renderError(error);
}
};
- actions.ts
export const updateCartItemAction = async ({
amount,
cartItemId,
}: {
amount: number
cartItemId: string
}) => {
const user = await getAuthUser()
try {
const cart = await fetchOrCreateCart({
userId: user.id,
errorOnFailure: true,
})
await db.cartItem.update({
where: {
id: cartItemId,
cartId: cart.id,
},
data: {
amount,
},
})
await updateCart(cart)
revalidatePath('/cart')
return { message: 'cart updated' }
} catch (error) {
return renderError(error)
}
}
'use client'
import { useState } from 'react'
import SelectProductAmount from '../single-product/SelectProductAmount'
import { Mode } from '../single-product/SelectProductAmount'
import FormContainer from '../form/FormContainer'
import { SubmitButton } from '../form/Buttons'
import { removeCartItemAction, updateCartItemAction } from '@/utils/actions'
import { useToast } from '../ui/use-toast'
import { ReloadIcon } from '@radix-ui/react-icons'
import { Button } from '../ui/button'
function ThirdColumn({ quantity, id }: { quantity: number; id: string }) {
const [amount, setAmount] = useState(quantity)
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const handleAmountChange = async (value: number) => {
setIsLoading(true)
toast({ description: 'Calculating...' })
const result = await updateCartItemAction({
amount: value,
cartItemId: id,
})
setAmount(value)
toast({ description: result.message })
setIsLoading(false)
}
return (
<div className="md:ml-8">
<SelectProductAmount
amount={amount}
setAmount={handleAmountChange}
mode={Mode.CartItem}
isLoading={isLoading}
/>
<FormContainer action={removeCartItemAction}>
<input type="hidden" name="id" value={id} />
<SubmitButton size="sm" className="mt-4" text="remove" />
</FormContainer>
</div>
)
}
export default ThirdColumn
-
make CartItemsList client component ('use client' directive)
-
refactor updateCart
actions.ts
export const updateCart = async (cart: Cart) => {
const cartItems = await db.cartItem.findMany({
where: {
cartId: cart.id,
},
include: {
product: true, // Include the related product
},
orderBy: {
createdAt: 'asc',
},
})
let numItemsInCart = 0
let cartTotal = 0
for (const item of cartItems) {
numItemsInCart += item.amount
cartTotal += item.amount * item.product.price
}
const tax = cart.taxRate * cartTotal
const shipping = cartTotal ? cart.shipping : 0
const orderTotal = cartTotal + tax + shipping
const currentCart = await db.cart.update({
where: {
id: cart.id,
},
data: {
numItemsInCart,
cartTotal,
tax,
orderTotal,
},
include: includeProductClause,
})
return { currentCart, cartItems }
}
- app/cart/page.tsx
import CartItemsList from '@/components/cart/CartItemsList'
import CartTotals from '@/components/cart/CartTotals'
import SectionTitle from '@/components/global/SectionTitle'
import { fetchOrCreateCart, updateCart } from '@/utils/actions'
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
async function CartPage() {
const { userId } = auth()
if (!userId) redirect('/')
const previousCart = await fetchOrCreateCart({ userId })
const { cartItems, currentCart } = await updateCart(previousCart)
if (cartItems.length === 0) {
return <SectionTitle text="Empty cart" />
}
return (
<>
<SectionTitle text="Shopping Cart" />
<div className="mt-8 grid gap-4 lg:grid-cols-12">
<div className="lg:col-span-8">
<CartItemsList cartItems={cartItems} />
</div>
<div className="lg:col-span-4 lg:pl-4">
<CartTotals cart={currentCart} />
</div>
</div>
</>
)
}
export default CartPage
model Order {
id String @id @default(uuid())
clerkId String
products Int @default(0)
orderTotal Int @default(0)
tax Int @default(0)
shipping Int @default(0)
email String
isPaid Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
export const createOrderAction = async (prevState: any, formData: FormData) => {
const user = await getAuthUser()
try {
const cart = await fetchOrCreateCart({
userId: user.id,
errorOnFailure: true,
})
const order = await db.order.create({
data: {
clerkId: user.id,
products: cart.numItemsInCart,
orderTotal: cart.orderTotal,
tax: cart.tax,
shipping: cart.shipping,
email: user.emailAddresses[0].emailAddress,
},
})
await db.cart.delete({
where: {
id: cart.id,
},
})
} catch (error) {
return renderError(error)
}
redirect('/orders')
}
export const fetchUserOrders = async () => {
const user = await getAuthUser()
const orders = await db.order.findMany({
where: {
clerkId: user.id,
isPaid: true,
},
orderBy: {
createdAt: 'desc',
},
})
return orders
}
export const fetchAdminOrders = async () => {
const user = await getAdminUser()
const orders = await db.order.findMany({
where: {
isPaid: true,
},
orderBy: {
createdAt: 'desc',
},
})
return orders
}
- utils/format.ts
export const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
- create app/orders/loading.tsx
'use client'
import LoadingTable from '@/components/global/LoadingTable'
function loading() {
return <LoadingTable />
}
export default loading
- app/orders/page.tsx
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import SectionTitle from '@/components/global/SectionTitle'
import { fetchUserOrders } from '@/utils/actions'
import { formatCurrency, formatDate } from '@/utils/format'
async function OrdersPage() {
const orders = await fetchUserOrders()
return (
<>
<SectionTitle text="Your Orders" />
<div>
<Table>
<TableCaption>Total orders : {orders.length}</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Products</TableHead>
<TableHead>Order Total</TableHead>
<TableHead>Tax</TableHead>
<TableHead>Shipping</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => {
const { id, products, orderTotal, tax, shipping, createdAt } =
order
return (
<TableRow key={order.id}>
<TableCell>{products}</TableCell>
<TableCell>{formatCurrency(orderTotal)}</TableCell>
<TableCell>{formatCurrency(tax)}</TableCell>
<TableCell>{formatCurrency(shipping)}</TableCell>
<TableCell>{formatDate(createdAt)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)
}
export default OrdersPage
- create app/admin/sales/loading.tsx
'use client'
import LoadingTable from '@/components/global/LoadingTable'
function loading() {
return <LoadingTable />
}
export default loading
- app/admin/sales/page.tsx
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { fetchAdminOrders } from '@/utils/actions'
import { formatCurrency, formatDate } from '@/utils/format'
async function SalesPage() {
const orders = await fetchAdminOrders()
return (
<div>
<Table>
<TableCaption>Total orders : {orders.length}</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Products</TableHead>
<TableHead>Order Total</TableHead>
<TableHead>Tax</TableHead>
<TableHead>Shipping</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => {
const {
id,
products,
orderTotal,
tax,
shipping,
createdAt,
email,
} = order
return (
<TableRow key={order.id}>
<TableCell>{email}</TableCell>
<TableCell>{products}</TableCell>
<TableCell>{formatCurrency(orderTotal)}</TableCell>
<TableCell>{formatCurrency(tax)}</TableCell>
<TableCell>{formatCurrency(shipping)}</TableCell>
<TableCell>{formatDate(createdAt)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
export default SalesPage
- setup and add keys to .env
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
- install
npm install --save @stripe/react-stripe-js @stripe/stripe-js stripe axios
model Order {
isPaid Boolean @default(false)
}
export const createOrderAction = async (prevState: any, formData: FormData) => {
const user = await getAuthUser()
let orderId: null | string = null
let cartId: null | string = null
try {
const cart = await fetchOrCreateCart({
userId: user.id,
errorOnFailure: true,
})
cartId = cart.id
await db.order.deleteMany({
where: {
clerkId: user.id,
isPaid: false,
},
})
const order = await db.order.create({
data: {
clerkId: user.id,
products: cart.numItemsInCart,
orderTotal: cart.orderTotal,
tax: cart.tax,
shipping: cart.shipping,
email: user.emailAddresses[0].emailAddress,
},
})
orderId = order.id
} catch (error) {
return renderError(error)
}
redirect(`/checkout?orderId=${orderId}&cartId=${cartId}`)
}
+--------+ Fetch clientSecret +--------+ Request +---------+
| Client | -----------------------> | Server | ---------------> | Stripe |
| | | | | API |
| | | | <--------------- | |
| | <----------------------- | | clientSecret | |
| | clientSecret response | | | |
+--------+ +--------+ +---------+
Checkout.tsx payment/route.ts
- create app/checkout/page.tsx
'use client'
import axios from 'axios'
import { useSearchParams } from 'next/navigation'
import React, { useCallback } from 'react'
import { loadStripe } from '@stripe/stripe-js'
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from '@stripe/react-stripe-js'
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string
)
export default function CheckoutPage() {
const searchParams = useSearchParams()
const orderId = searchParams.get('orderId')
const cartId = searchParams.get('cartId')
const fetchClientSecret = useCallback(async () => {
// Create a Checkout Session
const response = await axios.post('/api/payment', {
orderId: orderId,
cartId: cartId,
})
return response.data.clientSecret
}, [])
const options = { fetchClientSecret }
return (
<div id="checkout">
<EmbeddedCheckoutProvider stripe={stripePromise} options={options}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
)
}
- create api/payment/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
import { type NextRequest } from 'next/server'
import db from '@/utils/db'
export const POST = async (req: NextRequest) => {
const requestHeaders = new Headers(req.headers)
const origin = requestHeaders.get('origin')
const { orderId, cartId } = await req.json()
const order = await db.order.findUnique({
where: {
id: orderId,
},
})
const cart = await db.cart.findUnique({
where: {
id: cartId,
},
include: {
cartItems: {
include: {
product: true,
},
},
},
})
if (!order || !cart) {
return Response.json(null, {
status: 404,
statusText: 'Not Found',
})
}
const line_items = cart.cartItems.map((cartItem) => {
return {
quantity: cartItem.amount,
price_data: {
currency: 'usd',
product_data: {
name: cartItem.product.name,
images: [cartItem.product.image],
},
unit_amount: cartItem.product.price * 100, // price in cents
},
}
})
try {
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded',
metadata: { orderId, cartId },
line_items: line_items,
mode: 'payment',
return_url: `${origin}/api/confirm?session_id={CHECKOUT_SESSION_ID}`,
})
return Response.json({ clientSecret: session.client_secret })
} catch (error) {
console.log(error)
return Response.json(null, {
status: 500,
statusText: 'Internal Server Error',
})
}
}
- product structure
return {
quantity: 1,
price_data: {
currency: 'usd',
product_data: {
name: 'product name',
images: ['product image url'],
},
unit_amount: cartItem.product.price * 100, // price in cents
},
}
+--------+ Checkout Session ID +--------+ redirect +---------+
| Server | -----------------------> | Server | ---------------> | Orders |
| | | | | |
| | | | | |
| | | | | |
| | | | | |
+--------+ +--------+ +---------+
payment/route.ts confirm/route.ts orders page
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
import { redirect } from 'next/navigation'
import { type NextRequest } from 'next/server'
import db from '@/utils/db'
export const GET = async (req: NextRequest) => {
const { searchParams } = new URL(req.url)
const session_id = searchParams.get('session_id') as string
try {
const session = await stripe.checkout.sessions.retrieve(session_id)
// console.log(session);
const orderId = session.metadata?.orderId
const cartId = session.metadata?.cartId
if (session.status === 'complete') {
await db.order.update({
where: {
id: orderId,
},
data: {
isPaid: true,
},
})
await db.cart.delete({
where: {
id: cartId,
},
})
}
} catch (err) {
console.log(err)
return Response.json(null, {
status: 500,
statusText: 'Internal Server Error',
})
}
redirect('/orders')
}