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 { LuArmchair } from 'react-icons/lu';
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: 'elsa@prisma.io',
},
});
// 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');
};