Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/skyybbanerjee/storefront-temp

Temp StoreFront e-commerce platform , in NextJs, still getting built.. ⚙️
https://github.com/skyybbanerjee/storefront-temp

Last synced: 3 days ago
JSON representation

Temp StoreFront e-commerce platform , in NextJs, still getting built.. ⚙️

Awesome Lists containing this project

README

        

### Next App

```sh

npx create-next-app@latest store
```

```sh
npm run dev
```

### Remove Boilerplate

- in globals.css remove all code after directives
- page.tsx

```tsx
function HomePage() {
return

HomePage

;
}
export default HomePage;
```

- layout.tsx

```tsx
export const metadata: Metadata = {
title: 'Next Store',
description: 'A nifty store built with Next.js',
};
```

- get a hold of the README.MD

### Create Pages

- about
- admin
- cart
- favorites
- orders
- products
- reviews

- new file - pageName/page.tsx

```tsx
function AboutPage() {
return

AboutPage
;
}
export default AboutPage;
```

### Shadcn/ui

[Docs](https://ui.shadcn.com/)

[Next Install](https://ui.shadcn.com/docs/installation/next)

```sh
npx shadcn-ui@latest init

```

- New York
- Zinc
- CSS variables:YES

```sh
npx shadcn-ui@latest add button
```

```tsx
import { Button } from '@/components/ui/button';

function HomePage() {
return (


HomePage



Click me


);
}
export default HomePage;
```

```sh
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

### Navbar - Setup

- create

- navbar
- CartButton
- DarkMode
- LinksDropdown
- Logo
- Navbar
- NavSearch
- SignOutLink
- UserIcon

### Container Component

- create globals/Container.tsx

```tsx
import { cn } from '@/lib/utils';

function Container({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (


{children}

);
}

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.

### Navbar Component

```tsx
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 (











);
}
export default Navbar;
```

- layout.tsx

```tsx
import Navbar from '@/components/navbar/Navbar';
import Container from '@/components/global/Container';

return (



{children}


);
```

### Logo

```sh
npm install react-icons
```

[React Icons](https://react-icons.github.io/react-icons/)

Logo.tsx

```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 (





);
}

export default Logo;
```

### NavSearch Component

```tsx
import { Input } from '../ui/input';

function NavSearch() {
return (

);
}
export default NavSearch;
```

### CartButton Component

```tsx
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 (




{numItemsInCart}



);
}
export default CartButton;
```

### Theme

[Theming Options](https://ui.shadcn.com/docs/theming)
[Themes](https://ui.shadcn.com/themes)

- replace css variables in in globals.css

### Providers

- create app/providers.tsx

```tsx
'use client';

function Providers({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
export default Providers;
```

layout.tsx

```tsx
import Providers from './providers';

return (




{children}



);
```

### Shadcn DarkMode

[Next.js Dark Mode](https://ui.shadcn.com/docs/dark-mode/next)

```sh
npm install next-themes
```

- create app/theme-provider.tsx

```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 {children};
}
```

providers.tsx

```tsx
'use client';
import { ThemeProvider } from './theme-provider';

function Providers({ children }: { children: React.ReactNode }) {
return (

{children}

);
}
export default Providers;
```

### DarkMode Component

- make sure you export as default !!!

```tsx
'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 (





Toggle theme



setTheme('light')}>
Light

setTheme('dark')}>
Dark

setTheme('system')}>
System



);
}
```

### Links

- create utils/links.ts

```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' },
];
```

### LinksDropdown Component

```tsx
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 (







{links.map((link) => {
return (


{link.label}


);
})}


);
}
export default LinksDropdown;
```

### Supabase

[Docs](https://supabase.com/)

- create account and organization
- create project
- setup password in .env (optional)
- add .env to .gitignore !!!
- it will take few minutes

### Prisma

- 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.

```sh
npm install prisma --save-dev
npm install @prisma/client
```

```sh
npx prisma init
```

### Setup Instance

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

```ts
import { PrismaClient } from '@prisma/client';

const prismaClientSingleton = () => {
return new PrismaClient();
};

type PrismaClientSingleton = ReturnType;

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;
```

### Connect Supabase with Prisma

[Useful Info](https://supabase.com/partners/integrations/prisma)

- add to .env

```bash
DATABASE_URL=""
DIRECT_URL=""
```

- DATABASE_URL : Transaction + Password + "?pgbouncer=true&connection_limit=1"
- DIRECT_URL : Session + Password

```prisma
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.

```bash
npx prisma db push
```

```bash
npx prisma studio
```

## Optional - Prisma Crud

[Prisma Docs](https://www.prisma.io/docs/concepts/components/prisma-client/crud)

- Create Single Record

```js
const task = await prisma.task.create({
data: {
content: 'some task',
},
});
```

- Get All Records

```js
const tasks = await prisma.task.findMany();
```

- Get record by ID or unique identifier

```js
// By unique identifier
const user = await prisma.user.findUnique({
where: {
email: '[email protected]',
},
});

// By ID
const task = await prisma.task.findUnique({
where: {
id: id,
},
});
```

- Update Record

```js
const updateTask = await prisma.task.update({
where: {
id: id,
},
data: {
content: 'updated task',
},
});
```

- Update or create records

```js
const upsertTask = await prisma.task.upsert({
where: {
id: id,
},
update: {
content: 'some value',
},
create: {
content: 'some value',
},
});
```

- Delete a single record

```js
const deleteTask = await prisma.task.delete({
where: {
id: id,
},
});
```

### Practice Prisma Queries

about/page.tsx

```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 (


{users.map((user) => {
return (


{user.name}


);
})}

);
}
export default AboutPage;
```

### Product Model

```prisma

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

```bash
npx prisma db push
npx prisma studio
npm run dev
```

### Products JSON

- create prisma/products.json

```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"
}
]
```

### Seed File

- create prisma/seed.js

```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);
});
```

```sh
node prisma/seed
```

- check prisma studio

### Create More Components

- global

- EmptyList
- SectionTitle
- LoadingContainer

- home

- FeaturedProducts
- Hero
- HeroCarousel

- products
- FavoriteToggleButton
- FavoriteToggleForm
- ProductsContainer
- ProductsGrid
- ProductsList

### Home Page

```tsx
import FeaturedProducts from '@/components/home/FeaturedProducts';
import Hero from '@/components/home/Hero';

function HomPage() {
return (
<>


>
);
}
export default HomPage;
```

### SectionTitle Component

```tsx
import { Separator } from '@/components/ui/separator';

function SectionTitle({ text }: { text: string }) {
return (



{text}




);
}
export default SectionTitle;
```

### EmptyList Component

```tsx
import { cn } from '@/lib/utils';

function EmptyList({
heading = 'No items found.',
className,
}: {
heading?: string;
className?: string;
}) {
return

{heading}

;
}

export default EmptyList;
```

### FetchFeaturedProducts and FetchAllProducts

- create utils/actions.ts

```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',
},
});
};
```

### FeaturedProducts Component

```tsx
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 ;
return (




);
}
export default FeaturedProducts;
```

### FormatCurrency

- utils/format.ts

```ts
export const formatCurrency = (amount: number | null) => {
const value = amount || 0;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value);
};
```

### FavoriteToggleButton

```tsx
import { FaHeart } from 'react-icons/fa';
import { Button } from '@/components/ui/button';
function FavoriteToggleButton({ productId }: { productId: string }) {
return (



);
}
export default FavoriteToggleButton;
```

### ProductsGrid

```tsx
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 (


{products.map((product) => {
const { name, price, image } = product;
const productId = product.id;
const dollarsAmount = formatCurrency(price);
return (








{name}



{dollarsAmount}










);
})}

);
}
export default ProductsGrid;
```

### RemotePatterns

```mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.pexels.com',
},
],
},
};

export default nextConfig;
```

### Hero Component

```tsx
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import HeroCarousel from './HeroCarousel';

function Hero() {
return (



We are changing the way people shop



From the crowded streets of New York City🗽 to the vibrant markets of Tokyo🗼, our goal is to make shopping a more enjoyable and stress-free experience🛍️🙌🏻



Our Products




);
}
export default Hero;
```

### Product Images

[Pexels](https://www.pexels.com/)

HeroCarousel

```tsx
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 (




{carouselImages.map((image, index) => {
return (







);
})}





);
}
export default HeroCarousel;
```

### About Page

```tsx
function AboutPage() {
return (


We love

store



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?



);
}
export default AboutPage;
```

### Suspense Component

app/page.tsx

```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 (
<>

}>


>
);
}
export default HomPage;
```

### LoadingContainer Component

```tsx
import { Skeleton } from '../ui/skeleton';
import { Card, CardContent } from '../ui/card';

function LoadingContainer() {
return (






);
}

function LoadingProduct() {
return (







);
}
export default LoadingContainer;
```

### Products Page - Loading

- create app/products/loading.tsx

```tsx
'use client';

import LoadingContainer from '@/components/global/LoadingContainer';

function loading() {
return ;
}
export default loading;
```

### Products Page

```tsx
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 (
<>

>
);
}
export default ProductsPage;
```

### ProductsContainer Component

```tsx
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 */}



{totalProducts} product{totalProducts > 1 && 's'}

















{/* PRODUCTS */}

{totalProducts === 0 ? (

Sorry, no products matched your search...

) : layout === 'grid' ? (

) : (

)}

>
);
}
export default ProductsContainer;
```

### ProductsList Component

```tsx
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 (


{products.map((product) => {
const { name, price, image, company } = product;
const dollarsAmount = formatCurrency(price);
const productId = product.id;
return (







{name}


{company}




{dollarsAmount}









);
})}

);
}
export default ProductsList;
```

### NavSearch

- install use-debounce

```sh
npm i use-debounce
```

```tsx
'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 (
{
setSearch(e.target.value);
handleSearch(e.target.value);
}}
value={search}
/>
);
}
export default NavSearch;
```

### Search Argument

- refactor

ProductsContainer.tsx

```tsx
const products = await fetchAllProducts({ search });
```

- actions

```ts
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',
},
});
};
```

### Wrap NavSearch in Suspense

[useSearchParams Error](https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout)

Navbar.tsx

```tsx
import { Suspense } from 'react';

return (
<>



>
);
```

### Single Product / Single Product - Setup

- actions.ts

```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;
};
```

### Single Product - Components

- create components/single-product
- AddToCart
- BreadCrumbs
- ProductRating

AddToCart.tsx

```tsx
import { Button } from '../ui/button';

function AddToCart({ productId }: { productId: string }) {
return (

add to cart

);
}
export default AddToCart;
```

BreadCrumbs.tsx

```tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';

function BreadCrumbs({ name }: { name: string }) {
return (




home




products



{name}



);
}
export default BreadCrumbs;
```

ProductRating.tsx

```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 (


{rating} {countValue}

);
}

export default ProductRating;
```

### Single Product - Page

- create app/products/[id]/page.tsx

```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 (



{/* IMAGE FIRST COL */}



{/* PRODUCT INFO SECOND COL */}


{name}





{company}



{dollarsAmount}


{description}






);
}
export default SingleProductPage;
```

### Deploy On Vercel

- create vercel account
[Vercel](https://vercel.com)
- create github repository
- double check .gitignore
- update package.json

```json
"scripts": {
"dev": "next dev",
"build": "npx prisma generate && next build",
"start": "next start",
"lint": "next lint"
},
```

- push it up to github

```bash
git init
git add .
git commit -m "first commit"
```

- deploy on vercel
- setup env variables

### Toast Component

[Toast](https://ui.shadcn.com/docs/components/toast)

providers.tsx

```tsx
'use client';
import { ThemeProvider } from './theme-provider';
import { Toaster } from '@/components/ui/toaster';

function Providers({ children }: { children: React.ReactNode }) {
return (
<>


{children}

>
);
}
export default Providers;
```

### Clerk

[Clerk Docs](https://clerk.com/)
[Clerk + Next.js Setup](https://clerk.com/docs/quickstarts/nextjs)

- create new application

```sh
npm install @clerk/nextjs
```

- create .env.local

```bash
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
```

In Next.js, environment variables that start with NEXT*PUBLIC* 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

```tsx
import { ClerkProvider } from '@clerk/nextjs';

return (





{children}




);
```

- create middleware.ts

```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

### SignUp/SignIn and Customize Avatar (optional)

- customization
- avatars

### SignOutButton Component

```tsx
'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 (


Logout


);
}
export default SignOutLink;
```

### UserIcon Component

```tsx
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 (

);
return ;
}
export default UserIcon;
```

### LinksDropdown - Complete

```tsx
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 (











Login





Register




{links.map((link) => {
return (


{link.label}


);
})}







);
}
export default LinksDropdown;
```

### Admin Links

- utils/links.ts

```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' },
];
```

### Admin Pages

- remove existing page.tsx

- admin
- products
- [id]/edit/page.tsx
- create/page.tsx
- page.tsx
- sales/page.tsx
- layout.tsx
- Sidebar.tsx

Sidebar.tsx

```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 (

{adminLinks.map((link) => {
const isActivePage = pathname === link.href;
const variant = isActivePage ? 'default' : 'ghost';
return (


{link.label}


);
})}

);
}
export default Sidebar;
```

layout.tsx

```tsx
import { Separator } from '@/components/ui/separator';
import Sidebar from './Sidebar';

function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<>

Dashboard







{children}


>
);
}
export default DashboardLayout;
```

### Restrict Access - Middleware

```ts
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

```sh
ADMIN_USER_ID=
```

### Restrict Access - LinksDropdown

```tsx
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 (


{link.label}


);
})}
>
);
}
```

### Create Product - Setup

```tsx
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 (

create product





Product Name



Submit




);
}
export default CreateProductPage;
```

### Faker Library

```sh
npm install @faker-js/faker --save-dev
```

[Docs](https://fakerjs.dev/guide/)

```tsx
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 ;
}
export default CreateProductPage;
```

### Form Components - Setup

- components/form
- Buttons
- CheckBoxInput
- FormContainer
- FormInput
- ImageInput
- ImageInputContainer
- PriceInput
- TextAreaInput

FormInput.tsx

```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 (



{label || name}



);
}

export default FormInput;
```

### PriceInput Component

```tsx
import { Label } from '../ui/label';
import { Input } from '../ui/input';

const name = 'price';
type FormInputNumberProps = {
defaultValue?: number;
};

function PriceInput({ defaultValue }: FormInputNumberProps) {
return (



Price ($)



);
}
export default PriceInput;
```

### ImageInput Component

```tsx
import { Label } from '../ui/label';
import { Input } from '../ui/input';

function ImageInput() {
const name = 'image';
return (



Image



);
}
export default ImageInput;
```

### TextAreaInput Component

```tsx
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 (



{labelText || name}



);
}

export default TextAreaInput;
```

### CheckBoxInput Component

```tsx
'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 (




{label}


);
}
```

### Submit Button

components/form/Buttons.tsx

```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 (

{pending ? (
<>

Please wait...
>
) : (
text
)}

);
}
```

### FormContainer Component

- create utils/types.ts

```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

```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 {children};
}
export default FormContainer;
```

### Create Product Page - Complete

- actions.ts

```ts
'use server';

export const createProductAction = async (
prevState: any,
formData: FormData
): Promise<{ message: string }> => {
return { message: 'product created' };
};
```

page.tsx

```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 (

create product

















);
}
export default CreateProduct;
```

### Helper Functions

- actions.ts

```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;
};
```

### CreateProductAction - First Approach

- get/store product images in public/images

```ts
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);
}
};
```

### Problems

- lots of code code just to access input values
- no validation (only html one)

### Zod

Zod is a JavaScript library for building schemas and validating data, providing type safety and error handling.

```sh
npm install zod
```

[Docs](https://zod.dev/?id=basic-usage)

- setup utils/schemas.ts

```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

```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);
}
};
```

### Problem

- error messages are not user friendly

schemas.ts

```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.',
}
),
});
```

```ts
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' };
}
```

### ValidateWithZodSchema

schemas.ts

```ts
export function validateWithZodSchema(
schema: ZodSchema,
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

```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' };
}
```

### Image Upload

schemas.ts

```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

```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' };
}
```

### Create Bucket, Setup Policy and API Keys

```env
SUPABASE_URL=
SUPABASE_KEY=
```

### Setup Supabase

```sh
npm install @supabase/supabase-js
```

utils/supabase.ts

```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;
};
```

### Create Product Action - Complete

- actions.ts

```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

```tsx
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.pexels.com',
},
{
protocol: 'https',
hostname: 'pldbjxhkrlailuixuvhz.supabase.co',
},
],
},
};

export default nextConfig;
```

### Fetch Products - Admin

- actions.ts

```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;
};
```

### Admin Products Page

- app/admin/products/page.tsx

```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 ;
return (



total products : {items.length}



Product Name
Company
Price
Actions



{items.map((item) => {
const { id: productId, name, company, price } = item;
return (



{name}


{company}
{formatCurrency(price)}



);
})}



);
}

export default ItemsPage;
```

### Icon Button

```tsx
type actionType = 'edit' | 'delete';
export const IconButton = ({ actionType }: { actionType: actionType }) => {
const { pending } = useFormStatus();

const renderIcon = () => {
switch (actionType) {
case 'edit':
return ;
case 'delete':
return ;
default:
const never: never = actionType;
throw new Error(`Invalid action type: ${never}`);
}
};

return (

{pending ? : renderIcon()}

);
};
```

### Delete Product Action

- actions.ts

```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);
}
};
```

### Admin Products Page - Complete

```tsx
import FormContainer from '@/components/form/FormContainer';
import { IconButton } from '@/components/form/Buttons';
import { deleteProductAction } from '@/utils/actions';

return (
<>






>
);

function DeleteProduct({ productId }: { productId: string }) {
const deleteProduct = deleteProductAction.bind(null, { productId });
return (



);
}
```

### Remove Image From Supabase

- utils/supabase.ts

```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]);
};
```

```ts
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);
}
};
```

### FetchAdminProductDetails, UpdateProductAction and updateProductImageAction

- actions.ts

```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' };
};
```

### Edit Product Page

- app/admin/products/[id]/edit/page.tsx

```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 (

update product



{/* Image Input Container */}














);
}
export default EditProductPage;
```

### UpdateProductAction

actions.ts

```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);
}
};
```

### UpdateImageContainer Component

```tsx
'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 (


setUpdateFormVisible((prev) => !prev)}
>
{text}

{isUpdateFormVisible && (



{props.children}




)}

);
}
export default ImageInputContainer;
```

EditProductPage.tsx

```tsx
return (


{/* Image Input Container */}





);
```

### UpdateProductImageAction

- actions.ts

```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);
}
};
```

### LoadingTable

- create components/global/LoadingTable.tsx

```tsx
import { Skeleton } from '../ui/skeleton';

function LoadingTable({ rows = 5 }: { rows?: number }) {
const tableRows = Array.from({ length: rows }, (_, index) => {
return (




);
});
return <>{tableRows}>;
}
export default LoadingTable;
```

- create admin/products/loading.tsx

```tsx
'use client';

import LoadingTable from '@/components/global/LoadingTable';

function loading() {
return ;
}
export default loading;
```

### Favorite Model

```prisma
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
}
```

```sh
npx prisma db push
```

- restart server

### CardSignIn Button

- components/form/Buttons.tsx

```tsx
export const CardSignInButton = () => {
return (





);
};

export const CardSubmitButton = ({ isFavorite }: { isFavorite: boolean }) => {
const { pending } = useFormStatus();
return (

{pending ? (

) : isFavorite ? (

) : (

)}

);
};
```

### FetchFavoriteId

- actions.ts

```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' };
};
```

### FavoriteToggleButton

- components/products/FavoriteToggleButton.tsx

```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 ;
const favoriteId = await fetchFavoriteId({ productId });

return ;
}
export default FavoriteToggleButton;
```

### FavoriteToggleForm

```tsx
'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 (



);
}
export default FavoriteToggleForm;
```

### FavoriteToggleForm

- actions.ts

```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

### FetchUserFavorites

```ts
export const fetchUserFavorites = async () => {
const user = await getAuthUser();
const favorites = await db.favorite.findMany({
where: {
clerkId: user.id,
},
include: {
product: true,
},
});
return favorites;
};
```

### Favorites Page

- favorites/loading.tsx

```tsx
'use client';

import LoadingContainer from '@/components/global/LoadingContainer';

function loading() {
return ;
}
export default loading;
```

page.tsx

```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 ;
return (



favorite.product)} />

);
}

export default FavoritesPage;
```

### React Share

[React Share](https://www.npmjs.com/package/react-share)

```sh
npm i react-share
```

- create NEXT_PUBLIC_WEBSITE_URL in .env
- get url from vercel

components/single-product/ShareButton.tsx

```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 (


















);
}
export default ShareButton;
```

- products/[id]/page.tsx

```tsx
import ShareButton from '@/components/single-product/ShareButton';

return (


{name}







);
```

### Review Model

```prisma
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
}
```

```sh
npx prisma db push
```

- restar the server

### Review Components and Actions

- actions.ts

```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

### RatingInput Component

```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 (



{labelText || name}






{numbers.map((number) => {
return (

{number}

);
})}



);
};

export default RatingInput;
```

### SubmitReview Component

```tsx
'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 (


setIsReviewFormVisible((prev) => !prev)}
>
leave review

{isReviewFormVisible && (










)}

);
}

export default SubmitReview;
```

- render in app/products/[id]/page.tsx after second column

```tsx
import SubmitReview from '@/components/reviews/SubmitReview';
import ProductReviews from '@/components/reviews/ProductReviews';

return (
<>


>
);
```

### Create Review Action

- schemas.ts

```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

```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);
}
};
```

### Rating Component

```tsx
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 (


{stars.map((isFilled, i) => {
const className = `w-3 h-3 ${
isFilled ? 'text-primary' : 'text-gray-400'
}`;
return isFilled ? (

) : (

);
})}

);
}

export default Rating;
```

### Comment Component

```tsx
'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 (


{displayComment}


{longComment && (

{isExpanded ? 'Show Less' : 'Show More'}

)}

);
}

export default Comment;
```

### Fetch Product Reviews

```ts
export const fetchProductReviews = async (productId: string) => {
const reviews = await db.review.findMany({
where: {
productId,
},
orderBy: {
createdAt: 'desc',
},
});
return reviews;
};
```

### Product Reviews

```tsx
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 (



{reviews.map((review) => {
const { comment, rating, authorImageUrl, authorName } = review;
const reviewInfo = {
comment,
rating,
image: authorImageUrl,
name: authorName,
};
return ;
})}


);
}
export default ProductReviews;
```

### ReviewCard

```tsx
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 (






{reviewInfo.name}









{children}


);
}
export default ReviewCard;
```

- next.config.mjs

```mjs
{
protocol: 'https',
hostname: 'img.clerk.com',
},
```

### fetchProductRating

```ts
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,
};
};
```

### ProductRating

- components/single-product/ProductRating.tsx

```tsx
const { rating, count } = await fetchProductRating(productId);
```

### FetchProductReviewsByUser and DeleteReview Action

```ts
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);
}
};
```

### Reviews Page

- setup "reviews" link in utils/links.ts
- create app/reviews/page.tsx and app/reviews/loading.tsx

page.tsx

```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 ;

return (
<>


{reviews.map((review) => {
const { comment, rating } = review;
const { name, image } = review.product;
const reviewInfo = {
comment,
rating,
name,
image,
};
return (



);
})}

>
);
}

const DeleteReview = ({ reviewId }: { reviewId: string }) => {
const deleteReview = deleteReviewAction.bind(null, { reviewId });
return (



);
};

export default ReviewsPage;
```

loading.tsx

```tsx
'use client';

import { Card, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function loading() {
return (




);
}

const ReviewLoadingCard = () => {
return (











);
};

export default loading;
```

### Restrict Access

actions.ts

```ts
export const findExistingReview = async (userId: string, productId: string) => {
return db.review.findFirst({
where: {
clerkId: userId,
productId,
},
});
};
```

- app/products/[id]/page.tsx

```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 (
<>

{reviewDoesNotExist && }
>
);
}
```

### Cart and CartItem Model

- prisma/schema.prisma

```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

```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 () => {};
```

### FetchCartItems

- actions.ts

```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

```tsx
async function CartButton() {
const numItemsInCart = await fetchCartItems();
}
```

### ProductSignInButton Component

- components/form/Buttons.tsx

```tsx
export const ProductSignInButton = () => {
return (


Please Sign In


);
};
```

### SelectProductAmount Component

- create components/single-product/SelectProductAmount.tsx

```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;
isLoading: boolean;
};

function SelectProductAmount(
props: SelectProductAmountProps | SelectCartItemAmountProps
) {
const { mode, amount, setAmount } = props;

const cartItem = mode === Mode.CartItem;

return (
<>

Amount :


setAmount(Number(value))}
disabled={cartItem ? props.isLoading : false}
>




{Array.from({ length: cartItem ? amount + 10 : 10 }, (_, index) => {
const selectValue = (index + 1).toString();
return (

{selectValue}

);
})}


>
);
}
export default SelectProductAmount;
```

### AddToCart Component

```tsx
'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 (



{userId ? (





) : (

)}

);
}
export default AddToCart;
```

### AddToCart Action

- actions.ts

```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');
};
```

### Cart Page

- create components/cart

- CartItemColumns.tsx
- CartItemsList.tsx
- CartTotals.tsx
- ThirdColumn.tsx

- app/cart/page.tsx

```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 ;
}
return (
<>









>
);
}
export default CartPage;
```

### CartTotals Component

```tsx
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 (














);
}

function CartTotalRow({
label,
amount,
lastRow,
}: {
label: string;
amount: number;
lastRow?: boolean;
}) {
return (
<>


{label}
{formatCurrency(amount)}


{lastRow ? null : }
>
);
}

export default CartTotals;
```

### Cart Item Columns - 1,2 and 4

- cart/CartItemColumns.tsx

```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 (




);
};
export const SecondColumn = ({
name,
company,
productId,
}: {
name: string;
company: string;
productId: string;
}) => {
return (


{name}



{company}



);
};

export const FourthColumn = ({ price }: { price: number }) => {
return

{formatCurrency(price)}

;
};
```

### CartItemsList Component

- utils/types.ts

```ts
import { Prisma } from '@prisma/client';

export type CartItemWithProduct = Prisma.CartItemGetPayload<{
include: { product: true };
}>;
```

```tsx
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 (


{cartItems.map((cartItem) => {
const { id, amount } = cartItem;
const { id: productId, image, name, company, price } = cartItem.product;
return (






);
})}

);
}
export default CartItemsList;
```

### Cart Item - Third Column

- optional

actions.ts

```ts
export const removeCartItemAction = async (
prevState: any,
formData: FormData
) => {
return { message: 'Item removed from cart' };
};
```

```tsx
'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 (








);
}
export default ThirdColumn;
```

### RemoveCartItem Action

- actions.ts

```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);
}
};
```

### UpdateCartItem Action

- actions.ts

```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);
}
};
```

### Cart Item Third Column - Complete

```tsx
'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 (








);
}
export default ThirdColumn;
```

### Bug Fix

- make CartItemsList client component ('use client' directive)

- refactor updateCart

actions.ts

```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

```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 ;
}
return (
<>









>
);
}
export default CartPage;
```

### Order Model

```prisma
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
}
```

### Order Actions

```ts
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;
};
```

### Orders Page

- utils/format.ts

```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

```tsx
'use client';

import LoadingTable from '@/components/global/LoadingTable';

function loading() {
return ;
}
export default loading;
```

- app/orders/page.tsx

```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 (
<>



Total orders : {orders.length}


Products
Order Total
Tax
Shipping
Date



{orders.map((order) => {
const { id, products, orderTotal, tax, shipping, createdAt } =
order;

return (

{products}
{formatCurrency(orderTotal)}
{formatCurrency(tax)}
{formatCurrency(shipping)}
{formatDate(createdAt)}

);
})}



>
);
}
export default OrdersPage;
```

### Admin - Sales Page

- create app/admin/sales/loading.tsx

```tsx
'use client';

import LoadingTable from '@/components/global/LoadingTable';

function loading() {
return ;
}
export default loading;
```

- app/admin/sales/page.tsx

```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 (



Total orders : {orders.length}


Email
Products
Order Total
Tax
Shipping
Date



{orders.map((order) => {
const {
id,
products,
orderTotal,
tax,
shipping,
createdAt,
email,
} = order;

return (

{email}
{products}
{formatCurrency(orderTotal)}
{formatCurrency(tax)}
{formatCurrency(shipping)}
{formatDate(createdAt)}

);
})}



);
}
export default SalesPage;
```

### Stripe

[Embedded Form](https://docs.stripe.com/checkout/embedded/quickstart)

- setup and add keys to .env

```sh
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
```

- install

```sh
npm install --save @stripe/react-stripe-js @stripe/stripe-js stripe axios
```

### Refactor Order and createOrderAction

```prisma
model Order {
isPaid Boolean @default(false)
}
```

```ts
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}`);
};
```

### Stripe ClientSecret Fetch Call and Response Diagram

```plaintext
+--------+ Fetch clientSecret +--------+ Request +---------+
| Client | -----------------------> | Server | ---------------> | Stripe |
| | | | | API |
| | | | <--------------- | |
| | <----------------------- | | clientSecret | |
| | clientSecret response | | | |
+--------+ +--------+ +---------+

Checkout.tsx payment/route.ts

```

### Checkout Page

- create app/checkout/page.tsx

```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 (






);
}
```

### API - Payment Route

- create api/payment/route.ts

```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

```ts
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
},
};
```

```plaintext
+--------+ Checkout Session ID +--------+ redirect +---------+
| Server | -----------------------> | Server | ---------------> | Orders |
| | | | | |
| | | | | |
| | | | | |
| | | | | |
+--------+ +--------+ +---------+

payment/route.ts confirm/route.ts orders page

```

### API - Confirm Route

```ts
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');
};
```