Showing preview only (332K chars total). Download the full file or copy to clipboard to get everything.
Repository: bradtraversy/prostore
Branch: main
Commit: 05cc53e46630
Files: 134
Total size: 299.6 KB
Directory structure:
gitextract_eys7q486/
├── .eslintrc.json
├── .example-env
├── .gitignore
├── .prettierrc.yaml
├── README.md
├── app/
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── sign-in/
│ │ │ ├── credentials-signin-form.tsx
│ │ │ └── page.tsx
│ │ └── sign-up/
│ │ ├── page.tsx
│ │ └── sign-up-form.tsx
│ ├── (root)/
│ │ ├── cart/
│ │ │ ├── cart-table.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── order/
│ │ │ └── [id]/
│ │ │ ├── order-details-table.tsx
│ │ │ ├── page.tsx
│ │ │ ├── stripe-payment-success/
│ │ │ │ └── page.tsx
│ │ │ └── stripe-payment.tsx
│ │ ├── page.tsx
│ │ ├── payment-method/
│ │ │ ├── page.tsx
│ │ │ └── payment-method-form.tsx
│ │ ├── place-order/
│ │ │ ├── page.tsx
│ │ │ └── place-order-form.tsx
│ │ ├── product/
│ │ │ └── [slug]/
│ │ │ ├── page.tsx
│ │ │ ├── review-form.tsx
│ │ │ └── review-list.tsx
│ │ ├── search/
│ │ │ └── page.tsx
│ │ └── shipping-address/
│ │ ├── page.tsx
│ │ └── shipping-address-form.tsx
│ ├── admin/
│ │ ├── layout.tsx
│ │ ├── main-nav.tsx
│ │ ├── orders/
│ │ │ └── page.tsx
│ │ ├── overview/
│ │ │ ├── charts.tsx
│ │ │ └── page.tsx
│ │ ├── products/
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx
│ │ │ ├── create/
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── users/
│ │ ├── [id]/
│ │ │ ├── page.tsx
│ │ │ └── update-user-form.tsx
│ │ └── page.tsx
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts
│ │ ├── uploadthing/
│ │ │ ├── core.ts
│ │ │ └── route.ts
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── layout.tsx
│ ├── loading.tsx
│ ├── not-found.tsx
│ ├── unauthorized/
│ │ └── page.tsx
│ └── user/
│ ├── layout.tsx
│ ├── main-nav.tsx
│ ├── orders/
│ │ └── page.tsx
│ └── profile/
│ ├── page.tsx
│ └── profile-form.tsx
├── assets/
│ └── styles/
│ └── globals.css
├── auth.config.ts
├── auth.ts
├── components/
│ ├── admin/
│ │ ├── admin-search.tsx
│ │ └── product-form.tsx
│ ├── deal-countdown.tsx
│ ├── footer.tsx
│ ├── icon-boxes.tsx
│ ├── shared/
│ │ ├── checkout-steps.tsx
│ │ ├── delete-dialog.tsx
│ │ ├── header/
│ │ │ ├── category-drawer.tsx
│ │ │ ├── index.tsx
│ │ │ ├── menu.tsx
│ │ │ ├── mode-toggle.tsx
│ │ │ ├── search.tsx
│ │ │ └── user-button.tsx
│ │ ├── pagination.tsx
│ │ └── product/
│ │ ├── add-to-cart.tsx
│ │ ├── product-card.tsx
│ │ ├── product-carousel.tsx
│ │ ├── product-images.tsx
│ │ ├── product-list.tsx
│ │ ├── product-price.tsx
│ │ └── rating.tsx
│ ├── ui/
│ │ ├── alert-dialog.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── radio-group.tsx
│ │ ├── select.tsx
│ │ ├── sheet.tsx
│ │ ├── table.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ └── toaster.tsx
│ └── view-all-products-button.tsx
├── components.json
├── db/
│ ├── prisma.ts
│ ├── sample-data.ts
│ └── seed.ts
├── email/
│ ├── index.tsx
│ └── purchase-receipt.tsx
├── hooks/
│ └── use-toast.ts
├── jest.config.ts
├── jest.setup.ts
├── lib/
│ ├── actions/
│ │ ├── cart.actions.ts
│ │ ├── order.actions.ts
│ │ ├── product.actions.ts
│ │ ├── review.actions.ts
│ │ └── user.actions.ts
│ ├── auth-guard.ts
│ ├── constants/
│ │ └── index.ts
│ ├── encrypt.ts
│ ├── paypal.ts
│ ├── uploadthing.ts
│ ├── utils.ts
│ └── validators.ts
├── middleware.ts
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── prisma/
│ ├── migrations/
│ │ ├── 20241116125832_init/
│ │ │ └── migration.sql
│ │ ├── 20241118183645_add_user_based_tables/
│ │ │ └── migration.sql
│ │ ├── 20241121210251_add_cart/
│ │ │ └── migration.sql
│ │ ├── 20241125173259_add_order/
│ │ │ └── migration.sql
│ │ ├── 20241205162619_add_featured_default/
│ │ │ └── migration.sql
│ │ ├── 20241209181915_add_review/
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
├── tailwind.config.ts
├── tests/
│ └── paypal.test.ts
├── tsconfig.json
└── types/
├── index.ts
└── next-auth.d.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
================================================
FILE: .example-env
================================================
NEXT_PUBLIC_APP_NAME = "Prostore"
NEXT_PUBLIC_APP_DESCRIPTION = "A modern ecommerce store built with Next.js"
NEXT_PUBLIC_SERVER_URL = "http://localhost:3000"
DATABASE_URL=""
NEXTAUTH_SECRET=""
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL_INTERNAL=http://localhost:3000
PAYMENT_METHODS="PayPal, Stripe, CashOnDelivery"
DEFAULT_PAYMENT_METHOD="PayPal"
PAYPAL_API_URL="https://api-m.sandbox.paypal.com"
PAYPAL_CLIENT_ID=""
PAYPAL_APP_SECRET=""
UPLOADTHING_TOKEN=''
UPLOADTHING_SECRET=""
UPLOADTHING_APPID=""
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
STRIPE_SECRET_KEY=""
RESEND_API_KEY=""
SENDER_EMAIL="onboarding@resend.dev"
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
================================================
FILE: .prettierrc.yaml
================================================
printWidth: 80
tabWidth: 2
useTabs: false
semi: true
singleQuote: true
bracketSpacing: true
jsxBracketSameLine: false
jsxSingleQuote: true
trailingComma: es5
arrowFunctionParentheses: avoid
================================================
FILE: README.md
================================================
# Prostore
A full featured Ecommerce website built with Next.js, TypeScript, PostgreSQL and Prisma.
<img src="/public/images/screen.png" alt="Next.js Ecommerce" />
This project is from my **Next.js Ecommerce course**
- Traversy Media: [https://www.traversymedia.com/nextjs-ecommerce](https://www.traversymedia.com/nextjs-ecommerce)
- Udemy: [https://www.udemy.com/course/nextjs-ecommerce-course](https://www.udemy.com/course/nextjs-ecommerce-course)
## Table of Contents
<!--toc:start-->
- [Features](#features)
- [Usage](#usage)
- [Install Dependencies](#install-dependencies)
- [Environment Variables](#environment-variables)
- [PostgreSQL Database URL](#postgresql-database-url)
- [Next Auth Secret](#next-auth-secret)
- [PayPal Client ID and Secret](#paypal-client-id-and-secret)
- [Stripe Publishable and Secret Key](#stripe-publishable-and-secret-key)
- [Uploadthing Settings](#uploadthing-settings)
- [Resend API Key](#resend-api-key)
- [Run](#run)
- [Prisma Studio](#prisma-studio)
- [Seed Database](#seed-database)
- [Demo](#demo)
- [Bug Fixes And Course FAQ](#bug-fixes-and-course-faq)
- [Fix: Edge Function Middleware Limitations on Vercel](#fix-edge-function-middleware-limitations-on-vercel)
- [Bug: A newly logged in user can inherit the previous users cart](#bug-a-newly-logged-in-user-can-inherit-the-previous-users-cart)
- [Bug: Any user can see another users order](#bug-any-user-can-see-another-users-order)
- [Bug: Cart add and remove buttons share loading animation](#bug-cart-add-and-remove-buttons-share-loading-animation)
- [FAQ: Why are we using a JS click event in not-found](#faq-why-are-we-using-a-js-click-event-in-not-found)
- [Fix: TypeScript no-explicit-any in auth.ts](#fix-typescript-no-explicit-any-in-authts)
- [TailwindCSS Update: Breaking Changes](#tailwindcss-update-breaking-changes)
- [Option 1: Stick with Tailwind v3 (Matches the Course)](#option-1-stick-with-tailwind-v3-matches-the-course)
- [Option 2: Use Tailwind v4 (Updated Code Available, this seems to be the smoothest option)](#option-2-use-tailwind-v4-updated-code-available-this-seems-to-be-the-smoothest-option)
- [Changes Needed for Tailwind v4:](#changes-needed-for-tailwind-v4)
- [Migrating from Tailwind v3 to v4 Mid-Course?](#migrating-from-tailwind-v3-to-v4-mid-course)
- [:link: Upgrade Guide](#link-upgrade-guide)
- [License](#license)
<!--toc:end-->
## Features
- Next Auth authentication
- Admin area with stats & chart using Recharts
- Order, product and user management
- User area with profile and orders
- Stripe API integration
- PayPal integration
- Cash on delivery option
- Interactive checkout process
- Featured products with banners
- Multiple images using Uploadthing
- Ratings & reviews system
- Search form (customer & admin)
- Sorting, filtering & pagination
- Dark/Light mode
- Much more
## Usage
### Install Dependencies
```bash
npm install
```
Note: Some dependencies may have not yet been upadated to support React 19. If you get any errors about depencency compatability, run the following:
```bash
npm install --legacy-peer-deps
```
### Environment Variables
Rename the `.example-env` file to `.env` and add the following
#### PostgreSQL Database URL
Sign up for a free PostgreSQL database through Vercel. Log into Vercel and click on "Storage" and create a new Postgres database. Then add the URL.
**Example:**
```
DATABASE_URL="postgresql://username:password@host:port/dbname"
```
#### Next Auth Secret
Generate a secret with the following command and add it to your `.env`:
```bash
openssl rand -base64 32
```
**Example:**
```
NEXTAUTH_SECRET="xmVpackzg9sdkEPzJsdGse3dskUY+4ni2quxvoK6Go="
```
#### PayPal Client ID and Secret
Create a PayPal developer account and create a new app to get the client ID and secret.
**Example:**
```
PAYPAL_CLIENT_ID="AeFIdonfA_dW_ncys8G4LiECWBI9442IT_kRV15crlmMApC6zpb5Nsd7zlxj7UWJ5FRZtx"
PAYPAL_APP_SECRET="REdG53DEeX_ShoPawzM4vQHCYy0a554G3xXmzSxFCDcSofBBTq9VRqjs6xsNVBcbjqz--HiiGoiV"
```
#### Stripe Publishable and Secret Key
Create a Stripe account and get the publishable and secret key.
**Example:**
```
STRIPE_SECRET_KEY="sk_test_51QIr0IG87GyTererxmXxEeqV6wuzbmC0TpkRzabxqy3P4BpzpzDqnQaC1lZhmYg6IfNarnvpnbjjw5dsBq4afd0FXkeDriR"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_51QIr0Ids7GyT6H7X6R5GoEA68lYDcbcC94VU0U02SMkrrrYZT2CgSMZ1h22udb5Rg1AuonXyjmAQZESLLj100W3VGVwze"
```
#### Uploadthing Settings
Sign up for an account at https://uploadthing.com/ and get the token, secret and app ID.
**Example:**
```
UPLOADTHING_TOKEN='tyJhcGlLZXkiOiJza19saXZlXzQ4YTE2ZjhiMDE5YmFiOgrgOWQ4MmYxMGQxZGU2NTM3YzlkZGI3YjNiZDk3MmRhNGZmNGMwMmJlOWI2Y2Q0N2UiLCJhcHBJZCI6InRyejZ2NHczNzUiLCJyZWdpb25zIjpbInNlYTEiXX0='
UPLOADTHIUG_SECRET='gg'
UPLOADTHING_APPID='trz6vd475'
```
#### Resend API Key
Sign up for an account at https://resend.io/ and get the API key.
**Example:**
```
RESEND_API_KEY="re_ZnhUfrjR_QD2cDqdee3iYCrkfvPYFCYiXm"
```
### Run
```bash
# Run in development mode
npm run dev
# Build for production
npm run build
# Run in production mode
npm start
# Export static site
npm run export
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Prisma Studio
To open Prisma Studio, run the following command:
```bash
npx prisma studio
```
## Seed Database
To seed the database with sample data, run the following command:
```bash
npx tsx ./db/seed
```
## Demo
I am not sure how long I will have this demo up but you can view it here:
[ https://prostore-one.vercel.app/ ](https://prostore-one.vercel.app/)
## Bug Fixes And Course FAQ
### Fix: Edge Function Middleware Limitations on Vercel
After deploying your app you may be getting a build error along the lines of:
> The Edge Function "middleware size is 1.03 MB and your plan size limit is 1MB
For the solution to resolve this please see Brads [Gist here](https://gist.github.com/bradtraversy/16e3c89b9b25bc79cf86f5f36e14e83d)
There is also a new lesson added for this fix at the end of the course -
**Vercel Hobby Tier Fix**
### Bug: A newly logged in user can inherit the previous users cart
If a logged in user adds items to their cart and logs out then a different user
logs in on the same machine, they will inherit the first users cart.
To fix this we can delete the current users **Cart** from the database in our **lib/actions/user.actions.ts** `signOutUser` action.
> Changes can be seen in [lib/actions/user.actions.ts](https://github.com/bradtraversy/prostore/blob/a498d4362d1485b2bd3152124cb5c3a75f8fdd70/lib/actions/user.actions.ts#L45)
### Bug: Any user can see another users order
If a user knows the `Order.id` of another users order it is possible for them to
visit **/order/<Order.id>** and see that other users order. This isn't likely to
happen in reality but should be something we protect against by redirecting the
user to our **/unauthorized** page if they are not the owner of the order.
In **app/(root)/order/[id]/page.tsx** we can import the `redirect` function from Next:
```ts
import { notFound, redirect } from 'next/navigation';
```
Then check if the user is the owner of the order and redirect them if not:
```ts
// Redirect the user if they don't own the order
if (order.userId !== session?.user.id && session?.user.role !== 'admin') {
return redirect('/unauthorized');
}
```
> Changes can be seen in [app/(root)/order/[id]/page.tsx](<https://github.com/bradtraversy/prostore/blob/main/app/(root)/order/%5Bid%5D/page.tsx>)
### Bug: Cart add and remove buttons share loading animation
On our **/cart** page you may notice that when you increment or decrement the
quantity of an item in the cart, then the loader shows for all buttons after we
click. This is because all the buttons use the same **pending** state from our
use of `useTransition` in our [app/(root)/cart/cart-table.tsx](<https://github.com/bradtraversy/prostore/blob/main/app/(root)/cart/cart-table.tsx>)
We can solve this by breaking out the Buttons into their own `AddButton` and
`RemoveButton` components, each using their own `useTransition` and so having
their own **pending** state.
You can if you wish move these components to their own files/modules but for
ease of following along they can be seen in the same file.
> Changes can be seen in [app/(root)/cart/cart-table.tsx](<https://github.com/bradtraversy/prostore/blob/main/app/(root)/cart/cart-table.tsx>)
### FAQ: Why are we using a JS click event in not-found
In our [app/not-found.tsx](https://github.com/bradtraversy/prostore/blob/main/app/not-found.tsx) we currently have:
```tsx
<Button
variant='outline'
className='mt-4 ml-2'
onClick={() => (window.location.href = '/')}
>
Back To Home
</Button>
```
So we navigate the user back to the home page with a JavaScript click event,
but this should really be a `<a />` (link) instead.
So we can change the code to:
```tsx
<Button variant='outline' className='mt-4 ml-2' asChild>
<Link href='/'>Back To Home</Link>
</Button>
```
> Changes can be seen in [app/not-found.tsx](https://github.com/bradtraversy/prostore/blob/main/app/not-found.tsx)
### Fix: TypeScript no-explicit-any in auth.ts
You may be seeing warnings from TS in your **auth.ts** and **auth.config.ts**
about using the `any` Type.
Normally the Types are inferred from NextAuth, and you don't need to do anything.
Here however it's `any` because we added in other properties to the `JWT`, `User` and the `Session` Types, namely **role**, **sub** and **name**.
So because the callbacks no longer match the built in types, then TS defaults to `any`
The correct way to remedy it would be to tell TS about those additions by [ Augmenting ](https://next-auth.js.org/getting-started/typescript#module-augmentation) the **NextAuth** types.
So if you haven't already then you would need to create a **types/next-auth.d.ts** file with the following:
```ts
import { DefaultSession } from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import NextAuth from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { JWT } from 'next-auth/jwt';
declare module 'next-auth/jwt' {
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
interface JWT {
sub: string;
role: string;
name: string;
}
}
declare module 'next-auth' {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: {
role: string;
} & DefaultSession['user'];
}
interface User {
role: string;
}
}
```
This augments the built in types so TS will know about our modifications.
You can then remove the use of the `any` type in **auth.ts** and **auth.config.ts**.
You will also need to define the `config` object directly in the `NextAuth`
constructor, rather than creating the config object first.
> Changes can be seen in:
- [auth.ts](https://github.com/bradtraversy/prostore/blob/main/auth.ts)
- [auth.config.ts](https://github.com/bradtraversy/prostore/blob/main/auth.config.ts)
- [types/next-auth.d.ts](https://github.com/bradtraversy/prostore/blob/main/types/next-auth.d.ts)
## TailwindCSS Update: Breaking Changes
Many of you are running into issues following the course because **TailwindCSS recently had a major update**.
By default, you'll install the latest version (**Tailwind v4**), but the course was recorded with **Tailwind v3**.
### Option 1: Stick with Tailwind v3 (Matches the Course)
If you want to follow the course exactly, you should install **Tailwind v3** and refer to the v3 docs:
:link: **[Tailwind v3 Setup for Next.js](https://v3.tailwindcss.com/docs/guides/nextjs)**
Make sure your **tailwind.config.ts** matches [this file](https://github.com/bradtraversy/prostore/blob/main/tailwind.config.ts)
### Option 2: Use Tailwind v4 (Updated Code Available, this seems to be the smoothest option)
If you'd rather use **Tailwind v4**, there is a **`tailwind4`** branch of this repository where you can grab the updated code:
:link: **[Updated Repo](https://github.com/bradtraversy/prostore/tree/tailwind4)**
### Changes Needed for Tailwind v4:
- **Delete** `tailwind.config.ts` (if it exists).
- **Update** `globals.css` to match [this file](https://github.com/bradtraversy/prostore/blob/tailwind4/assets/styles/globals.css).
- **Update** `postcss.config.mjs` to match [this file](https://github.com/bradtraversy/prostore/blob/tailwind4/postcss.config.mjs)
- If you're using the latest Next.js, these should be the only changes required.
- Make sure you have the `tailwindcss-animate` package installed - `npm i tailwindcss-animate`
### Migrating from Tailwind v3 to v4 Mid-Course?
If you've already started the course with **Tailwind v3**, some **Radix UI components may break** due to class name changes.
The easiest fix is to use Tailwind's migration tool:
```sh
npx @tailwindcss/upgrade
```
### :link: Upgrade Guide
If you use the migration tool, you don't need to manually:
- :white_check_mark: Update globals.css (the tool handles it).
- :white_check_mark: Delete tailwind.config.ts.
If you run into issues, please post over on **Discord** or in the **Udemy Q&A**
for the course.
## License
MIT License
Copyright (c) [2025] [Traversy Media]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall
================================================
FILE: app/(auth)/layout.tsx
================================================
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <div className='flex-center min-h-screen w-full'>{children}</div>;
}
================================================
FILE: app/(auth)/sign-in/credentials-signin-form.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { signInDefaultValues } from '@/lib/constants';
import Link from 'next/link';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { signInWithCredentials } from '@/lib/actions/user.actions';
import { useSearchParams } from 'next/navigation';
const CredentialsSignInForm = () => {
const [data, action] = useActionState(signInWithCredentials, {
success: false,
message: '',
});
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
const SignInButton = () => {
const { pending } = useFormStatus();
return (
<Button disabled={pending} className='w-full' variant='default'>
{pending ? 'Signing In...' : 'Sign In'}
</Button>
);
};
return (
<form action={action}>
<input type='hidden' name='callbackUrl' value={callbackUrl} />
<div className='space-y-6'>
<div>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
name='email'
type='email'
required
autoComplete='email'
defaultValue={signInDefaultValues.email}
/>
</div>
<div>
<Label htmlFor='password'>Password</Label>
<Input
id='password'
name='password'
type='password'
required
autoComplete='password'
defaultValue={signInDefaultValues.password}
/>
</div>
<div>
<SignInButton />
</div>
{data && !data.success && (
<div className='text-center text-destructive'>{data.message}</div>
)}
<div className='text-sm text-center text-muted-foreground'>
Don't have an account?{' '}
<Link href='/sign-up' target='_self' className='link'>
Sign Up
</Link>
</div>
</div>
</form>
);
};
export default CredentialsSignInForm;
================================================
FILE: app/(auth)/sign-in/page.tsx
================================================
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Metadata } from 'next';
import Link from 'next/link';
import Image from 'next/image';
import { APP_NAME } from '@/lib/constants';
import CredentialsSignInForm from './credentials-signin-form';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'Sign In',
};
const SignInPage = async (props: {
searchParams: Promise<{
callbackUrl: string;
}>;
}) => {
const { callbackUrl } = await props.searchParams;
const session = await auth();
if (session) {
return redirect(callbackUrl || '/');
}
return (
<div className='w-full max-w-md mx-auto'>
<Card>
<CardHeader className='space-y-4'>
<Link href='/' className='flex-center'>
<Image
src='/images/logo.svg'
width={100}
height={100}
alt={`${APP_NAME} logo`}
priority={true}
/>
</Link>
<CardTitle className='text-center'>Sign In</CardTitle>
<CardDescription className='text-center'>
Sign in to your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<CredentialsSignInForm />
</CardContent>
</Card>
</div>
);
};
export default SignInPage;
================================================
FILE: app/(auth)/sign-up/page.tsx
================================================
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Metadata } from 'next';
import Link from 'next/link';
import Image from 'next/image';
import { APP_NAME } from '@/lib/constants';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import SignUpForm from './sign-up-form';
export const metadata: Metadata = {
title: 'Sign Up',
};
const SignUpPage = async (props: {
searchParams: Promise<{
callbackUrl: string;
}>;
}) => {
const { callbackUrl } = await props.searchParams;
const session = await auth();
if (session) {
return redirect(callbackUrl || '/');
}
return (
<div className='w-full max-w-md mx-auto'>
<Card>
<CardHeader className='space-y-4'>
<Link href='/' className='flex-center'>
<Image
src='/images/logo.svg'
width={100}
height={100}
alt={`${APP_NAME} logo`}
priority={true}
/>
</Link>
<CardTitle className='text-center'>Create Account</CardTitle>
<CardDescription className='text-center'>
Enter your information below to sign up
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<SignUpForm />
</CardContent>
</Card>
</div>
);
};
export default SignUpPage;
================================================
FILE: app/(auth)/sign-up/sign-up-form.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { signUpDefaultValues } from '@/lib/constants';
import Link from 'next/link';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { signUpUser } from '@/lib/actions/user.actions';
import { useSearchParams } from 'next/navigation';
const SignUpForm = () => {
const [data, action] = useActionState(signUpUser, {
success: false,
message: '',
});
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
const SignUpButton = () => {
const { pending } = useFormStatus();
return (
<Button disabled={pending} className='w-full' variant='default'>
{pending ? 'Submitting...' : 'Sign Up'}
</Button>
);
};
return (
<form action={action}>
<input type='hidden' name='callbackUrl' value={callbackUrl} />
<div className='space-y-6'>
<div>
<Label htmlFor='email'>Name</Label>
<Input
id='name'
name='name'
type='text'
autoComplete='name'
defaultValue={signUpDefaultValues.name}
/>
</div>
<div>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
name='email'
type='text'
autoComplete='email'
defaultValue={signUpDefaultValues.email}
/>
</div>
<div>
<Label htmlFor='password'>Password</Label>
<Input
id='password'
name='password'
type='password'
required
autoComplete='password'
defaultValue={signUpDefaultValues.password}
/>
</div>
<div>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
<Input
id='confirmPassword'
name='confirmPassword'
type='password'
required
autoComplete='confirmPassword'
defaultValue={signUpDefaultValues.confirmPassword}
/>
</div>
<div>
<SignUpButton />
</div>
{data && !data.success && (
<div className='text-center text-destructive'>{data.message}</div>
)}
<div className='text-sm text-center text-muted-foreground'>
Already have an account?{' '}
<Link href='/sign-in' target='_self' className='link'>
Sign In
</Link>
</div>
</div>
</form>
);
};
export default SignUpForm;
================================================
FILE: app/(root)/cart/cart-table.tsx
================================================
'use client';
import { useRouter } from 'next/navigation';
import { useToast } from '@/hooks/use-toast';
import { useTransition } from 'react';
import { addItemToCart, removeItemFromCart } from '@/lib/actions/cart.actions';
import { ArrowRight, Loader, Minus, Plus } from 'lucide-react';
import { Cart, CartItem } from '@/types';
import Link from 'next/link';
import Image from 'next/image';
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
TableCell,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { formatCurrency } from '@/lib/utils';
// NOTE: The code here has changed from the original course code so that the
// Buttons no longer share the same state and show the loader independently from
// other items in the cart
function AddButton({ item }: { item: CartItem }) {
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
return (
<Button
disabled={isPending}
variant='outline'
type='button'
onClick={() =>
startTransition(async () => {
const res = await addItemToCart(item);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
}
})
}
>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<Plus className='w-4 h-4' />
)}
</Button>
);
}
function RemoveButton({ item }: { item: CartItem }) {
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
return (
<Button
disabled={isPending}
variant='outline'
type='button'
onClick={() =>
startTransition(async () => {
const res = await removeItemFromCart(item.productId);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
}
})
}
>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<Minus className='w-4 h-4' />
)}
</Button>
);
}
const CartTable = ({ cart }: { cart?: Cart }) => {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<>
<h1 className='py-4 h2-bold'>Shopping Cart</h1>
{!cart || cart.items.length === 0 ? (
<div>
Cart is empty. <Link href='/'>Go Shopping</Link>
</div>
) : (
<div className='grid md:grid-cols-4 md:gap-5'>
<div className='overflow-x-auto md:col-span-3'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className='text-center'>Quantity</TableHead>
<TableHead className='text-right'>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cart.items.map((item) => (
<TableRow key={item.slug}>
<TableCell>
<Link
href={`/product/${item.slug}`}
className='flex items-center'
>
<Image
src={item.image}
alt={item.name}
width={50}
height={50}
/>
<span className='px-2'>{item.name}</span>
</Link>
</TableCell>
<TableCell className='flex-center gap-2'>
<RemoveButton item={item} />
<span>{item.qty}</span>
<AddButton item={item} />
</TableCell>
<TableCell className='text-right'>${item.price}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Card>
<CardContent className='p-4 gap-4'>
<div className='pb-3 text-xl'>
Subtotal ({cart.items.reduce((a, c) => a + c.qty, 0)}):
<span className='font-bold'>
{formatCurrency(cart.itemsPrice)}
</span>
</div>
<Button
className='w-full'
disabled={isPending}
onClick={() =>
startTransition(() => router.push('/shipping-address'))
}
>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<ArrowRight className='w-4 h-4' />
)}{' '}
Proceed to Checkout
</Button>
</CardContent>
</Card>
</div>
)}
</>
);
};
export default CartTable;
================================================
FILE: app/(root)/cart/page.tsx
================================================
import CartTable from './cart-table';
import { getMyCart } from '@/lib/actions/cart.actions';
export const metadata = {
title: 'Shopping Cart',
};
const CartPage = async () => {
const cart = await getMyCart();
return (
<>
<CartTable cart={cart} />
</>
);
};
export default CartPage;
================================================
FILE: app/(root)/layout.tsx
================================================
import Header from '@/components/shared/header';
import Footer from '@/components/footer';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className='flex h-screen flex-col'>
<Header />
<main className='flex-1 wrapper'>{children}</main>
<Footer />
</div>
);
}
================================================
FILE: app/(root)/order/[id]/order-details-table.tsx
================================================
'use client';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { formatCurrency, formatDateTime, formatId } from '@/lib/utils';
import { Order } from '@/types';
import Link from 'next/link';
import Image from 'next/image';
import { useToast } from '@/hooks/use-toast';
import { useTransition } from 'react';
import {
PayPalButtons,
PayPalScriptProvider,
usePayPalScriptReducer,
} from '@paypal/react-paypal-js';
import {
createPayPalOrder,
approvePayPalOrder,
updateOrderToPaidCOD,
deliverOrder,
} from '@/lib/actions/order.actions';
import StripePayment from './stripe-payment';
const OrderDetailsTable = ({
order,
paypalClientId,
isAdmin,
stripeClientSecret,
}: {
order: Omit<Order, 'paymentResult'>;
paypalClientId: string;
isAdmin: boolean;
stripeClientSecret: string | null;
}) => {
const {
id,
shippingAddress,
orderitems,
itemsPrice,
shippingPrice,
taxPrice,
totalPrice,
paymentMethod,
isDelivered,
isPaid,
paidAt,
deliveredAt,
} = order;
const { toast } = useToast();
const PrintLoadingState = () => {
const [{ isPending, isRejected }] = usePayPalScriptReducer();
let status = '';
if (isPending) {
status = 'Loading PayPal...';
} else if (isRejected) {
status = 'Error Loading PayPal';
}
return status;
};
const handleCreatePayPalOrder = async () => {
const res = await createPayPalOrder(order.id);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
}
return res.data;
};
const handleApprovePayPalOrder = async (data: { orderID: string }) => {
const res = await approvePayPalOrder(order.id, data);
toast({
variant: res.success ? 'default' : 'destructive',
description: res.message,
});
};
// Button to mark order as paid
const MarkAsPaidButton = () => {
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
return (
<Button
type='button'
disabled={isPending}
onClick={() =>
startTransition(async () => {
const res = await updateOrderToPaidCOD(order.id);
toast({
variant: res.success ? 'default' : 'destructive',
description: res.message,
});
})
}
>
{isPending ? 'processing...' : 'Mark As Paid'}
</Button>
);
};
// Button to mark order as delivered
const MarkAsDeliveredButton = () => {
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
return (
<Button
type='button'
disabled={isPending}
onClick={() =>
startTransition(async () => {
const res = await deliverOrder(order.id);
toast({
variant: res.success ? 'default' : 'destructive',
description: res.message,
});
})
}
>
{isPending ? 'processing...' : 'Mark As Delivered'}
</Button>
);
};
return (
<>
<h1 className='py-4 text-2xl'>Order {formatId(id)}</h1>
<div className='grid md:grid-cols-3 md:gap-5'>
<div className='col-span-2 space-4-y overlow-x-auto'>
<Card>
<CardContent className='p-4 gap-4'>
<h2 className='text-xl pb-4'>Payment Method</h2>
<p className='mb-2'>{paymentMethod}</p>
{isPaid ? (
<Badge variant='secondary'>
Paid at {formatDateTime(paidAt!).dateTime}
</Badge>
) : (
<Badge variant='destructive'>Not paid</Badge>
)}
</CardContent>
</Card>
<Card className='my-2'>
<CardContent className='p-4 gap-4'>
<h2 className='text-xl pb-4'>Shipping Address</h2>
<p>{shippingAddress.fullName}</p>
<p className='mb-2'>
{shippingAddress.streetAddress}, {shippingAddress.city}
{shippingAddress.postalCode}, {shippingAddress.country}
</p>
{isDelivered ? (
<Badge variant='secondary'>
Delivered at {formatDateTime(deliveredAt!).dateTime}
</Badge>
) : (
<Badge variant='destructive'>Not Delivered</Badge>
)}
</CardContent>
</Card>
<Card>
<CardContent className='p-4 gap-4'>
<h2 className='text-xl pb-4'>Order Items</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orderitems.map((item) => (
<TableRow key={item.slug}>
<TableCell>
<Link
href={`/product/{item.slug}`}
className='flex items-center'
>
<Image
src={item.image}
alt={item.name}
width={50}
height={50}
/>
<span className='px-2'>{item.name}</span>
</Link>
</TableCell>
<TableCell>
<span className='px-2'>{item.qty}</span>
</TableCell>
<TableCell className='text-right'>
${item.price}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div>
<Card>
<CardContent className='p-4 gap-4 space-y-4'>
<div className='flex justify-between'>
<div>Items</div>
<div>{formatCurrency(itemsPrice)}</div>
</div>
<div className='flex justify-between'>
<div>Tax</div>
<div>{formatCurrency(taxPrice)}</div>
</div>
<div className='flex justify-between'>
<div>Shipping</div>
<div>{formatCurrency(shippingPrice)}</div>
</div>
<div className='flex justify-between'>
<div>Total</div>
<div>{formatCurrency(totalPrice)}</div>
</div>
{/* PayPal Payment */}
{!isPaid && paymentMethod === 'PayPal' && (
<div>
<PayPalScriptProvider options={{ clientId: paypalClientId }}>
<PrintLoadingState />
<PayPalButtons
createOrder={handleCreatePayPalOrder}
onApprove={handleApprovePayPalOrder}
/>
</PayPalScriptProvider>
</div>
)}
{/* Stripe Payment */}
{!isPaid && paymentMethod === 'Stripe' && stripeClientSecret && (
<StripePayment
priceInCents={Number(order.totalPrice) * 100}
orderId={order.id}
clientSecret={stripeClientSecret}
/>
)}
{/* Cash On Delivery */}
{isAdmin && !isPaid && paymentMethod === 'CashOnDelivery' && (
<MarkAsPaidButton />
)}
{isAdmin && isPaid && !isDelivered && <MarkAsDeliveredButton />}
</CardContent>
</Card>
</div>
</div>
</>
);
};
export default OrderDetailsTable;
================================================
FILE: app/(root)/order/[id]/page.tsx
================================================
import { Metadata } from 'next';
import { getOrderById } from '@/lib/actions/order.actions';
import { notFound, redirect } from 'next/navigation';
import OrderDetailsTable from './order-details-table';
import { ShippingAddress } from '@/types';
import { auth } from '@/auth';
import Stripe from 'stripe';
export const metadata: Metadata = {
title: 'Order Details',
};
const OrderDetailsPage = async (props: {
params: Promise<{
id: string;
}>;
}) => {
const { id } = await props.params;
const order = await getOrderById(id);
if (!order) notFound();
const session = await auth();
// Redirect the user if they don't own the order
if (order.userId !== session?.user.id && session?.user.role !== 'admin') {
return redirect('/unauthorized');
}
let client_secret = null;
// Check if is not paid and using stripe
if (order.paymentMethod === 'Stripe' && !order.isPaid) {
// Init stripe instance
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
// Create payment intent
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(Number(order.totalPrice) * 100),
currency: 'USD',
metadata: { orderId: order.id },
});
client_secret = paymentIntent.client_secret;
}
return (
<OrderDetailsTable
order={{
...order,
shippingAddress: order.shippingAddress as ShippingAddress,
}}
stripeClientSecret={client_secret}
paypalClientId={process.env.PAYPAL_CLIENT_ID || 'sb'}
isAdmin={session?.user?.role === 'admin' || false}
/>
);
};
export default OrderDetailsPage;
================================================
FILE: app/(root)/order/[id]/stripe-payment-success/page.tsx
================================================
import { Button } from '@/components/ui/button';
import { getOrderById } from '@/lib/actions/order.actions';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
const SuccessPage = async (props: {
params: Promise<{ id: string }>;
searchParams: Promise<{ payment_intent: string }>;
}) => {
const { id } = await props.params;
const { payment_intent: paymentIntentId } = await props.searchParams;
// Fetch order
const order = await getOrderById(id);
if (!order) notFound();
// Retrieve payment intent
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
// Check if payment intent is valid
if (
paymentIntent.metadata.orderId == null ||
paymentIntent.metadata.orderId !== order.id.toString()
) {
return notFound();
}
// Check if payment is successful
const isSuccess = paymentIntent.status === 'succeeded';
if (!isSuccess) return redirect(`/order/${id}`);
return (
<div className='max-w-4xl w-full mx-auto space-y-8'>
<div className='flex flex-col gap-6 items-center'>
<h1 className='h1-bold'>Thanks for your purchase</h1>
<div>We are processing your order.</div>
<Button asChild>
<Link href={`/order/${id}`}>View Order</Link>
</Button>
</div>
</div>
);
};
export default SuccessPage;
================================================
FILE: app/(root)/order/[id]/stripe-payment.tsx
================================================
import { FormEvent, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
Elements,
LinkAuthenticationElement,
PaymentElement,
useElements,
useStripe,
} from '@stripe/react-stripe-js';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { formatCurrency } from '@/lib/utils';
import { SERVER_URL } from '@/lib/constants';
const StripePayment = ({
priceInCents,
orderId,
clientSecret,
}: {
priceInCents: number;
orderId: string;
clientSecret: string;
}) => {
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string
);
const { theme, systemTheme } = useTheme();
// Stripe Form Component
const StripeForm = () => {
const stripe = useStripe();
const elements = useElements();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [email, setEmail] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (stripe == null || elements == null || email == null) return;
setIsLoading(true);
stripe
.confirmPayment({
elements,
confirmParams: {
return_url: `${SERVER_URL}/order/${orderId}/stripe-payment-success`,
},
})
.then(({ error }) => {
if (
error?.type === 'card_error' ||
error?.type === 'validation_error'
) {
setErrorMessage(error?.message ?? 'An unknown error occurred');
} else if (error) {
setErrorMessage('An unknown error occurred');
}
})
.finally(() => setIsLoading(false));
};
return (
<form className='space-y-4' onSubmit={handleSubmit}>
<div className='text-xl'>Stripe Checkout</div>
{errorMessage && <div className='text-destructive'>{errorMessage}</div>}
<PaymentElement />
<div>
<LinkAuthenticationElement
onChange={(e) => setEmail(e.value.email)}
/>
</div>
<Button
className='w-full'
size='lg'
disabled={stripe == null || elements == null || isLoading}
>
{isLoading
? 'Purchasing...'
: `Purchase ${formatCurrency(priceInCents / 100)}`}
</Button>
</form>
);
};
return (
<Elements
options={{
clientSecret,
appearance: {
theme:
theme === 'dark'
? 'night'
: theme === 'light'
? 'stripe'
: systemTheme === 'light'
? 'stripe'
: 'night',
},
}}
stripe={stripePromise}
>
<StripeForm />
</Elements>
);
};
export default StripePayment;
================================================
FILE: app/(root)/page.tsx
================================================
import ProductList from '@/components/shared/product/product-list';
import {
getLatestProducts,
getFeaturedProducts,
} from '@/lib/actions/product.actions';
import ProductCarousel from '@/components/shared/product/product-carousel';
import ViewAllProductsButton from '@/components/view-all-products-button';
import IconBoxes from '@/components/icon-boxes';
import DealCountdown from '@/components/deal-countdown';
const Homepage = async () => {
const latestProducts = await getLatestProducts();
const featuredProducts = await getFeaturedProducts();
return (
<>
{featuredProducts.length > 0 && (
<ProductCarousel data={featuredProducts} />
)}
<ProductList data={latestProducts} title='Newest Arrivals' limit={4} />
<ViewAllProductsButton />
<DealCountdown />
<IconBoxes />
</>
);
};
export default Homepage;
================================================
FILE: app/(root)/payment-method/page.tsx
================================================
import { Metadata } from 'next';
import { auth } from '@/auth';
import { getUserById } from '@/lib/actions/user.actions';
import PaymentMethodForm from './payment-method-form';
import CheckoutSteps from '@/components/shared/checkout-steps';
export const metadata: Metadata = {
title: 'Select Payment Method',
};
const PaymentMethodPage = async () => {
const session = await auth();
const userId = session?.user?.id;
if (!userId) throw new Error('User not found');
const user = await getUserById(userId);
return (
<>
<CheckoutSteps current={2} />
<PaymentMethodForm preferredPaymentMethod={user.paymentMethod} />
</>
);
};
export default PaymentMethodPage;
================================================
FILE: app/(root)/payment-method/payment-method-form.tsx
================================================
'use client';
import { useRouter } from 'next/navigation';
import { useToast } from '@/hooks/use-toast';
import { useTransition } from 'react';
import { paymentMethodSchema } from '@/lib/validators';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { DEFAULT_PAYMENT_METHOD, PAYMENT_METHODS } from '@/lib/constants';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { ArrowRight, Loader } from 'lucide-react';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { updateUserPaymentMethod } from '@/lib/actions/user.actions';
const PaymentMethodForm = ({
preferredPaymentMethod,
}: {
preferredPaymentMethod: string | null;
}) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<z.infer<typeof paymentMethodSchema>>({
resolver: zodResolver(paymentMethodSchema),
defaultValues: {
type: preferredPaymentMethod || DEFAULT_PAYMENT_METHOD,
},
});
const [isPending, startTransition] = useTransition();
const onSubmit = async (values: z.infer<typeof paymentMethodSchema>) => {
startTransition(async () => {
const res = await updateUserPaymentMethod(values);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
return;
}
router.push('/place-order');
});
};
return (
<>
<div className='max-w-md mx-auto space-y-4'>
<h1 className='h2-bold mt-4'>Payment Method</h1>
<p className='text-sm text-muted-foreground'>
Please select a payment method
</p>
<Form {...form}>
<form
method='post'
className='space-y-4'
onSubmit={form.handleSubmit(onSubmit)}
>
<div className='flex flex-col md:flex-row gap-5'>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
className='flex flex-col space-y-2'
>
{PAYMENT_METHODS.map((paymentMethod) => (
<FormItem
key={paymentMethod}
className='flex items-center space-x-3 space-y-0'
>
<FormControl>
<RadioGroupItem
value={paymentMethod}
checked={field.value === paymentMethod}
/>
</FormControl>
<FormLabel className='font-normal'>
{paymentMethod}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex gap-2'>
<Button type='submit' disabled={isPending}>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<ArrowRight className='w-4 h-4' />
)}{' '}
Continue
</Button>
</div>
</form>
</Form>
</div>
</>
);
};
export default PaymentMethodForm;
================================================
FILE: app/(root)/place-order/page.tsx
================================================
import { auth } from '@/auth';
import { getMyCart } from '@/lib/actions/cart.actions';
import { getUserById } from '@/lib/actions/user.actions';
import { ShippingAddress } from '@/types';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import CheckoutSteps from '@/components/shared/checkout-steps';
import { Card, CardContent } from '@/components/ui/card';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import Image from 'next/image';
import { formatCurrency } from '@/lib/utils';
import PlaceOrderForm from './place-order-form';
export const metadata: Metadata = {
title: 'Place Order',
};
const PlaceOrderPage = async () => {
const cart = await getMyCart();
const session = await auth();
const userId = session?.user?.id;
if (!userId) throw new Error('User not found');
const user = await getUserById(userId);
if (!cart || cart.items.length === 0) redirect('/cart');
if (!user.address) redirect('/shipping-address');
if (!user.paymentMethod) redirect('/payment-method');
const userAddress = user.address as ShippingAddress;
return (
<>
<CheckoutSteps current={3} />
<h1 className='py-4 text-2xl'>Place Order</h1>
<div className='grid md:grid-cols-3 md:gap-5'>
<div className='md:col-span-2 overflow-x-auto space-y-4'>
<Card>
<CardContent className='p-4 gap-4'>
<h2 className='text-xl pb-4'>Shipping Address</h2>
<p>{userAddress.fullName}</p>
<p>
{userAddress.streetAddress}, {userAddress.city}{' '}
{userAddress.postalCode}, {userAddress.country}{' '}
</p>
<div className='mt-3'>
<Link href='/shipping-address'>
<Button variant='outline'>Edit</Button>
</Link>
</div>
</CardContent>
</Card>
<Card>
<CardContent className='p-4 gap-4'>
<h2 className='text-xl pb-4'>Payment Method</h2>
<p>{user.paymentMethod}</p>
<div className='mt-3'>
<Link href='/payment-method'>
<Button variant='outline'>Edit</Button>
</Link>
</div>
</CardContent>
</Card>
<Card>
<CardContent className='p-4 gap-4'>
<h2 className='text-xl pb-4'>Order Items</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cart.items.map((item) => (
<TableRow key={item.slug}>
<TableCell>
<Link
href={`/product/{item.slug}`}
className='flex items-center'
>
<Image
src={item.image}
alt={item.name}
width={50}
height={50}
/>
<span className='px-2'>{item.name}</span>
</Link>
</TableCell>
<TableCell>
<span className='px-2'>{item.qty}</span>
</TableCell>
<TableCell className='text-right'>
${item.price}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div>
<Card>
<CardContent className='p-4 gap-4 space-y-4'>
<div className='flex justify-between'>
<div>Items</div>
<div>{formatCurrency(cart.itemsPrice)}</div>
</div>
<div className='flex justify-between'>
<div>Tax</div>
<div>{formatCurrency(cart.taxPrice)}</div>
</div>
<div className='flex justify-between'>
<div>Shipping</div>
<div>{formatCurrency(cart.shippingPrice)}</div>
</div>
<div className='flex justify-between'>
<div>Total</div>
<div>{formatCurrency(cart.totalPrice)}</div>
</div>
<PlaceOrderForm />
</CardContent>
</Card>
</div>
</div>
</>
);
};
export default PlaceOrderPage;
================================================
FILE: app/(root)/place-order/place-order-form.tsx
================================================
'use client';
import { useRouter } from 'next/navigation';
import { Check, Loader } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useFormStatus } from 'react-dom';
import { createOrder } from '@/lib/actions/order.actions';
const PlaceOrderForm = () => {
const router = useRouter();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const res = await createOrder();
if (res.redirectTo) {
router.push(res.redirectTo);
}
};
const PlaceOrderButton = () => {
const { pending } = useFormStatus();
return (
<Button disabled={pending} className='w-full'>
{pending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<Check className='w-4 h-4' />
)}{' '}
Place Order
</Button>
);
};
return (
<form onSubmit={handleSubmit} className='w-full'>
<PlaceOrderButton />
</form>
);
};
export default PlaceOrderForm;
================================================
FILE: app/(root)/product/[slug]/page.tsx
================================================
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { getProductBySlug } from '@/lib/actions/product.actions';
import { notFound } from 'next/navigation';
import ProductPrice from '@/components/shared/product/product-price';
import ProductImages from '@/components/shared/product/product-images';
import AddToCart from '@/components/shared/product/add-to-cart';
import { getMyCart } from '@/lib/actions/cart.actions';
import ReviewList from './review-list';
import { auth } from '@/auth';
import Rating from '@/components/shared/product/rating';
const ProductDetailsPage = async (props: {
params: Promise<{ slug: string }>;
}) => {
const { slug } = await props.params;
const product = await getProductBySlug(slug);
if (!product) notFound();
const session = await auth();
const userId = session?.user?.id;
const cart = await getMyCart();
return (
<>
<section>
<div className='grid grid-cols-1 md:grid-cols-5'>
{/* Images Column */}
<div className='col-span-2'>
<ProductImages images={product.images} />
</div>
{/* Details Column */}
<div className='col-span-2 p-5'>
<div className='flex flex-col gap-6'>
<p>
{product.brand} {product.category}
</p>
<h1 className='h3-bold'>{product.name}</h1>
<Rating value={Number(product.rating)} />
<p>{product.numReviews} reviews</p>
<div className='flex flex-col sm:flex-row sm:items-center gap-3'>
<ProductPrice
value={Number(product.price)}
className='w-24 rounded-full bg-green-100 text-green-700 px-5 py-2'
/>
</div>
</div>
<div className='mt-10'>
<p className='font-semibold'>Description</p>
<p>{product.description}</p>
</div>
</div>
{/* Action Column */}
<div>
<Card>
<CardContent className='p-4'>
<div className='mb-2 flex justify-between'>
<div>Price</div>
<div>
<ProductPrice value={Number(product.price)} />
</div>
</div>
<div className='mb-2 flex justify-between'>
<div>Status</div>
{product.stock > 0 ? (
<Badge variant='outline'>In Stock</Badge>
) : (
<Badge variant='destructive'>Out Of Stock</Badge>
)}
</div>
{product.stock > 0 && (
<div className='flex-center'>
<AddToCart
cart={cart}
item={{
productId: product.id,
name: product.name,
slug: product.slug,
price: product.price,
qty: 1,
image: product.images![0],
}}
/>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</section>
<section className='mt-10'>
<h2 className='h2-bold mb-5'>Customer Reviews</h2>
<ReviewList
userId={userId || ''}
productId={product.id}
productSlug={product.slug}
/>
</section>
</>
);
};
export default ProductDetailsPage;
================================================
FILE: app/(root)/product/[slug]/review-form.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/hooks/use-toast';
import { reviewFormDefaultValues } from '@/lib/constants';
import { insertReviewSchema } from '@/lib/validators';
import { zodResolver } from '@hookform/resolvers/zod';
import { StarIcon } from 'lucide-react';
import { useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';
import {
createUpdateReview,
getReviewByProductId,
} from '@/lib/actions/review.actions';
const ReviewForm = ({
userId,
productId,
onReviewSubmitted,
}: {
userId: string;
productId: string;
onReviewSubmitted: () => void;
}) => {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const form = useForm<z.infer<typeof insertReviewSchema>>({
resolver: zodResolver(insertReviewSchema),
defaultValues: reviewFormDefaultValues,
});
// Open Form Handler
const handleOpenForm = async () => {
form.setValue('productId', productId);
form.setValue('userId', userId);
const review = await getReviewByProductId({ productId });
if (review) {
form.setValue('title', review.title);
form.setValue('description', review.description);
form.setValue('rating', review.rating);
}
setOpen(true);
};
// Submit Form Handler
const onSubmit: SubmitHandler<z.infer<typeof insertReviewSchema>> = async (
values
) => {
const res = await createUpdateReview({ ...values, productId });
if (!res.success) {
return toast({
variant: 'destructive',
description: res.message,
});
}
setOpen(false);
onReviewSubmitted();
toast({
description: res.message,
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button onClick={handleOpenForm} variant='default'>
Write a Review
</Button>
<DialogContent className='sm:max-w-[425px]'>
<Form {...form}>
<form method='post' onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Write a Review</DialogTitle>
<DialogDescription>
Share your thoughts with other customers
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder='Enter title' {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => {
return (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder='Enter description' {...field} />
</FormControl>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name='rating'
render={({ field }) => {
return (
<FormItem>
<FormLabel>Rating</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Array.from({ length: 5 }).map((_, index) => (
<SelectItem
key={index}
value={(index + 1).toString()}
>
{index + 1}{' '}
<StarIcon className='inline h-4 w-4' />
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<DialogFooter>
<Button
type='submit'
size='lg'
className='w-full'
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default ReviewForm;
================================================
FILE: app/(root)/product/[slug]/review-list.tsx
================================================
'use client';
import { useEffect } from 'react';
import { Review } from '@/types';
import Link from 'next/link';
import { useState } from 'react';
import ReviewForm from './review-form';
import { getReviews } from '@/lib/actions/review.actions';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Calendar, User } from 'lucide-react';
import { formatDateTime } from '@/lib/utils';
import Rating from '@/components/shared/product/rating';
const ReviewList = ({
userId,
productId,
productSlug,
}: {
userId: string;
productId: string;
productSlug: string;
}) => {
const [reviews, setReviews] = useState<Review[]>([]);
useEffect(() => {
const loadReviews = async () => {
const res = await getReviews({ productId });
setReviews(res.data);
};
loadReviews();
}, [productId]);
// Reload reviews after created or updated
const reload = async () => {
const res = await getReviews({ productId });
setReviews([...res.data]);
};
return (
<div className='space-y-4'>
{reviews.length === 0 && <div>No reviews yet</div>}
{userId ? (
<ReviewForm
userId={userId}
productId={productId}
onReviewSubmitted={reload}
/>
) : (
<div>
Please
<Link
className='text-blue-700 px-2'
href={`/sign-in?callbackUrl=/product/${productSlug}`}
>
sign in
</Link>
to write a review
</div>
)}
<div className='flex flex-col gap-3'>
{reviews.map((review) => (
<Card key={review.id}>
<CardHeader>
<div className='flex-between'>
<CardTitle>{review.title}</CardTitle>
</div>
<CardDescription>{review.description}</CardDescription>
</CardHeader>
<CardContent>
<div className='flex space-x-4 text-sm text-muted-foreground'>
<Rating value={review.rating} />
<div className='flex items-center'>
<User className='mr-1 h-3 w-3' />
{review.user ? review.user.name : 'User'}
</div>
<div className='flex items-center'>
<Calendar className='mr-1 h-3 w-3' />
{formatDateTime(review.createdAt).dateTime}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
};
export default ReviewList;
================================================
FILE: app/(root)/search/page.tsx
================================================
import ProductCard from '@/components/shared/product/product-card';
import { Button } from '@/components/ui/button';
import {
getAllProducts,
getAllCategories,
} from '@/lib/actions/product.actions';
import Link from 'next/link';
const prices = [
{
name: '$1 to $50',
value: '1-50',
},
{
name: '$51 to $100',
value: '51-100',
},
{
name: '$101 to $200',
value: '101-200',
},
{
name: '$201 to $500',
value: '201-500',
},
{
name: '$501 to $1000',
value: '501-1000',
},
];
const ratings = [4, 3, 2, 1];
const sortOrders = ['newest', 'lowest', 'highest', 'rating'];
export async function generateMetadata(props: {
searchParams: Promise<{
q: string;
category: string;
price: string;
rating: string;
}>;
}) {
const {
q = 'all',
category = 'all',
price = 'all',
rating = 'all',
} = await props.searchParams;
const isQuerySet = q && q !== 'all' && q.trim() !== '';
const isCategorySet =
category && category !== 'all' && category.trim() !== '';
const isPriceSet = price && price !== 'all' && price.trim() !== '';
const isRatingSet = rating && rating !== 'all' && rating.trim() !== '';
if (isQuerySet || isCategorySet || isPriceSet || isRatingSet) {
return {
title: `
Search ${isQuerySet ? q : ''}
${isCategorySet ? `: Category ${category}` : ''}
${isPriceSet ? `: Price ${price}` : ''}
${isRatingSet ? `: Rating ${rating}` : ''}`,
};
} else {
return {
title: 'Search Products',
};
}
}
const SearchPage = async (props: {
searchParams: Promise<{
q?: string;
category?: string;
price?: string;
rating?: string;
sort?: string;
page?: string;
}>;
}) => {
const {
q = 'all',
category = 'all',
price = 'all',
rating = 'all',
sort = 'newest',
page = '1',
} = await props.searchParams;
// Construct filter url
const getFilterUrl = ({
c,
p,
s,
r,
pg,
}: {
c?: string;
p?: string;
s?: string;
r?: string;
pg?: string;
}) => {
const params = { q, category, price, rating, sort, page };
if (c) params.category = c;
if (p) params.price = p;
if (s) params.sort = s;
if (r) params.rating = r;
if (pg) params.page = pg;
return `/search?${new URLSearchParams(params).toString()}`;
};
const products = await getAllProducts({
query: q,
category,
price,
rating,
sort,
page: Number(page),
});
const categories = await getAllCategories();
return (
<div className='grid md:grid-cols-5 md:gap-5'>
<div className='filter-links'>
{/* Category Links */}
<div className='text-xl mb-2 mt-3'>Department</div>
<div>
<ul className='space-y-1'>
<li>
<Link
className={`${
(category === 'all' || category === '') && 'font-bold'
}`}
href={getFilterUrl({ c: 'all' })}
>
Any
</Link>
</li>
{categories.map((x) => (
<li key={x.category}>
<Link
className={`${category === x.category && 'font-bold'}`}
href={getFilterUrl({ c: x.category })}
>
{x.category}
</Link>
</li>
))}
</ul>
</div>
{/* Price Links */}
<div className='text-xl mb-2 mt-8'>Price</div>
<div>
<ul className='space-y-1'>
<li>
<Link
className={`${price === 'all' && 'font-bold'}`}
href={getFilterUrl({ p: 'all' })}
>
Any
</Link>
</li>
{prices.map((p) => (
<li key={p.value}>
<Link
className={`${price === p.value && 'font-bold'}`}
href={getFilterUrl({ p: p.value })}
>
{p.name}
</Link>
</li>
))}
</ul>
</div>
{/* Rating Links */}
<div className='text-xl mb-2 mt-8'>Customer Ratings</div>
<div>
<ul className='space-y-1'>
<li>
<Link
className={`${rating === 'all' && 'font-bold'}`}
href={getFilterUrl({ r: 'all' })}
>
Any
</Link>
</li>
{ratings.map((r) => (
<li key={r}>
<Link
className={`${rating === r.toString() && 'font-bold'}`}
href={getFilterUrl({ r: `${r}` })}
>
{`${r} stars & up`}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className='md:col-span-4 space-y-4'>
<div className='flex-between flex-col md:flex-row my-4'>
<div className='flex items-center'>
{q !== 'all' && q !== '' && 'Query: ' + q}
{category !== 'all' && category !== '' && 'Category: ' + category}
{price !== 'all' && ' Price: ' + price}
{rating !== 'all' && ' Rating: ' + rating + ' stars & up'}
{(q !== 'all' && q !== '') ||
(category !== 'all' && category !== '') ||
rating !== 'all' ||
price !== 'all' ? (
<Button variant={'link'} asChild>
<Link href='/search'>Clear</Link>
</Button>
) : null}
</div>
<div>
Sort by{' '}
{sortOrders.map((s) => (
<Link
key={s}
className={`mx-2 ${sort == s && 'font-bold'}`}
href={getFilterUrl({ s })}
>
{s}
</Link>
))}
</div>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
{products.data.length === 0 && <div>No products found</div>}
{products.data.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
</div>
);
};
export default SearchPage;
================================================
FILE: app/(root)/shipping-address/page.tsx
================================================
import { auth } from '@/auth';
import { getMyCart } from '@/lib/actions/cart.actions';
import { getUserById } from '@/lib/actions/user.actions';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { ShippingAddress } from '@/types';
import ShippingAddressForm from './shipping-address-form';
import CheckoutSteps from '@/components/shared/checkout-steps';
export const metadata: Metadata = {
title: 'Shipping Address',
};
const ShippingAddressPage = async () => {
const cart = await getMyCart();
if (!cart || cart.items.length === 0) redirect('/cart');
const session = await auth();
const userId = session?.user?.id;
if (!userId) throw new Error('No user ID');
const user = await getUserById(userId);
return (
<>
<CheckoutSteps current={1} />
<ShippingAddressForm address={user.address as ShippingAddress} />
</>
);
};
export default ShippingAddressPage;
================================================
FILE: app/(root)/shipping-address/shipping-address-form.tsx
================================================
'use client';
import { useRouter } from 'next/navigation';
import { useToast } from '@/hooks/use-toast';
import { useTransition } from 'react';
import { ShippingAddress } from '@/types';
import { shippingAddressSchema } from '@/lib/validators';
import { zodResolver } from '@hookform/resolvers/zod';
import { ControllerRenderProps, useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ArrowRight, Loader } from 'lucide-react';
import { updateUserAddress } from '@/lib/actions/user.actions';
import { shippingAddressDefaultValues } from '@/lib/constants';
const ShippingAddressForm = ({ address }: { address: ShippingAddress }) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<z.infer<typeof shippingAddressSchema>>({
resolver: zodResolver(shippingAddressSchema),
defaultValues: address || shippingAddressDefaultValues,
});
const [isPending, startTransition] = useTransition();
const onSubmit: SubmitHandler<z.infer<typeof shippingAddressSchema>> = async (
values
) => {
startTransition(async () => {
const res = await updateUserAddress(values);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
return;
}
router.push('/payment-method');
});
};
return (
<>
<div className='max-w-md mx-auto space-y-4'>
<h1 className='h2-bold mt-4'>Shipping Address</h1>
<p className='text-sm text-muted-foreground'>
Please enter and address to ship to
</p>
<Form {...form}>
<form
method='post'
className='space-y-4'
onSubmit={form.handleSubmit(onSubmit)}
>
<div className='flex flex-col md:flex-row gap-5'>
<FormField
control={form.control}
name='fullName'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof shippingAddressSchema>,
'fullName'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder='Enter full name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex flex-col md:flex-row gap-5'>
<FormField
control={form.control}
name='streetAddress'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof shippingAddressSchema>,
'streetAddress'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Address</FormLabel>
<FormControl>
<Input placeholder='Enter address' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex flex-col md:flex-row gap-5'>
<FormField
control={form.control}
name='city'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof shippingAddressSchema>,
'city'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>City</FormLabel>
<FormControl>
<Input placeholder='Enter city' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex flex-col md:flex-row gap-5'>
<FormField
control={form.control}
name='postalCode'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof shippingAddressSchema>,
'postalCode'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Postal Code</FormLabel>
<FormControl>
<Input placeholder='Enter postal code' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex flex-col md:flex-row gap-5'>
<FormField
control={form.control}
name='country'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof shippingAddressSchema>,
'country'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Country</FormLabel>
<FormControl>
<Input placeholder='Enter country' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex gap-2'>
<Button type='submit' disabled={isPending}>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<ArrowRight className='w-4 h-4' />
)}{' '}
Continue
</Button>
</div>
</form>
</Form>
</div>
</>
);
};
export default ShippingAddressForm;
================================================
FILE: app/admin/layout.tsx
================================================
import { APP_NAME } from '@/lib/constants';
import Image from 'next/image';
import Link from 'next/link';
import Menu from '@/components/shared/header/menu';
import MainNav from './main-nav';
import AdminSearch from '@/components/admin/admin-search';
export default function AdminLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<div className='flex flex-col'>
<div className='border-b container mx-auto'>
<div className='flex items-center h-16 px-4'>
<Link href='/' className='w-22'>
<Image
src='/images/logo.svg'
height={48}
width={48}
alt={APP_NAME}
/>
</Link>
<MainNav className='mx-6' />
<div className='ml-auto items-center flex space-x-4'>
<AdminSearch />
<Menu />
</div>
</div>
</div>
<div className='flex-1 space-y-4 p-8 pt-6 container mx-auto'>
{children}
</div>
</div>
</>
);
}
================================================
FILE: app/admin/main-nav.tsx
================================================
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import React from 'react';
const links = [
{
title: 'Overview',
href: '/admin/overview',
},
{
title: 'Products',
href: '/admin/products',
},
{
title: 'Orders',
href: '/admin/orders',
},
{
title: 'Users',
href: '/admin/users',
},
];
const MainNav = ({
className,
...props
}: React.HTMLAttributes<HTMLElement>) => {
const pathname = usePathname();
return (
<nav
className={cn('flex items-center space-x-4 lg:space-x-6', className)}
{...props}
>
{links.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'text-sm font-medium transition-colors hover:text-primary',
pathname.includes(item.href) ? '' : 'text-muted-foreground'
)}
>
{item.title}
</Link>
))}
</nav>
);
};
export default MainNav;
================================================
FILE: app/admin/orders/page.tsx
================================================
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { deleteOrder, getAllOrders } from '@/lib/actions/order.actions';
import { formatCurrency, formatDateTime, formatId } from '@/lib/utils';
import { Metadata } from 'next';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import Pagination from '@/components/shared/pagination';
import DeleteDialog from '@/components/shared/delete-dialog';
import { requireAdmin } from '@/lib/auth-guard';
export const metadata: Metadata = {
title: 'Admin Orders',
};
const AdminOrdersPage = async (props: {
searchParams: Promise<{ page: string; query: string }>;
}) => {
const { page = '1', query: searchText } = await props.searchParams;
await requireAdmin();
const orders = await getAllOrders({
page: Number(page),
query: searchText,
});
return (
<div className='space-y-2'>
<div className='flex items-center gap-3'>
<h1 className='h2-bold'>Orders</h1>
{searchText && (
<div>
Filtered by <i>"{searchText}"</i>{' '}
<Link href='/admin/orders'>
<Button variant='outline' size='sm'>
Remove Filter
</Button>
</Link>
</div>
)}
</div>
<div className='overflow-x-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>DATE</TableHead>
<TableHead>BUYER</TableHead>
<TableHead>TOTAL</TableHead>
<TableHead>PAID</TableHead>
<TableHead>DELIVERED</TableHead>
<TableHead>ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.data.map((order) => (
<TableRow key={order.id}>
<TableCell>{formatId(order.id)}</TableCell>
<TableCell>
{formatDateTime(order.createdAt).dateTime}
</TableCell>
<TableCell>{order.user.name}</TableCell>
<TableCell>{formatCurrency(order.totalPrice)}</TableCell>
<TableCell>
{order.isPaid && order.paidAt
? formatDateTime(order.paidAt).dateTime
: 'Not Paid'}
</TableCell>
<TableCell>
{order.isDelivered && order.deliveredAt
? formatDateTime(order.deliveredAt).dateTime
: 'Not Delivered'}
</TableCell>
<TableCell>
<Button asChild variant='outline' size='sm'>
<Link href={`/order/${order.id}`}>Details</Link>
</Button>
<DeleteDialog id={order.id} action={deleteOrder} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{orders.totalPages > 1 && (
<Pagination
page={Number(page) || 1}
totalPages={orders?.totalPages}
/>
)}
</div>
</div>
);
};
export default AdminOrdersPage;
================================================
FILE: app/admin/overview/charts.tsx
================================================
'use client';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer } from 'recharts';
const Charts = ({
data: { salesData },
}: {
data: { salesData: { month: string; totalSales: number }[] };
}) => {
return (
<ResponsiveContainer width='100%' height={350}>
<BarChart data={salesData}>
<XAxis
dataKey='month'
stroke='#888888'
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke='#888888'
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Bar
dataKey='totalSales'
fill='currentColor'
radius={[4, 4, 0, 0]}
className='fill-primary'
/>
</BarChart>
</ResponsiveContainer>
);
};
export default Charts;
================================================
FILE: app/admin/overview/page.tsx
================================================
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getOrderSummary } from '@/lib/actions/order.actions';
import { formatCurrency, formatDateTime, formatNumber } from '@/lib/utils';
import { BadgeDollarSign, Barcode, CreditCard, Users } from 'lucide-react';
import { Metadata } from 'next';
import Link from 'next/link';
import Charts from './charts';
import { requireAdmin } from '@/lib/auth-guard';
export const metadata: Metadata = {
title: 'Admin Dashboard',
};
const AdminOverviewPage = async () => {
await requireAdmin();
const summary = await getOrderSummary();
return (
<div className='space-y-2'>
<h1 className='h2-bold'>Dashboard</h1>
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
<BadgeDollarSign />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{formatCurrency(
summary.totalSales._sum.totalPrice?.toString() || 0
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Sales</CardTitle>
<CreditCard />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{formatNumber(summary.ordersCount)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Customers</CardTitle>
<Users />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{formatNumber(summary.usersCount)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Products</CardTitle>
<Barcode />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{formatNumber(summary.productsCount)}
</div>
</CardContent>
</Card>
</div>
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-7'>
<Card className='col-span-4'>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent>
<Charts
data={{
salesData: summary.salesData,
}}
/>
</CardContent>
</Card>
<Card className='col-span-3'>
<CardHeader>
<CardTitle>Recent Sales</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>BUYER</TableHead>
<TableHead>DATE</TableHead>
<TableHead>TOTAL</TableHead>
<TableHead>ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.latestSales.map((order) => (
<TableRow key={order.id}>
<TableCell>
{order?.user?.name ? order.user.name : 'Deleted User'}
</TableCell>
<TableCell>
{formatDateTime(order.createdAt).dateOnly}
</TableCell>
<TableCell>{formatCurrency(order.totalPrice)}</TableCell>
<TableCell>
<Link href={`/order/${order.id}`}>
<span className='px-2'>Details</span>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
);
};
export default AdminOverviewPage;
================================================
FILE: app/admin/products/[id]/page.tsx
================================================
import ProductForm from '@/components/admin/product-form';
import { getProductById } from '@/lib/actions/product.actions';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { requireAdmin } from '@/lib/auth-guard';
export const metadata: Metadata = {
title: 'Update Product',
};
const AdminProductUpdatePage = async (props: {
params: Promise<{
id: string;
}>;
}) => {
await requireAdmin();
const { id } = await props.params;
const product = await getProductById(id);
if (!product) return notFound();
return (
<div className='space-y-8 max-w-5xl mx-auto'>
<h1 className='h2-bold'>Update Product</h1>
<ProductForm type='Update' product={product} productId={product.id} />
</div>
);
};
export default AdminProductUpdatePage;
================================================
FILE: app/admin/products/create/page.tsx
================================================
import { Metadata } from 'next';
import ProductForm from '@/components/admin/product-form';
import { requireAdmin } from '@/lib/auth-guard';
export const metadata: Metadata = {
title: 'Create Product',
};
const CreateProductPage = async () => {
await requireAdmin();
return (
<>
<h2 className='h2-bold'>Create Product</h2>
<div className='my-8'>
<ProductForm type='Create' />
</div>
</>
);
};
export default CreateProductPage;
================================================
FILE: app/admin/products/page.tsx
================================================
import Link from 'next/link';
import { getAllProducts, deleteProduct } from '@/lib/actions/product.actions';
import { formatCurrency, formatId } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import Pagination from '@/components/shared/pagination';
import DeleteDialog from '@/components/shared/delete-dialog';
import { requireAdmin } from '@/lib/auth-guard';
const AdminProductsPage = async (props: {
searchParams: Promise<{
page: string;
query: string;
category: string;
}>;
}) => {
await requireAdmin();
const searchParams = await props.searchParams;
const page = Number(searchParams.page) || 1;
const searchText = searchParams.query || '';
const category = searchParams.category || '';
const products = await getAllProducts({
query: searchText,
page,
category,
});
return (
<div className='space-y-2'>
<div className='flex-between'>
<div className='flex items-center gap-3'>
<h1 className='h2-bold'>Products</h1>
{searchText && (
<div>
Filtered by <i>"{searchText}"</i>{' '}
<Link href='/admin/products'>
<Button variant='outline' size='sm'>
Remove Filter
</Button>
</Link>
</div>
)}
</div>
<Button asChild variant='default'>
<Link href='/admin/products/create'>Create Product</Link>
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>NAME</TableHead>
<TableHead className='text-right'>PRICE</TableHead>
<TableHead>CATEGORY</TableHead>
<TableHead>STOCK</TableHead>
<TableHead>RATING</TableHead>
<TableHead className='w-[100px]'>ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.data.map((product) => (
<TableRow key={product.id}>
<TableCell>{formatId(product.id)}</TableCell>
<TableCell>{product.name}</TableCell>
<TableCell className='text-right'>
{formatCurrency(product.price)}
</TableCell>
<TableCell>{product.category}</TableCell>
<TableCell>{product.stock}</TableCell>
<TableCell>{product.rating}</TableCell>
<TableCell className='flex gap-1'>
<Button asChild variant='outline' size='sm'>
<Link href={`/admin/products/${product.id}`}>Edit</Link>
</Button>
<DeleteDialog id={product.id} action={deleteProduct} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{products.totalPages > 1 && (
<Pagination page={page} totalPages={products.totalPages} />
)}
</div>
);
};
export default AdminProductsPage;
================================================
FILE: app/admin/users/[id]/page.tsx
================================================
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getUserById } from '@/lib/actions/user.actions';
import UpdateUserForm from './update-user-form';
import { requireAdmin } from '@/lib/auth-guard';
export const metadata: Metadata = {
title: 'Update User',
};
const AdminUserUpdatePage = async (props: {
params: Promise<{
id: string;
}>;
}) => {
await requireAdmin();
const { id } = await props.params;
const user = await getUserById(id);
if (!user) notFound();
return (
<div className='space-y-8 max-w-lg mx-auto'>
<h1 className='h2-bold'>Update User</h1>
<UpdateUserForm user={user} />
</div>
);
};
export default AdminUserUpdatePage;
================================================
FILE: app/admin/users/[id]/update-user-form.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { updateUser } from '@/lib/actions/user.actions';
import { USER_ROLES } from '@/lib/constants';
import { updateUserSchema } from '@/lib/validators';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { ControllerRenderProps, useForm } from 'react-hook-form';
import { z } from 'zod';
const UpdateUserForm = ({
user,
}: {
user: z.infer<typeof updateUserSchema>;
}) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<z.infer<typeof updateUserSchema>>({
resolver: zodResolver(updateUserSchema),
defaultValues: user,
});
const onSubmit = async (values: z.infer<typeof updateUserSchema>) => {
try {
const res = await updateUser({
...values,
id: user.id,
});
if (!res.success) {
return toast({
variant: 'destructive',
description: res.message,
});
}
toast({
description: res.message,
});
form.reset();
router.push('/admin/users');
} catch (error) {
toast({
variant: 'destructive',
description: (error as Error).message,
});
}
};
return (
<Form {...form}>
<form method='POST' onSubmit={form.handleSubmit(onSubmit)}>
{/* Email */}
<div>
<FormField
control={form.control}
name='email'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof updateUserSchema>,
'email'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
disabled={true}
placeholder='Enter user email'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Name */}
<div>
<FormField
control={form.control}
name='name'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof updateUserSchema>,
'name'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Enter user name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Role */}
<div>
<FormField
control={form.control}
name='role'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof updateUserSchema>,
'role'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a role' />
</SelectTrigger>
</FormControl>
<SelectContent>
{USER_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex-between mt-6'>
<Button
type='submit'
className='w-full'
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? 'Submitting...' : 'Update User'}
</Button>
</div>
</form>
</Form>
);
};
export default UpdateUserForm;
================================================
FILE: app/admin/users/page.tsx
================================================
import { Metadata } from 'next';
import { getAllUsers, deleteUser } from '@/lib/actions/user.actions';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatId } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import Pagination from '@/components/shared/pagination';
import { Badge } from '@/components/ui/badge';
import DeleteDialog from '@/components/shared/delete-dialog';
import { requireAdmin } from '@/lib/auth-guard';
export const metadata: Metadata = {
title: 'Admin Users',
};
const AdminUserPage = async (props: {
searchParams: Promise<{
page: string;
query: string;
}>;
}) => {
await requireAdmin();
const { page = '1', query: searchText } = await props.searchParams;
const users = await getAllUsers({ page: Number(page), query: searchText });
return (
<div className='space-y-2'>
<div className='flex items-center gap-3'>
<h1 className='h2-bold'>Users</h1>
{searchText && (
<div>
Filtered by <i>"{searchText}"</i>{' '}
<Link href='/admin/users'>
<Button variant='outline' size='sm'>
Remove Filter
</Button>
</Link>
</div>
)}
</div>
<div className='overflow-x-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.data.map((user) => (
<TableRow key={user.id}>
<TableCell>{formatId(user.id)}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{user.role === 'user' ? (
<Badge variant='secondary'>User</Badge>
) : (
<Badge variant='default'>Admin</Badge>
)}
</TableCell>
<TableCell>
<Button asChild variant='outline' size='sm'>
<Link href={`/admin/users/${user.id}`}>Edit</Link>
</Button>
<DeleteDialog id={user.id} action={deleteUser} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{users.totalPages > 1 && (
<Pagination page={Number(page) || 1} totalPages={users?.totalPages} />
)}
</div>
</div>
);
};
export default AdminUserPage;
================================================
FILE: app/api/auth/[...nextauth]/route.ts
================================================
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
================================================
FILE: app/api/uploadthing/core.ts
================================================
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { UploadThingError } from 'uploadthing/server';
import { auth } from '@/auth';
const f = createUploadthing();
export const ourFileRouter = {
imageUploader: f({
image: { maxFileSize: '4MB' },
})
.middleware(async () => {
const session = await auth();
if (!session) throw new UploadThingError('Unauthorized');
return { userId: session?.user?.id };
})
.onUploadComplete(async ({ metadata }) => {
return { uploadedBy: metadata.userId };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
================================================
FILE: app/api/uploadthing/route.ts
================================================
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
});
================================================
FILE: app/api/webhooks/stripe/route.ts
================================================
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { updateOrderToPaid } from '@/lib/actions/order.actions';
export async function POST(req: NextRequest) {
// Build the webhook event
const event = await Stripe.webhooks.constructEvent(
await req.text(),
req.headers.get('stripe-signature') as string,
process.env.STRIPE_WEBHOOK_SECRET as string
);
// Check for successful payment
if (event.type === 'charge.succeeded') {
const { object } = event.data;
// Update order status
await updateOrderToPaid({
orderId: object.metadata.orderId,
paymentResult: {
id: object.id,
status: 'COMPLETED',
email_address: object.billing_details.email!,
pricePaid: (object.amount / 100).toFixed(),
},
});
return NextResponse.json({
message: 'updateOrderToPaid was successful',
});
}
return NextResponse.json({
message: 'event is not charge.succeeded',
});
}
================================================
FILE: app/layout.tsx
================================================
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '@/assets/styles/globals.css';
import { APP_DESCRIPTION, APP_NAME, SERVER_URL } from '@/lib/constants';
import { ThemeProvider } from 'next-themes';
import { Toaster } from '@/components/ui/toaster';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
template: `%s | Prostore`,
default: APP_NAME,
},
description: APP_DESCRIPTION,
metadataBase: new URL(SERVER_URL),
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='en' suppressHydrationWarning>
<body className={`${inter.className} antialiased`}>
<ThemeProvider
attribute='class'
defaultTheme='light'
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
================================================
FILE: app/loading.tsx
================================================
import Image from 'next/image';
import loader from '@/assets/loader.gif';
const LoadingPage = () => {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
width: '100vw',
}}
>
<Image src={loader} height={150} width={150} alt='Loading...' />
</div>
);
};
export default LoadingPage;
================================================
FILE: app/not-found.tsx
================================================
'use client';
import { APP_NAME } from '@/lib/constants';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
const NotFoundPage = () => {
return (
<div className='flex flex-col items-center justify-center min-h-screen'>
<Image
src='/images/logo.svg'
width={48}
height={48}
alt={`${APP_NAME} logo`}
priority={true}
/>
<div className='p-6 w-1/3 rounded-lg shadow-md text-center'>
<h1 className='text-3xl font-bold mb-4'>Not Found</h1>
<p className='text-destructive'>Could not find requested page</p>
<Button variant='outline' className='mt-4 ml-2' asChild>
<Link href='/'>Back To Home</Link>
</Button>
</div>
</div>
);
};
export default NotFoundPage;
================================================
FILE: app/unauthorized/page.tsx
================================================
import { Button } from '@/components/ui/button'
import { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Unauthorized Access',
}
export default function UnauthorizedPage() {
return (
<div className='container mx-auto flex h-[calc(100vh-200px)] flex-col items-center justify-center space-y-4'>
<h1 className='h1-bold text-4xl'>Unauthorized Access</h1>
<p className='text-muted-foreground'>
You do not have permission to access this page.
</p>
<Button asChild>
<Link href='/'>Return Home</Link>
</Button>
</div>
)
}
================================================
FILE: app/user/layout.tsx
================================================
import { APP_NAME } from '@/lib/constants';
import Image from 'next/image';
import Link from 'next/link';
import Menu from '@/components/shared/header/menu';
import MainNav from './main-nav';
export default function UserLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<div className='flex flex-col'>
<div className='border-b container mx-auto'>
<div className='flex items-center h-16 px-4'>
<Link href='/' className='w-22'>
<Image
src='/images/logo.svg'
height={48}
width={48}
alt={APP_NAME}
/>
</Link>
<MainNav className='mx-6' />
<div className='ml-auto items-center flex space-x-4'>
<Menu />
</div>
</div>
</div>
<div className='flex-1 space-y-4 p-8 pt-6 container mx-auto'>
{children}
</div>
</div>
</>
);
}
================================================
FILE: app/user/main-nav.tsx
================================================
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import React from 'react';
const links = [
{
title: 'Profile',
href: '/user/profile',
},
{
title: 'Orders',
href: '/user/orders',
},
];
const MainNav = ({
className,
...props
}: React.HTMLAttributes<HTMLElement>) => {
const pathname = usePathname();
return (
<nav
className={cn('flex items-center space-x-4 lg:space-x-6', className)}
{...props}
>
{links.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'text-sm font-medium transition-colors hover:text-primary',
pathname.includes(item.href) ? '' : 'text-muted-foreground'
)}
>
{item.title}
</Link>
))}
</nav>
);
};
export default MainNav;
================================================
FILE: app/user/orders/page.tsx
================================================
import { Metadata } from 'next';
import { getMyOrders } from '@/lib/actions/order.actions';
import { formatCurrency, formatDateTime, formatId } from '@/lib/utils';
import Link from 'next/link';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import Pagination from '@/components/shared/pagination';
export const metadata: Metadata = {
title: 'My Orders',
};
const OrdersPage = async (props: {
searchParams: Promise<{ page: string }>;
}) => {
const { page } = await props.searchParams;
const orders = await getMyOrders({
page: Number(page) || 1,
});
return (
<div className='space-y-2'>
<h2 className='h2-bold'>Orders</h2>
<div className='overflow-x-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>DATE</TableHead>
<TableHead>TOTAL</TableHead>
<TableHead>PAID</TableHead>
<TableHead>DELIVERED</TableHead>
<TableHead>ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.data.map((order) => (
<TableRow key={order.id}>
<TableCell>{formatId(order.id)}</TableCell>
<TableCell>
{formatDateTime(order.createdAt).dateTime}
</TableCell>
<TableCell>{formatCurrency(order.totalPrice)}</TableCell>
<TableCell>
{order.isPaid && order.paidAt
? formatDateTime(order.paidAt).dateTime
: 'Not Paid'}
</TableCell>
<TableCell>
{order.isDelivered && order.deliveredAt
? formatDateTime(order.deliveredAt).dateTime
: 'Not Delivered'}
</TableCell>
<TableCell>
<Link href={`/order/${order.id}`}>
<span className='px-2'>Details</span>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{orders.totalPages > 1 && (
<Pagination
page={Number(page) || 1}
totalPages={orders?.totalPages}
/>
)}
</div>
</div>
);
};
export default OrdersPage;
================================================
FILE: app/user/profile/page.tsx
================================================
import { Metadata } from 'next';
import { auth } from '@/auth';
import { SessionProvider } from 'next-auth/react';
import ProfileForm from './profile-form';
export const metadata: Metadata = {
title: 'Customer Profile',
};
const Profile = async () => {
const session = await auth();
return (
<SessionProvider session={session}>
<div className='max-w-md mx-auto space-y-4'>
<h2 className='h2-bold'>Profile</h2>
<ProfileForm />
</div>
</SessionProvider>
);
};
export default Profile;
================================================
FILE: app/user/profile/profile-form.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/use-toast';
import { updateProfile } from '@/lib/actions/user.actions';
import { updateProfileSchema } from '@/lib/validators';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ProfileForm = () => {
const { data: session, update } = useSession();
const form = useForm<z.infer<typeof updateProfileSchema>>({
resolver: zodResolver(updateProfileSchema),
defaultValues: {
name: session?.user?.name ?? '',
email: session?.user?.email ?? '',
},
});
const { toast } = useToast();
const onSubmit = async (values: z.infer<typeof updateProfileSchema>) => {
const res = await updateProfile(values);
if (!res.success) {
return toast({
variant: 'destructive',
description: res.message,
});
}
const newSession = {
...session,
user: {
...session?.user,
name: values.name,
},
};
await update(newSession);
toast({
description: res.message,
});
};
return (
<Form {...form}>
<form
className='flex flex-col gap-5'
onSubmit={form.handleSubmit(onSubmit)}
>
<div className='flex flex-col gap-5'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem className='w-full'>
<FormControl>
<Input
disabled
placeholder='Email'
className='input-field'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem className='w-full'>
<FormControl>
<Input
placeholder='Name'
className='input-field'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type='submit'
size='lg'
className='button col-span-2 w-full'
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? 'Submitting...' : 'Update Profile'}
</Button>
</form>
</Form>
);
};
export default ProfileForm;
================================================
FILE: assets/styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.wrapper {
@apply max-w-7xl lg:mx-auto p-5 md:px-10 w-full;
}
.flex-start {
@apply flex justify-start items-center;
}
.flex-center {
@apply flex justify-center items-center;
}
.flex-between {
@apply flex justify-between items-center;
}
.h1-bold {
@apply font-bold text-3xl lg:text-4xl;
}
.h2-bold {
@apply font-bold text-2xl lg:text-3xl;
}
.h3-bold {
@apply font-bold text-xl lg:text-2xl;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
body {
@apply bg-background text-foreground;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Uploadthing button text override */
html.dark .upload-field .text-white {
color: #ffffff !important;
}
.upload-field .text-white {
color: #000 !important;
}
================================================
FILE: auth.config.ts
================================================
import type { NextAuthConfig } from 'next-auth';
import { NextResponse } from 'next/server';
export const authConfig = {
providers: [], // Required by NextAuthConfig type
callbacks: {
authorized({ request, auth }) {
// Array of regex patterns of paths we want to protect
const protectedPaths = [
/\/shipping-address/,
/\/payment-method/,
/\/place-order/,
/\/profile/,
/\/user\/(.*)/,
/\/order\/(.*)/,
/\/admin/,
];
// Get pathname from the req URL object
const { pathname } = request.nextUrl;
// Check if user is not authenticated and accessing a protected path
if (!auth && protectedPaths.some((p) => p.test(pathname))) return false;
// Check for session cart cookie
if (!request.cookies.get('sessionCartId')) {
// Generate new session cart id cookie
const sessionCartId = crypto.randomUUID();
// Create new response and add the new headers
const response = NextResponse.next({
request: {
headers: new Headers(request.headers),
},
});
// Set newly generated sessionCartId in the response cookies
response.cookies.set('sessionCartId', sessionCartId);
return response;
}
return true;
},
},
} satisfies NextAuthConfig;
================================================
FILE: auth.ts
================================================
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/db/prisma';
import { cookies } from 'next/headers';
import { compare } from './lib/encrypt';
import CredentialsProvider from 'next-auth/providers/credentials';
export const { handlers, auth, signIn, signOut } = NextAuth({
pages: {
signIn: '/sign-in',
error: '/sign-in',
},
session: {
strategy: 'jwt' as const,
maxAge: 30 * 24 * 60 * 60, // 30 days
},
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
credentials: {
email: { type: 'email' },
password: { type: 'password' },
},
async authorize(credentials) {
if (credentials == null) return null;
// Find user in database
const user = await prisma.user.findFirst({
where: {
email: credentials.email as string,
},
});
// Check if user exists and if the password matches
if (user && user.password) {
const isMatch = await compare(
credentials.password as string,
user.password
);
// If password is correct, return user
if (isMatch) {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
};
}
}
// If user does not exist or password does not match return null
return null;
},
}),
],
callbacks: {
...authConfig.callbacks,
async session({ session, user, trigger, token }) {
// Set the user ID from the token
session.user.id = token.sub;
session.user.role = token.role;
session.user.name = token.name;
// If there is an update, set the user name
if (trigger === 'update') {
session.user.name = user.name;
}
return session;
},
async jwt({ token, user, trigger, session }) {
// Assign user fields to token
if (user) {
token.id = user.id;
token.role = user.role;
// If user has no name then use the email
if (user.name === 'NO_NAME') {
token.name = user.email!.split('@')[0];
// Update database to reflect the token name
await prisma.user.update({
where: { id: user.id },
data: { name: token.name },
});
}
if (trigger === 'signIn' || trigger === 'signUp') {
const cookiesObject = await cookies();
const sessionCartId = cookiesObject.get('sessionCartId')?.value;
if (sessionCartId) {
const sessionCart = await prisma.cart.findFirst({
where: { sessionCartId },
});
if (sessionCart) {
// Delete current user cart
await prisma.cart.deleteMany({
where: { userId: user.id },
});
// Assign new cart
await prisma.cart.update({
where: { id: sessionCart.id },
data: { userId: user.id },
});
}
}
}
}
// Handle session updates
if (session?.user.name && trigger === 'update') {
token.name = session.user.name;
}
return token;
},
},
});
================================================
FILE: components/admin/admin-search.tsx
================================================
'use client';
import { useState, useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { Input } from '../ui/input';
const AdminSearch = () => {
const pathname = usePathname();
const formActionUrl = pathname.includes('/admin/orders')
? '/admin/orders'
: pathname.includes('/admin/users')
? '/admin/users'
: '/admin/products';
const searchParams = useSearchParams();
const [queryValue, setQueryValue] = useState(searchParams.get('query') || '');
useEffect(() => {
setQueryValue(searchParams.get('query') || '');
}, [searchParams]);
return (
<form action={formActionUrl} method='GET'>
<Input
type='search'
placeholder='Search...'
name='query'
value={queryValue}
onChange={(e) => setQueryValue(e.target.value)}
className='md:w-[100px] lg:w-[300px]'
/>
<button className='sr-only' type='submit'>
Search
</button>
</form>
);
};
export default AdminSearch;
================================================
FILE: components/admin/product-form.tsx
================================================
'use client';
import { useToast } from '@/hooks/use-toast';
import { productDefaultValues } from '@/lib/constants';
import { insertProductSchema, updateProductSchema } from '@/lib/validators';
import { Product } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { ControllerRenderProps, SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form';
import slugify from 'slugify';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { Textarea } from '../ui/textarea';
import { createProduct, updateProduct } from '@/lib/actions/product.actions';
import { UploadButton } from '@/lib/uploadthing';
import { Card, CardContent } from '../ui/card';
import Image from 'next/image';
import { Checkbox } from '../ui/checkbox';
const ProductForm = ({
type,
product,
productId,
}: {
type: 'Create' | 'Update';
product?: Product;
productId?: string;
}) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<z.infer<typeof insertProductSchema>>({
resolver:
type === 'Update'
? zodResolver(updateProductSchema)
: zodResolver(insertProductSchema),
defaultValues:
product && type === 'Update' ? product : productDefaultValues,
});
const onSubmit: SubmitHandler<z.infer<typeof insertProductSchema>> = async (
values
) => {
// On Create
if (type === 'Create') {
const res = await createProduct(values);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
} else {
toast({
description: res.message,
});
router.push('/admin/products');
}
}
// On Update
if (type === 'Update') {
if (!productId) {
router.push('/admin/products');
return;
}
const res = await updateProduct({ ...values, id: productId });
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
} else {
toast({
description: res.message,
});
router.push('/admin/products');
}
}
};
const images = form.watch('images');
const isFeatured = form.watch('isFeatured');
const banner = form.watch('banner');
return (
<Form {...form}>
<form
method='POST'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-8'
>
<div className='flex flex-col md:flex-row gap-5'>
{/* Name */}
<FormField
control={form.control}
name='name'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'name'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Enter product name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Slug */}
<FormField
control={form.control}
name='slug'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'slug'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Name</FormLabel>
<FormControl>
<div className='relative'>
<Input placeholder='Enter slug' {...field} />
<Button
type='button'
className='bg-gray-500 hover:bg-gray-600 text-white px-4 py-1 mt-2'
onClick={() => {
form.setValue(
'slug',
slugify(form.getValues('name'), { lower: true })
);
}}
>
Generate
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex flex-col md:flex-row gap-5'>
{/* Category */}
<FormField
control={form.control}
name='category'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'category'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Category</FormLabel>
<FormControl>
<Input placeholder='Enter category' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Brand */}
<FormField
control={form.control}
name='brand'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'brand'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder='Enter brand' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex flex-col md:flex-row gap-5'>
{/* Price */}
<FormField
control={form.control}
name='price'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'price'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Price</FormLabel>
<FormControl>
<Input placeholder='Enter product price' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Stock */}
<FormField
control={form.control}
name='stock'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'stock'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Stock</FormLabel>
<FormControl>
<Input placeholder='Enter stock' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='upload-field flex flex-col md:flex-row gap-5'>
{/* Images */}
<FormField
control={form.control}
name='images'
render={() => (
<FormItem className='w-full'>
<FormLabel>Images</FormLabel>
<Card>
<CardContent className='space-y-2 mt-2 min-h-48'>
<div className='flex-start space-x-2'>
{images.map((image: string) => (
<Image
key={image}
src={image}
alt='product image'
className='w-20 h-20 object-cover object-center rounded-sm'
width={100}
height={100}
/>
))}
<FormControl>
<UploadButton
endpoint='imageUploader'
onClientUploadComplete={(res: { url: string }[]) => {
form.setValue('images', [...images, res[0].url]);
}}
onUploadError={(error: Error) => {
toast({
variant: 'destructive',
description: `ERROR! ${error.message}`,
});
}}
/>
</FormControl>
</div>
</CardContent>
</Card>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='upload-field'>
{/* isFeatured */}
Featured Product
<Card>
<CardContent className='space-y-2 mt-2'>
<FormField
control={form.control}
name='isFeatured'
render={({ field }) => (
<FormItem className='space-x-2 items-center'>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Is Featured?</FormLabel>
</FormItem>
)}
/>
{isFeatured && banner && (
<Image
src={banner}
alt='banner image'
className='w-full object-cover object-center rounded-sm'
width={1920}
height={680}
/>
)}
{isFeatured && !banner && (
<UploadButton
endpoint='imageUploader'
onClientUploadComplete={(res: { url: string }[]) => {
form.setValue('banner', res[0].url);
}}
onUploadError={(error: Error) => {
toast({
variant: 'destructive',
description: `ERROR! ${error.message}`,
});
}}
/>
)}
</CardContent>
</Card>
</div>
<div>
{/* Description */}
<FormField
control={form.control}
name='description'
render={({
field,
}: {
field: ControllerRenderProps<
z.infer<typeof insertProductSchema>,
'description'
>;
}) => (
<FormItem className='w-full'>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Enter product description'
className='resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<Button
type='submit'
size='lg'
disabled={form.formState.isSubmitting}
className='button col-span-2 w-full'
>
{form.formState.isSubmitting ? 'Submitting' : `${type} Product`}
</Button>
</div>
</form>
</Form>
);
};
export default ProductForm;
================================================
FILE: components/deal-countdown.tsx
================================================
'use client';
import Link from 'next/link';
import { Button } from './ui/button';
import Image from 'next/image';
import { useEffect, useState } from 'react';
// Static target date (replace with desired date)
const TARGET_DATE = new Date('2025-01-20T00:00:00');
// Function to calculate the time remaining
const calculateTimeRemaining = (targetDate: Date) => {
const currentTime = new Date();
const timeDifference = Math.max(Number(targetDate) - Number(currentTime), 0);
return {
days: Math.floor(timeDifference / (1000 * 60 * 60 * 24)),
hours: Math.floor(
(timeDifference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
),
minutes: Math.floor((timeDifference % (1000 * 60 * 60)) / (1000 * 60)),
seconds: Math.floor((timeDifference % (1000 * 60)) / 1000),
};
};
const DealCountdown = () => {
const [time, setTime] = useState<ReturnType<typeof calculateTimeRemaining>>();
useEffect(() => {
// Calculate initial time on client
setTime(calculateTimeRemaining(TARGET_DATE));
const timerInterval = setInterval(() => {
const newTime = calculateTimeRemaining(TARGET_DATE);
setTime(newTime);
if (
newTime.days === 0 &&
newTime.hours === 0 &&
newTime.minutes === 0 &&
newTime.seconds === 0
) {
clearInterval(timerInterval);
}
return () => clearInterval(timerInterval);
}, 1000);
}, []);
if (!time) {
return (
<section className='grid grid-cols-1 md:grid-cols-2 my-20'>
<div className='flex flex-col gap-2 justify-center'>
<h3 className='text-3xl font-bold'>Loading Countdown...</h3>
</div>
</section>
);
}
if (
time.days === 0 &&
time.hours === 0 &&
time.minutes === 0 &&
time.seconds === 0
) {
return (
<section className='grid grid-cols-1 md:grid-cols-2 my-20'>
<div className='flex flex-col gap-2 justify-center'>
<h3 className='text-3xl font-bold'>Deal Has Ended</h3>
<p>
This deal is no longer available. Check out our latest promotions!
</p>
<div className='text-center'>
<Button asChild>
<Link href='/search'>View Products</Link>
</Button>
</div>
</div>
<div className='flex justify-center'>
<Image
src='/images/promo.jpg'
alt='promotion'
width={300}
height={200}
/>
</div>
</section>
);
}
return (
<section className='grid grid-cols-1 md:grid-cols-2 my-20'>
<div className='flex flex-col gap-2 justify-center'>
<h3 className='text-3xl font-bold'>Deal Of The Month</h3>
<p>
Get ready for a shopping experience like never before with our Deals
of the Month! Every purchase comes with exclusive perks and offers,
making this month a celebration of savvy choices and amazing deals.
Don't miss out! 🎁🛒
</p>
<ul className='grid grid-cols-4'>
<StatBox label='Days' value={time.days} />
<StatBox label='Hours' value={time.hours} />
<StatBox label='Minutes' value={time.minutes} />
<StatBox label='Seconds' value={time.seconds} />
</ul>
<div className='text-center'>
<Button asChild>
<Link href='/search'>View Products</Link>
</Button>
</div>
</div>
<div className='flex justify-center'>
<Image
src='/images/promo.jpg'
alt='promotion'
width={300}
height={200}
/>
</div>
</section>
);
};
const StatBox = ({ label, value }: { label: string; value: number }) => (
<li className='p-4 w-full text-center'>
<p className='text-3xl font-bold'>{value}</p>
<p>{label}</p>
</li>
);
export default DealCountdown;
================================================
FILE: components/footer.tsx
================================================
import { APP_NAME } from '@/lib/constants';
const Footer = () => {
const currentYear = new Date().getFullYear();
return (
<footer className='border-t'>
<div className='p-5 flex-center'>
{currentYear} {APP_NAME}. All Rights Reserved
</div>
</footer>
);
};
export default Footer;
================================================
FILE: components/icon-boxes.tsx
================================================
import { DollarSign, Headset, ShoppingBag, WalletCards } from 'lucide-react';
import { Card, CardContent } from './ui/card';
const IconBoxes = () => {
return (
<div>
<Card>
<CardContent className='grid md:grid-cols-4 gap-4 p-4'>
<div className='space-y-2'>
<ShoppingBag />
<div className='text-sm font-bold'>Free Shipping</div>
<div className='text-sm text-muted-foreground'>
Free shipping on orders above $100
</div>
</div>
<div className='space-y-2'>
<DollarSign />
<div className='text-sm font-bold'>Money Back Guarantee</div>
<div className='text-sm text-muted-foreground'>
Within 30 days of purchase
</div>
</div>
<div className='space-y-2'>
<WalletCards />
<div className='text-sm font-bold'>Flexible Payment</div>
<div className='text-sm text-muted-foreground'>
Pay with credit card, PayPal or COD
</div>
</div>
<div className='space-y-2'>
<Headset />
<div className='text-sm font-bold'>24/7 Support</div>
<div className='text-sm text-muted-foreground'>
Get support at any time
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default IconBoxes;
================================================
FILE: components/shared/checkout-steps.tsx
================================================
import React from 'react';
import { cn } from '@/lib/utils';
const CheckoutSteps = ({ current = 0 }) => {
return (
<div className='flex-between flex-col md:flex-row space-x-2 space-y-2 mb-10'>
{['User Login', 'Shipping Address', 'Payment Method', 'Place Order'].map(
(step, index) => (
<React.Fragment key={step}>
<div
className={cn(
'p-2 w-56 rounded-full text-center text-sm',
index === current ? 'bg-secondary' : ''
)}
>
{step}
</div>
{step !== 'Place Order' && (
<hr className='w-16 border-t border-gray-300 mx-2' />
)}
</React.Fragment>
)
)}
</div>
);
};
export default CheckoutSteps;
================================================
FILE: components/shared/delete-dialog.tsx
================================================
'use client';
import { useState } from 'react';
import { useTransition } from 'react';
import { useToast } from '@/hooks/use-toast';
import { Button } from '../ui/button';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '../ui/alert-dialog';
const DeleteDialog = ({
id,
action,
}: {
id: string;
action: (id: string) => Promise<{ success: boolean; message: string }>;
}) => {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handleDeleteClick = () => {
startTransition(async () => {
const res = await action(id);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
} else {
setOpen(false);
toast({
description: res.message,
});
}
});
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button size='sm' variant='destructive' className='ml-2'>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
variant='destructive'
size='sm'
disabled={isPending}
onClick={handleDeleteClick}
>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteDialog;
================================================
FILE: components/shared/header/category-drawer.tsx
================================================
import { Button } from '@/components/ui/button';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
import { getAllCategories } from '@/lib/actions/product.actions';
import { MenuIcon } from 'lucide-react';
import Link from 'next/link';
const CategoryDrawer = async () => {
const categories = await getAllCategories();
return (
<Drawer direction='left'>
<DrawerTrigger asChild>
<Button variant='outline'>
<MenuIcon />
</Button>
</DrawerTrigger>
<DrawerContent className='h-full max-w-sm'>
<DrawerHeader>
<DrawerTitle>Select a category</DrawerTitle>
<div className='space-y-1 mt-4'>
{categories.map((x) => (
<Button
variant='ghost'
className='w-full justify-start'
key={x.category}
asChild
>
<DrawerClose asChild>
<Link href={`/search?category=${x.category}`}>
{x.category} ({x._count})
</Link>
</DrawerClose>
</Button>
))}
</div>
</DrawerHeader>
</DrawerContent>
</Drawer>
);
};
export default CategoryDrawer;
================================================
FILE: components/shared/header/index.tsx
================================================
import Image from 'next/image';
import Link from 'next/link';
import { APP_NAME } from '@/lib/constants';
import Menu from './menu';
import CategoryDrawer from './category-drawer';
import Search from './search';
const Header = () => {
return (
<header className='w-full border-b'>
<div className='wrapper flex-between'>
<div className='flex-start'>
<CategoryDrawer />
<Link href='/' className='flex-start ml-4'>
<Image
src='/images/logo.svg'
alt={`${APP_NAME} logo`}
height={48}
width={48}
priority={true}
/>
<span className='hidden lg:block font-bold text-2xl ml-3'>
{APP_NAME}
</span>
</Link>
</div>
<div className='hidden md:block'>
<Search />
</div>
<Menu />
</div>
</header>
);
};
export default Header;
================================================
FILE: components/shared/header/menu.tsx
================================================
import { Button } from '@/components/ui/button';
import ModeToggle from './mode-toggle';
import Link from 'next/link';
import { EllipsisVertical, ShoppingCart } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import UserButton from './user-button';
const Menu = () => {
return (
<div className='flex justify-end gap-3'>
<nav className='hidden md:flex w-full max-w-xs gap-1'>
<ModeToggle />
<Button asChild variant='ghost'>
<Link href='/cart'>
<ShoppingCart /> Cart
</Link>
</Button>
<UserButton />
</nav>
<nav className='md:hidden'>
<Sheet>
<SheetTrigger className='align-middle'>
<EllipsisVertical />
</SheetTrigger>
<SheetContent className='flex flex-col items-start'>
<SheetTitle>Menu</SheetTitle>
<ModeToggle />
<Button asChild variant='ghost'>
<Link href='/cart'>
<ShoppingCart /> Cart
</Link>
</Button>
<UserButton />
<SheetDescription></SheetDescription>
</SheetContent>
</Sheet>
</nav>
</div>
);
};
export default Menu;
================================================
FILE: components/shared/header/mode-toggle.tsx
================================================
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuContent,
DropdownMenuCheckboxItem,
} from '@/components/ui/dropdown-menu';
import { useTheme } from 'next-themes';
import { SunIcon, MoonIcon, SunMoon } from 'lucide-react';
const ModeToggle = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='focus-visible:ring-0 focus-visible:ring-offset-0'
>
{theme === 'system' ? (
<SunMoon />
) : theme === 'dark' ? (
<MoonIcon />
) : (
<SunIcon />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={theme === 'system'}
onClick={() => setTheme('system')}
>
System
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={theme === 'dark'}
onClick={() => setTheme('dark')}
>
Dark
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={theme === 'light'}
onClick={() => setTheme('light')}
>
Light
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default ModeToggle;
================================================
FILE: components/shared/header/search.tsx
================================================
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getAllCategories } from '@/lib/actions/product.actions';
import { SearchIcon } from 'lucide-react';
const Search = async () => {
const categories = await getAllCategories();
return (
<form action='/search' method='GET'>
<div className='flex w-full max-w-sm items-center space-x-2'>
<Select name='category'>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='All' />
</SelectTrigger>
<SelectContent>
<SelectItem key='All' value='all'>
All
</SelectItem>
{categories.map((x) => (
<SelectItem key={x.category} value={x.category}>
{x.category}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
name='q'
type='text'
placeholder='Search...'
className='md:w-[100px] lg:w-[300px]'
/>
<Button>
<SearchIcon />
</Button>
</div>
</form>
);
};
export default Search;
================================================
FILE: components/shared/header/user-button.tsx
================================================
import Link from 'next/link';
import { auth } from '@/auth';
import { signOutUser } from '@/lib/actions/user.actions';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UserIcon } from 'lucide-react';
const UserButton = async () => {
const session = await auth();
if (!session) {
return (
<Button asChild>
<Link href='/sign-in'>
<UserIcon /> Sign In
</Link>
</Button>
);
}
const firstInitial = session.user?.name?.charAt(0).toUpperCase() ?? 'U';
return (
<div className='flex gap-2 items-center'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className='flex items-center'>
<Button
variant='ghost'
className='relativee w-8 h-8 rounded-full ml-2 flex items-center justify-center bg-gray-200'
>
{firstInitial}
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col space-y-1'>
<div className='text-sm font-medium leading-none'>
{session.user?.name}
</div>
<div className='text-sm text-muted-foreground leading-none'>
{session.user?.email}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuItem>
<Link href='/user/profile' className='w-full'>
User Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href='/user/orders' className='w-full'>
Order History
</Link>
</DropdownMenuItem>
{session?.user?.role === 'admin' && (
<DropdownMenuItem>
<Link href='/admin/overview' className='w-full'>
Admin
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className='p-0 mb-1'>
<form action={signOutUser} className='w-full'>
<Button
className='w-full py-4 px-2 h-4 justify-start'
variant='ghost'
>
Sign Out
</Button>
</form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default UserButton;
================================================
FILE: components/shared/pagination.tsx
================================================
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '../ui/button';
import { formUrlQuery } from '@/lib/utils';
type PaginationProps = {
page: number | string;
totalPages: number;
urlParamName?: string;
};
const Pagination = ({ page, totalPages, urlParamName }: PaginationProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const handleClick = (btnType: string) => {
const pageValue = btnType === 'next' ? Number(page) + 1 : Number(page) - 1;
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: urlParamName || 'page',
value: pageValue.toString(),
});
router.push(newUrl);
};
return (
<div className='flex gap-2'>
<Button
size='lg'
variant='outline'
className='w-28'
disabled={Number(page) <= 1}
onClick={() => handleClick('prev')}
>
Previous
</Button>
<Button
size='lg'
variant='outline'
className='w-28'
disabled={Number(page) >= totalPages}
onClick={() => handleClick('next')}
>
Next
</Button>
</div>
);
};
export default Pagination;
================================================
FILE: components/shared/product/add-to-cart.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { Plus, Minus, Loader } from 'lucide-react';
import { Cart, CartItem } from '@/types';
import { useToast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast';
import { addItemToCart, removeItemFromCart } from '@/lib/actions/cart.actions';
import { useTransition } from 'react';
const AddToCart = ({ cart, item }: { cart?: Cart; item: CartItem }) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const handleAddToCart = async () => {
startTransition(async () => {
const res = await addItemToCart(item);
if (!res.success) {
toast({
variant: 'destructive',
description: res.message,
});
return;
}
// Handle success add to cart
toast({
description: res.message,
action: (
<ToastAction
className='bg-primary text-white hover:bg-gray-800'
altText='Go To Cart'
onClick={() => router.push('/cart')}
>
Go To Cart
</ToastAction>
),
});
});
};
// Handle remove from cart
const handleRemoveFromCart = async () => {
startTransition(async () => {
const res = await removeItemFromCart(item.productId);
toast({
variant: res.success ? 'default' : 'destructive',
description: res.message,
});
return;
});
};
// Check if item is in cart
const existItem =
cart && cart.items.find((x) => x.productId === item.productId);
return existItem ? (
<div>
<Button type='button' variant='outline' onClick={handleRemoveFromCart}>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<Minus className='w-4 h-4' />
)}
</Button>
<span className='px-2'>{existItem.qty}</span>
<Button type='button' variant='outline' onClick={handleAddToCart}>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<Plus className='w-4 h-4' />
)}
</Button>
</div>
) : (
<Button className='w-full' type='button' onClick={handleAddToCart}>
{isPending ? (
<Loader className='w-4 h-4 animate-spin' />
) : (
<Plus className='w-4 h-4' />
)}{' '}
Add To Cart
</Button>
);
};
export default AddToCart;
================================================
FILE: components/shared/product/product-card.tsx
================================================
import Link from 'next/link';
import Image from 'next/image';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import ProductPrice from './product-price';
import { Product } from '@/types';
import Rating from './rating';
const ProductCard = ({ product }: { product: Product }) => {
return (
<Card className='w-full max-w-sm'>
<CardHeader className='p-0 items-center'>
<Link href={`/product/${product.slug}`}>
<Image
src={product.images[0]}
alt={product.name}
height={300}
width={300}
priority={true}
/>
</Link>
</CardHeader>
<CardContent className='p-4 grid gap-4'>
<div className='text-xs'>{product.brand}</div>
<Link href={`/product/${product.slug}`}>
<h2 className='text-sm font-medium'>{product.name}</h2>
</Link>
<div className='flex-between gap-4'>
<Rating value={Number(product.rating)} />
{product.stock > 0 ? (
<ProductPrice value={Number(product.price)} />
) : (
<p className='text-destructive'>Out Of Stock</p>
)}
</div>
</CardContent>
</Card>
);
};
export default ProductCard;
================================================
FILE: components/shared/product/product-carousel.tsx
================================================
'use client';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Product } from '@/types';
import Autoplay from 'embla-carousel-autoplay';
import Link from 'next/link';
import Image from 'next/image';
const ProductCarousel = ({ data }: { data: Product[] }) => {
return (
<Carousel
className='w-full mb-12'
opts={{
loop: true,
}}
plugins={[
Autoplay({
delay: 10000,
stopOnInteraction: true,
stopOnMouseEnter: true,
}),
]}
>
<CarouselContent>
{data.map((product: Product) => (
<CarouselItem key={product.id}>
<Link href={`/product/${product.slug}`}>
<div className='relative mx-auto'>
<Image
src={product.banner!}
alt={product.name}
height='0'
width='0'
sizes='100vw'
className='w-full h-auto'
/>
<div className='absolute inset-0 flex items-end justify-center'>
<h2 className='bg-gray-900 bg-opacity-50 text-2xl font-bold px-2 text-white'>
{product.name}
</h2>
</div>
</div>
</Link>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
);
};
export default ProductCarousel;
================================================
FILE: components/shared/product/product-images.tsx
================================================
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { cn } from '@/lib/utils';
const ProductImages = ({ images }: { images: string[] }) => {
const [current, setCurrent] = useState(0);
return (
<div className='space-y-4'>
<Image
src={images[current]}
alt='product image'
width={1000}
height={1000}
className='min-h-[300px] object-cover object-center'
/>
<div className='flex'>
{images.map((image, index) => (
<div
key={image}
onClick={() => setCurrent(index)}
className={cn(
'border mr-2 cursor-pointer hover:border-orange-600',
current === index && 'border-orange-500'
)}
>
<Image src={image} alt='image' width={100} height={100} />
</div>
))}
</div>
</div>
);
};
export default ProductImages;
================================================
FILE: components/shared/product/product-list.tsx
================================================
import ProductCard from './product-card';
import { Product } from '@/types';
const ProductList = ({
data,
title,
limit,
}: {
data: Product[];
title?: string;
limit?: number;
}) => {
const limitedData = limit ? data.slice(0, limit) : data;
return (
<div className='my-10'>
<h2 className='h2-bold mb-4'>{title}</h2>
{data.length > 0 ? (
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
{limitedData.map((product: Product) => (
<ProductCard key={product.slug} product={product} />
))}
</div>
) : (
<div>
<p>No products found</p>
</div>
)}
</div>
);
};
export default ProductList;
================================================
FILE: components/shared/product/product-price.tsx
================================================
import { cn } from '@/lib/utils';
const ProductPrice = ({
value,
className,
}: {
value: number;
className?: string;
}) => {
// Ensure two decimal places
const stringValue = value.toFixed(2);
// Get the int/float
const [intValue, floatValue] = stringValue.split('.');
return (
<p className={cn('text-2xl', className)}>
<span className='text-xs align-super'>$</span>
{intValue}
<span className='text-xs align-super'>.{floatValue}</span>
</p>
);
};
export default ProductPrice;
================================================
FILE: components/shared/product/rating.tsx
================================================
const Rating = ({ value, caption }: { value: number; caption?: string }) => {
const Full = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
className='text-yellow-500 w-5 h-auto fill-current'
viewBox='0 0 16 16'
>
<path d='M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z' />
</svg>
);
const Half = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
className='text-yellow-500 w-5 h-auto fill-current'
viewBox='0 0 16 16'
>
<path d='M5.354 5.119 7.538.792A.516.516 0 0 1 8 .5c.183 0 .366.097.465.292l2.184 4.327 4.898.696A.537.537 0 0 1 16 6.32a.548.548 0 0 1-.17.445l-3.523 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256a.52.52 0 0 1-.146.05c-.342.06-.668-.254-.6-.642l.83-4.73L.173 6.765a.55.55 0 0 1-.172-.403.58.58 0 0 1 .085-.302.513.513 0 0 1 .37-.245l4.898-.696zM8 12.027a.5.5 0 0 1 .232.056l3.686 1.894-.694-3.957a.565.565 0 0 1 .162-.505l2.907-2.77-4.052-.576a.525.525 0 0 1-.393-.288L8.001 2.223 8 2.226v9.8z' />
</svg>
);
const Empty = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
className='text-yellow-500 w-5 h-auto fill-current'
viewBox='0 0 16 16'
>
<path d='M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z' />
</svg>
);
return (
<div className='flex gap-2'>
<div className='flex gap-1'>
{value >= 1 ? <Full /> : value >= 0.5 ? <Half /> : <Empty />}
{value >= 2 ? <Full /> : value >= 1.5 ? <Half /> : <Empty />}
{value >= 3 ? <Full /> : value >= 2.5 ? <Half /> : <Empty />}
{value >= 4 ? <Full /> : value >= 3.5 ? <Half /> : <Empty />}
{value >= 5 ? <Full /> : value >= 4.5 ? <Half /> : <Empty />}
</div>
{caption && <span className='text-sm'>{caption}</span>}
</div>
);
};
export default Rating;
================================================
FILE: components/ui/alert-dialog.tsx
================================================
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
================================================
FILE: components/ui/badge.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
================================================
FILE: components/ui/button.tsx
================================================
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
================================================
FILE: components/ui/card.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
================================================
FILE: components/ui/carousel.tsx
================================================
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
================================================
FILE: components/ui/checkbox.tsx
================================================
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
================================================
FILE: components/ui/dialog.tsx
================================================
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
================================================
FILE: components/ui/drawer.tsx
================================================
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
================================================
FILE: components/ui/dropdown-menu.tsx
================================================
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4
gitextract_eys7q486/
├── .eslintrc.json
├── .example-env
├── .gitignore
├── .prettierrc.yaml
├── README.md
├── app/
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── sign-in/
│ │ │ ├── credentials-signin-form.tsx
│ │ │ └── page.tsx
│ │ └── sign-up/
│ │ ├── page.tsx
│ │ └── sign-up-form.tsx
│ ├── (root)/
│ │ ├── cart/
│ │ │ ├── cart-table.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── order/
│ │ │ └── [id]/
│ │ │ ├── order-details-table.tsx
│ │ │ ├── page.tsx
│ │ │ ├── stripe-payment-success/
│ │ │ │ └── page.tsx
│ │ │ └── stripe-payment.tsx
│ │ ├── page.tsx
│ │ ├── payment-method/
│ │ │ ├── page.tsx
│ │ │ └── payment-method-form.tsx
│ │ ├── place-order/
│ │ │ ├── page.tsx
│ │ │ └── place-order-form.tsx
│ │ ├── product/
│ │ │ └── [slug]/
│ │ │ ├── page.tsx
│ │ │ ├── review-form.tsx
│ │ │ └── review-list.tsx
│ │ ├── search/
│ │ │ └── page.tsx
│ │ └── shipping-address/
│ │ ├── page.tsx
│ │ └── shipping-address-form.tsx
│ ├── admin/
│ │ ├── layout.tsx
│ │ ├── main-nav.tsx
│ │ ├── orders/
│ │ │ └── page.tsx
│ │ ├── overview/
│ │ │ ├── charts.tsx
│ │ │ └── page.tsx
│ │ ├── products/
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx
│ │ │ ├── create/
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── users/
│ │ ├── [id]/
│ │ │ ├── page.tsx
│ │ │ └── update-user-form.tsx
│ │ └── page.tsx
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts
│ │ ├── uploadthing/
│ │ │ ├── core.ts
│ │ │ └── route.ts
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── layout.tsx
│ ├── loading.tsx
│ ├── not-found.tsx
│ ├── unauthorized/
│ │ └── page.tsx
│ └── user/
│ ├── layout.tsx
│ ├── main-nav.tsx
│ ├── orders/
│ │ └── page.tsx
│ └── profile/
│ ├── page.tsx
│ └── profile-form.tsx
├── assets/
│ └── styles/
│ └── globals.css
├── auth.config.ts
├── auth.ts
├── components/
│ ├── admin/
│ │ ├── admin-search.tsx
│ │ └── product-form.tsx
│ ├── deal-countdown.tsx
│ ├── footer.tsx
│ ├── icon-boxes.tsx
│ ├── shared/
│ │ ├── checkout-steps.tsx
│ │ ├── delete-dialog.tsx
│ │ ├── header/
│ │ │ ├── category-drawer.tsx
│ │ │ ├── index.tsx
│ │ │ ├── menu.tsx
│ │ │ ├── mode-toggle.tsx
│ │ │ ├── search.tsx
│ │ │ └── user-button.tsx
│ │ ├── pagination.tsx
│ │ └── product/
│ │ ├── add-to-cart.tsx
│ │ ├── product-card.tsx
│ │ ├── product-carousel.tsx
│ │ ├── product-images.tsx
│ │ ├── product-list.tsx
│ │ ├── product-price.tsx
│ │ └── rating.tsx
│ ├── ui/
│ │ ├── alert-dialog.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── radio-group.tsx
│ │ ├── select.tsx
│ │ ├── sheet.tsx
│ │ ├── table.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ └── toaster.tsx
│ └── view-all-products-button.tsx
├── components.json
├── db/
│ ├── prisma.ts
│ ├── sample-data.ts
│ └── seed.ts
├── email/
│ ├── index.tsx
│ └── purchase-receipt.tsx
├── hooks/
│ └── use-toast.ts
├── jest.config.ts
├── jest.setup.ts
├── lib/
│ ├── actions/
│ │ ├── cart.actions.ts
│ │ ├── order.actions.ts
│ │ ├── product.actions.ts
│ │ ├── review.actions.ts
│ │ └── user.actions.ts
│ ├── auth-guard.ts
│ ├── constants/
│ │ └── index.ts
│ ├── encrypt.ts
│ ├── paypal.ts
│ ├── uploadthing.ts
│ ├── utils.ts
│ └── validators.ts
├── middleware.ts
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── prisma/
│ ├── migrations/
│ │ ├── 20241116125832_init/
│ │ │ └── migration.sql
│ │ ├── 20241118183645_add_user_based_tables/
│ │ │ └── migration.sql
│ │ ├── 20241121210251_add_cart/
│ │ │ └── migration.sql
│ │ ├── 20241125173259_add_order/
│ │ │ └── migration.sql
│ │ ├── 20241205162619_add_featured_default/
│ │ │ └── migration.sql
│ │ ├── 20241209181915_add_review/
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
├── tailwind.config.ts
├── tests/
│ └── paypal.test.ts
├── tsconfig.json
└── types/
├── index.ts
└── next-auth.d.ts
SYMBOL INDEX (140 symbols across 41 files)
FILE: app/(auth)/layout.tsx
function AuthLayout (line 1) | function AuthLayout({
FILE: app/(root)/cart/cart-table.tsx
function AddButton (line 25) | function AddButton({ item }: { item: CartItem }) {
function RemoveButton (line 55) | function RemoveButton({ item }: { item: CartItem }) {
FILE: app/(root)/layout.tsx
function RootLayout (line 4) | function RootLayout({
FILE: app/(root)/search/page.tsx
function generateMetadata (line 36) | async function generateMetadata(props: {
FILE: app/admin/layout.tsx
function AdminLayout (line 8) | function AdminLayout({
FILE: app/api/uploadthing/core.ts
type OurFileRouter (line 20) | type OurFileRouter = typeof ourFileRouter;
FILE: app/api/webhooks/stripe/route.ts
function POST (line 5) | async function POST(req: NextRequest) {
FILE: app/layout.tsx
function RootLayout (line 19) | function RootLayout({
FILE: app/unauthorized/page.tsx
function UnauthorizedPage (line 9) | function UnauthorizedPage() {
FILE: app/user/layout.tsx
function UserLayout (line 7) | function UserLayout({
FILE: auth.config.ts
method authorized (line 7) | authorized({ request, auth }) {
FILE: auth.ts
method authorize (line 25) | async authorize(credentials) {
method session (line 59) | async session({ session, user, trigger, token }) {
method jwt (line 72) | async jwt({ token, user, trigger, session }) {
FILE: components/deal-countdown.tsx
constant TARGET_DATE (line 9) | const TARGET_DATE = new Date('2025-01-20T00:00:00');
FILE: components/shared/pagination.tsx
type PaginationProps (line 6) | type PaginationProps = {
FILE: components/ui/badge.tsx
type BadgeProps (line 26) | interface BadgeProps
function Badge (line 30) | function Badge({ className, variant, ...props }: BadgeProps) {
FILE: components/ui/button.tsx
type ButtonProps (line 36) | interface ButtonProps
FILE: components/ui/carousel.tsx
type CarouselApi (line 12) | type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters (line 13) | type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions (line 14) | type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin (line 15) | type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps (line 17) | type CarouselProps = {
type CarouselContextProps (line 24) | type CarouselContextProps = {
function useCarousel (line 35) | function useCarousel() {
FILE: components/ui/form.tsx
type FormFieldContextValue (line 20) | type FormFieldContextValue<
type FormItemContextValue (line 67) | type FormItemContextValue = {
FILE: components/ui/sheet.tsx
type SheetContentProps (line 52) | interface SheetContentProps
FILE: components/ui/toast.tsx
type ToastProps (line 115) | type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement (line 117) | type ToastActionElement = React.ReactElement<typeof ToastAction>
FILE: components/ui/toaster.tsx
function Toaster (line 13) | function Toaster() {
FILE: db/prisma.ts
method compute (line 21) | compute(product) {
method compute (line 26) | compute(product) {
method compute (line 34) | compute(cart) {
method compute (line 40) | compute(cart) {
method compute (line 46) | compute(cart) {
method compute (line 52) | compute(cart) {
method compute (line 60) | compute(cart) {
method compute (line 66) | compute(cart) {
method compute (line 72) | compute(cart) {
method compute (line 78) | compute(cart) {
method compute (line 85) | compute(cart) {
FILE: db/seed.ts
function main (line 5) | async function main() {
FILE: email/purchase-receipt.tsx
type OrderInformationProps (line 65) | type OrderInformationProps = {
function PurchaseReceiptEmail (line 69) | function PurchaseReceiptEmail({ order }: OrderInformationProps) {
FILE: hooks/use-toast.ts
constant TOAST_LIMIT (line 11) | const TOAST_LIMIT = 1
constant TOAST_REMOVE_DELAY (line 12) | const TOAST_REMOVE_DELAY = 1000000
type ToasterToast (line 14) | type ToasterToast = ToastProps & {
function genId (line 30) | function genId() {
type ActionType (line 35) | type ActionType = typeof actionTypes
type Action (line 37) | type Action =
type State (line 55) | interface State {
function dispatch (line 136) | function dispatch(action: Action) {
type Toast (line 143) | type Toast = Omit<ToasterToast, "id">
function toast (line 145) | function toast({ ...props }: Toast) {
function useToast (line 174) | function useToast() {
FILE: lib/actions/cart.actions.ts
function addItemToCart (line 29) | async function addItemToCart(data: CartItem) {
function getMyCart (line 123) | async function getMyCart() {
function removeItemFromCart (line 150) | async function removeItemFromCart(productId: string) {
FILE: lib/actions/order.actions.ts
function createOrder (line 18) | async function createOrder() {
function getOrderById (line 107) | async function getOrderById(orderId: string) {
function createPayPalOrder (line 122) | async function createPayPalOrder(orderId: string) {
function approvePayPalOrder (line 162) | async function approvePayPalOrder(
function updateOrderToPaid (line 210) | async function updateOrderToPaid({
function getMyOrders (line 273) | async function getMyOrders({
type SalesDataType (line 300) | type SalesDataType = {
function getOrderSummary (line 306) | async function getOrderSummary() {
function getAllOrders (line 347) | async function getAllOrders({
function deleteOrder (line 387) | async function deleteOrder(id: string) {
function updateOrderToPaidCOD (line 403) | async function updateOrderToPaidCOD(orderId: string) {
function deliverOrder (line 416) | async function deliverOrder(orderId: string) {
FILE: lib/actions/product.actions.ts
function getLatestProducts (line 11) | async function getLatestProducts() {
function getProductBySlug (line 21) | async function getProductBySlug(slug: string) {
function getProductById (line 28) | async function getProductById(productId: string) {
function getAllProducts (line 37) | async function getAllProducts({
function deleteProduct (line 117) | async function deleteProduct(id: string) {
function createProduct (line 139) | async function createProduct(data: z.infer<typeof insertProductSchema>) {
function updateProduct (line 156) | async function updateProduct(data: z.infer<typeof updateProductSchema>) {
function getAllCategories (line 182) | async function getAllCategories() {
function getFeaturedProducts (line 192) | async function getFeaturedProducts() {
FILE: lib/actions/review.actions.ts
function createUpdateReview (line 11) | async function createUpdateReview(
function getReviews (line 88) | async function getReviews({ productId }: { productId: string }) {
function getReviewByProductId (line 109) | async function getReviewByProductId({
FILE: lib/actions/user.actions.ts
function signInWithCredentials (line 23) | async function signInWithCredentials(
function signOutUser (line 45) | async function signOutUser() {
function signUpUser (line 58) | async function signUpUser(prevState: unknown, formData: FormData) {
function getUserById (line 94) | async function getUserById(userId: string) {
function updateUserAddress (line 103) | async function updateUserAddress(data: ShippingAddress) {
function updateUserPaymentMethod (line 130) | async function updateUserPaymentMethod(
function updateProfile (line 158) | async function updateProfile(user: { name: string; email: string }) {
function getAllUsers (line 189) | async function getAllUsers({
function deleteUser (line 226) | async function deleteUser(id: string) {
function updateUser (line 245) | async function updateUser(user: z.infer<typeof updateUserSchema>) {
FILE: lib/auth-guard.ts
function requireAdmin (line 4) | async function requireAdmin() {
FILE: lib/constants/index.ts
constant APP_NAME (line 1) | const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || 'Prostore';
constant APP_DESCRIPTION (line 2) | const APP_DESCRIPTION =
constant SERVER_URL (line 5) | const SERVER_URL =
constant LATEST_PRODUCTS_LIMIT (line 7) | const LATEST_PRODUCTS_LIMIT =
constant PAYMENT_METHODS (line 30) | const PAYMENT_METHODS = process.env.PAYMENT_METHODS
constant DEFAULT_PAYMENT_METHOD (line 33) | const DEFAULT_PAYMENT_METHOD =
constant PAGE_SIZE (line 36) | const PAGE_SIZE = Number(process.env.PAGE_SIZE) || 12;
constant USER_ROLES (line 53) | const USER_ROLES = process.env.USER_ROLES
constant SENDER_EMAIL (line 63) | const SENDER_EMAIL = process.env.SENDER_EMAIL || 'onboarding@resend.dev';
FILE: lib/paypal.ts
function generateAccessToken (line 45) | async function generateAccessToken() {
function handleResponse (line 64) | async function handleResponse(response: Response) {
FILE: lib/utils.ts
function cn (line 5) | function cn(...inputs: ClassValue[]) {
function convertToPlainObject (line 10) | function convertToPlainObject<T>(value: T): T {
function formatNumberWithDecimal (line 15) | function formatNumberWithDecimal(num: number): string {
function formatError (line 22) | function formatError(error: any) {
function round2 (line 46) | function round2(value: number | string) {
constant CURRENCY_FORMATTER (line 56) | const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
function formatCurrency (line 63) | function formatCurrency(amount: number | string | null) {
constant NUMBER_FORMATTER (line 74) | const NUMBER_FORMATTER = new Intl.NumberFormat('en-US');
function formatNumber (line 76) | function formatNumber(number: number) {
function formatId (line 81) | function formatId(id: string) {
function formUrlQuery (line 126) | function formUrlQuery({
FILE: prisma/migrations/20241116125832_init/migration.sql
type "Product" (line 2) | CREATE TABLE "Product" (
type "Product" (line 22) | CREATE UNIQUE INDEX "product_slug_idx" ON "Product"("slug")
FILE: prisma/migrations/20241118183645_add_user_based_tables/migration.sql
type "User" (line 2) | CREATE TABLE "User" (
type "Account" (line 19) | CREATE TABLE "Account" (
type "Session" (line 38) | CREATE TABLE "Session" (
type "VerificationToken" (line 49) | CREATE TABLE "VerificationToken" (
type "User" (line 58) | CREATE UNIQUE INDEX "user_email_idx" ON "User"("email")
FILE: prisma/migrations/20241121210251_add_cart/migration.sql
type "Cart" (line 2) | CREATE TABLE "Cart" (
FILE: prisma/migrations/20241125173259_add_order/migration.sql
type "Order" (line 2) | CREATE TABLE "Order" (
type "OrderItem" (line 22) | CREATE TABLE "OrderItem" (
FILE: prisma/migrations/20241209181915_add_review/migration.sql
type "Review" (line 2) | CREATE TABLE "Review" (
FILE: types/index.ts
type Product (line 13) | type Product = z.infer<typeof insertProductSchema> & {
type Cart (line 20) | type Cart = z.infer<typeof insertCartSchema>;
type CartItem (line 21) | type CartItem = z.infer<typeof cartItemSchema>;
type ShippingAddress (line 22) | type ShippingAddress = z.infer<typeof shippingAddressSchema>;
type OrderItem (line 23) | type OrderItem = z.infer<typeof insertOrderItemSchema>;
type Order (line 24) | type Order = z.infer<typeof insertOrderSchema> & {
type PaymentResult (line 35) | type PaymentResult = z.infer<typeof paymentResultSchema>;
type Review (line 36) | type Review = z.infer<typeof insertReviewSchema> & {
FILE: types/next-auth.d.ts
type JWT (line 11) | interface JWT {
type Session (line 22) | interface Session {
type User (line 28) | interface User {
Condensed preview — 134 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (327K chars).
[
{
"path": ".eslintrc.json",
"chars": 61,
"preview": "{\n \"extends\": [\"next/core-web-vitals\", \"next/typescript\"]\n}\n"
},
{
"path": ".example-env",
"chars": 629,
"preview": "NEXT_PUBLIC_APP_NAME = \"Prostore\"\nNEXT_PUBLIC_APP_DESCRIPTION = \"A modern ecommerce store built with Next.js\"\nNEXT_PUBLI"
},
{
"path": ".gitignore",
"chars": 463,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".prettierrc.yaml",
"chars": 190,
"preview": "printWidth: 80\ntabWidth: 2\nuseTabs: false\nsemi: true\nsingleQuote: true\nbracketSpacing: true\njsxBracketSameLine: false\njs"
},
{
"path": "README.md",
"chars": 13846,
"preview": "# Prostore\n\nA full featured Ecommerce website built with Next.js, TypeScript, PostgreSQL and Prisma.\n\n<img src=\"/public/"
},
{
"path": "app/(auth)/layout.tsx",
"chars": 176,
"preview": "export default function AuthLayout({\n children,\n}: Readonly<{\n children: React.ReactNode;\n}>) {\n return <div classNam"
},
{
"path": "app/(auth)/sign-in/credentials-signin-form.tsx",
"chars": 2149,
"preview": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { "
},
{
"path": "app/(auth)/sign-in/page.tsx",
"chars": 1438,
"preview": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/components/ui/card';\nimport { Me"
},
{
"path": "app/(auth)/sign-up/page.tsx",
"chars": 1428,
"preview": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/components/ui/card';\nimport { Me"
},
{
"path": "app/(auth)/sign-up/sign-up-form.tsx",
"chars": 2689,
"preview": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { "
},
{
"path": "app/(root)/cart/cart-table.tsx",
"chars": 5019,
"preview": "'use client';\nimport { useRouter } from 'next/navigation';\nimport { useToast } from '@/hooks/use-toast';\nimport { useTra"
},
{
"path": "app/(root)/cart/page.tsx",
"chars": 309,
"preview": "import CartTable from './cart-table';\nimport { getMyCart } from '@/lib/actions/cart.actions';\n\nexport const metadata = {"
},
{
"path": "app/(root)/layout.tsx",
"chars": 355,
"preview": "import Header from '@/components/shared/header';\nimport Footer from '@/components/footer';\n\nexport default function Root"
},
{
"path": "app/(root)/order/[id]/order-details-table.tsx",
"chars": 8191,
"preview": "'use client';\nimport { Badge } from '@/components/ui/badge';\nimport { Card, CardContent } from '@/components/ui/card';\ni"
},
{
"path": "app/(root)/order/[id]/page.tsx",
"chars": 1627,
"preview": "import { Metadata } from 'next';\nimport { getOrderById } from '@/lib/actions/order.actions';\nimport { notFound, redirect"
},
{
"path": "app/(root)/order/[id]/stripe-payment-success/page.tsx",
"chars": 1454,
"preview": "import { Button } from '@/components/ui/button';\nimport { getOrderById } from '@/lib/actions/order.actions';\nimport Link"
},
{
"path": "app/(root)/order/[id]/stripe-payment.tsx",
"chars": 2852,
"preview": "import { FormEvent, useState } from 'react';\nimport { loadStripe } from '@stripe/stripe-js';\nimport {\n Elements,\n Link"
},
{
"path": "app/(root)/page.tsx",
"chars": 874,
"preview": "import ProductList from '@/components/shared/product/product-list';\nimport {\n getLatestProducts,\n getFeaturedProducts,"
},
{
"path": "app/(root)/payment-method/page.tsx",
"chars": 696,
"preview": "import { Metadata } from 'next';\nimport { auth } from '@/auth';\nimport { getUserById } from '@/lib/actions/user.actions'"
},
{
"path": "app/(root)/payment-method/payment-method-form.tsx",
"chars": 3825,
"preview": "'use client';\nimport { useRouter } from 'next/navigation';\nimport { useToast } from '@/hooks/use-toast';\nimport { useTra"
},
{
"path": "app/(root)/place-order/page.tsx",
"chars": 4894,
"preview": "import { auth } from '@/auth';\nimport { getMyCart } from '@/lib/actions/cart.actions';\nimport { getUserById } from '@/li"
},
{
"path": "app/(root)/place-order/place-order-form.tsx",
"chars": 999,
"preview": "'use client';\n\nimport { useRouter } from 'next/navigation';\nimport { Check, Loader } from 'lucide-react';\nimport { Butto"
},
{
"path": "app/(root)/product/[slug]/page.tsx",
"chars": 3619,
"preview": "import { Badge } from '@/components/ui/badge';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { getPro"
},
{
"path": "app/(root)/product/[slug]/review-form.tsx",
"chars": 5466,
"preview": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n Dialog,\n DialogContent,\n DialogDescription,"
},
{
"path": "app/(root)/product/[slug]/review-list.tsx",
"chars": 2583,
"preview": "'use client';\n\nimport { useEffect } from 'react';\nimport { Review } from '@/types';\nimport Link from 'next/link';\nimport"
},
{
"path": "app/(root)/search/page.tsx",
"chars": 6326,
"preview": "import ProductCard from '@/components/shared/product/product-card';\nimport { Button } from '@/components/ui/button';\nimp"
},
{
"path": "app/(root)/shipping-address/page.tsx",
"chars": 934,
"preview": "import { auth } from '@/auth';\nimport { getMyCart } from '@/lib/actions/cart.actions';\nimport { getUserById } from '@/li"
},
{
"path": "app/(root)/shipping-address/shipping-address-form.tsx",
"chars": 6309,
"preview": "'use client';\n\nimport { useRouter } from 'next/navigation';\nimport { useToast } from '@/hooks/use-toast';\nimport { useTr"
},
{
"path": "app/admin/layout.tsx",
"chars": 1090,
"preview": "import { APP_NAME } from '@/lib/constants';\nimport Image from 'next/image';\nimport Link from 'next/link';\nimport Menu fr"
},
{
"path": "app/admin/main-nav.tsx",
"chars": 1027,
"preview": "'use client';\nimport Link from 'next/link';\nimport { usePathname } from 'next/navigation';\nimport { cn } from '@/lib/uti"
},
{
"path": "app/admin/orders/page.tsx",
"chars": 3226,
"preview": "import {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table';\nimpo"
},
{
"path": "app/admin/overview/charts.tsx",
"chars": 873,
"preview": "'use client';\nimport { BarChart, Bar, XAxis, YAxis, ResponsiveContainer } from 'recharts';\n\nconst Charts = ({\n data: { "
},
{
"path": "app/admin/overview/page.tsx",
"chars": 4389,
"preview": "import {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table';\nimpo"
},
{
"path": "app/admin/products/[id]/page.tsx",
"chars": 806,
"preview": "import ProductForm from '@/components/admin/product-form';\nimport { getProductById } from '@/lib/actions/product.actions"
},
{
"path": "app/admin/products/create/page.tsx",
"chars": 471,
"preview": "import { Metadata } from 'next';\nimport ProductForm from '@/components/admin/product-form';\nimport { requireAdmin } from"
},
{
"path": "app/admin/products/page.tsx",
"chars": 3097,
"preview": "import Link from 'next/link';\nimport { getAllProducts, deleteProduct } from '@/lib/actions/product.actions';\nimport { fo"
},
{
"path": "app/admin/users/[id]/page.tsx",
"chars": 720,
"preview": "import { Metadata } from 'next';\nimport { notFound } from 'next/navigation';\nimport { getUserById } from '@/lib/actions/"
},
{
"path": "app/admin/users/[id]/update-user-form.tsx",
"chars": 4635,
"preview": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n Form,\n FormControl,\n FormField,\n FormItem,"
},
{
"path": "app/admin/users/page.tsx",
"chars": 2800,
"preview": "import { Metadata } from 'next';\nimport { getAllUsers, deleteUser } from '@/lib/actions/user.actions';\nimport {\n Table,"
},
{
"path": "app/api/auth/[...nextauth]/route.ts",
"chars": 74,
"preview": "import { handlers } from '@/auth';\nexport const { GET, POST } = handlers;\n"
},
{
"path": "app/api/uploadthing/core.ts",
"chars": 639,
"preview": "import { createUploadthing, type FileRouter } from 'uploadthing/next';\nimport { UploadThingError } from 'uploadthing/ser"
},
{
"path": "app/api/uploadthing/route.ts",
"chars": 175,
"preview": "import { createRouteHandler } from 'uploadthing/next';\nimport { ourFileRouter } from './core';\n\nexport const { GET, POST"
},
{
"path": "app/api/webhooks/stripe/route.ts",
"chars": 996,
"preview": "import { NextRequest, NextResponse } from 'next/server';\nimport Stripe from 'stripe';\nimport { updateOrderToPaid } from "
},
{
"path": "app/layout.tsx",
"chars": 978,
"preview": "import type { Metadata } from 'next';\nimport { Inter } from 'next/font/google';\nimport '@/assets/styles/globals.css';\nim"
},
{
"path": "app/loading.tsx",
"chars": 410,
"preview": "import Image from 'next/image';\nimport loader from '@/assets/loader.gif';\n\nconst LoadingPage = () => {\n return (\n <d"
},
{
"path": "app/not-found.tsx",
"chars": 830,
"preview": "'use client';\nimport { APP_NAME } from '@/lib/constants';\nimport Image from 'next/image';\nimport { Button } from '@/comp"
},
{
"path": "app/unauthorized/page.tsx",
"chars": 624,
"preview": "import { Button } from '@/components/ui/button'\nimport { Metadata } from 'next'\nimport Link from 'next/link'\n\nexport con"
},
{
"path": "app/user/layout.tsx",
"chars": 1000,
"preview": "import { APP_NAME } from '@/lib/constants';\nimport Image from 'next/image';\nimport Link from 'next/link';\nimport Menu fr"
},
{
"path": "app/user/main-nav.tsx",
"chars": 907,
"preview": "'use client';\nimport Link from 'next/link';\nimport { usePathname } from 'next/navigation';\nimport { cn } from '@/lib/uti"
},
{
"path": "app/user/orders/page.tsx",
"chars": 2404,
"preview": "import { Metadata } from 'next';\nimport { getMyOrders } from '@/lib/actions/order.actions';\nimport { formatCurrency, for"
},
{
"path": "app/user/profile/page.tsx",
"chars": 530,
"preview": "import { Metadata } from 'next';\nimport { auth } from '@/auth';\nimport { SessionProvider } from 'next-auth/react';\nimpor"
},
{
"path": "app/user/profile/profile-form.tsx",
"chars": 2839,
"preview": "'use client';\nimport { Button } from '@/components/ui/button';\nimport {\n Form,\n FormControl,\n FormField,\n FormItem,\n"
},
{
"path": "assets/styles/globals.css",
"chars": 2659,
"preview": "@tailwind base;\r\n@tailwind components;\r\n@tailwind utilities;\r\n\r\n@layer utilities {\r\n .wrapper {\r\n @apply max-w-7xl l"
},
{
"path": "auth.config.ts",
"chars": 1356,
"preview": "import type { NextAuthConfig } from 'next-auth';\nimport { NextResponse } from 'next/server';\n\nexport const authConfig = "
},
{
"path": "auth.ts",
"chars": 3394,
"preview": "import NextAuth from 'next-auth';\nimport { authConfig } from './auth.config';\nimport { PrismaAdapter } from '@auth/prism"
},
{
"path": "components/admin/admin-search.tsx",
"chars": 1028,
"preview": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { usePathname, useSearchParams } from 'next/navigatio"
},
{
"path": "components/admin/product-form.tsx",
"chars": 11659,
"preview": "'use client';\n\nimport { useToast } from '@/hooks/use-toast';\nimport { productDefaultValues } from '@/lib/constants';\nimp"
},
{
"path": "components/deal-countdown.tsx",
"chars": 3902,
"preview": "'use client';\n\nimport Link from 'next/link';\nimport { Button } from './ui/button';\nimport Image from 'next/image';\nimpor"
},
{
"path": "components/footer.tsx",
"chars": 315,
"preview": "import { APP_NAME } from '@/lib/constants';\n\nconst Footer = () => {\n const currentYear = new Date().getFullYear();\n\n r"
},
{
"path": "components/icon-boxes.tsx",
"chars": 1431,
"preview": "import { DollarSign, Headset, ShoppingBag, WalletCards } from 'lucide-react';\nimport { Card, CardContent } from './ui/ca"
},
{
"path": "components/shared/checkout-steps.tsx",
"chars": 800,
"preview": "import React from 'react';\nimport { cn } from '@/lib/utils';\n\nconst CheckoutSteps = ({ current = 0 }) => {\n return (\n "
},
{
"path": "components/shared/delete-dialog.tsx",
"chars": 1932,
"preview": "'use client';\nimport { useState } from 'react';\nimport { useTransition } from 'react';\nimport { useToast } from '@/hooks"
},
{
"path": "components/shared/header/category-drawer.tsx",
"chars": 1322,
"preview": "import { Button } from '@/components/ui/button';\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerHeader,\n Dr"
},
{
"path": "components/shared/header/index.tsx",
"chars": 947,
"preview": "import Image from 'next/image';\nimport Link from 'next/link';\nimport { APP_NAME } from '@/lib/constants';\nimport Menu fr"
},
{
"path": "components/shared/header/menu.tsx",
"chars": 1303,
"preview": "import { Button } from '@/components/ui/button';\nimport ModeToggle from './mode-toggle';\nimport Link from 'next/link';\ni"
},
{
"path": "components/shared/header/mode-toggle.tsx",
"chars": 1751,
"preview": "'use client';\nimport { useState, useEffect } from 'react';\nimport { Button } from '@/components/ui/button';\nimport {\n D"
},
{
"path": "components/shared/header/search.tsx",
"chars": 1273,
"preview": "import { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport {\n Select,\n Sel"
},
{
"path": "components/shared/header/user-button.tsx",
"chars": 2586,
"preview": "import Link from 'next/link';\nimport { auth } from '@/auth';\nimport { signOutUser } from '@/lib/actions/user.actions';\ni"
},
{
"path": "components/shared/pagination.tsx",
"chars": 1226,
"preview": "'use client';\nimport { useRouter, useSearchParams } from 'next/navigation';\nimport { Button } from '../ui/button';\nimpor"
},
{
"path": "components/shared/product/add-to-cart.tsx",
"chars": 2527,
"preview": "'use client';\nimport { Button } from '@/components/ui/button';\nimport { useRouter } from 'next/navigation';\nimport { Plu"
},
{
"path": "components/shared/product/product-card.tsx",
"chars": 1258,
"preview": "import Link from 'next/link';\nimport Image from 'next/image';\nimport { Card, CardContent, CardHeader } from '@/component"
},
{
"path": "components/shared/product/product-carousel.tsx",
"chars": 1536,
"preview": "'use client';\n\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n CarouselNext,\n CarouselPrevious,\n} from '@/com"
},
{
"path": "components/shared/product/product-images.tsx",
"chars": 944,
"preview": "'use client';\nimport { useState } from 'react';\nimport Image from 'next/image';\nimport { cn } from '@/lib/utils';\n\nconst"
},
{
"path": "components/shared/product/product-list.tsx",
"chars": 741,
"preview": "import ProductCard from './product-card';\nimport { Product } from '@/types';\n\nconst ProductList = ({\n data,\n title,\n "
},
{
"path": "components/shared/product/product-price.tsx",
"chars": 524,
"preview": "import { cn } from '@/lib/utils';\n\nconst ProductPrice = ({\n value,\n className,\n}: {\n value: number;\n className?: str"
},
{
"path": "components/shared/product/rating.tsx",
"chars": 2464,
"preview": "const Rating = ({ value, caption }: { value: number; caption?: string }) => {\n const Full = () => (\n <svg\n xmln"
},
{
"path": "components/ui/alert-dialog.tsx",
"chars": 4434,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimpor"
},
{
"path": "components/ui/badge.tsx",
"chars": 1128,
"preview": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/"
},
{
"path": "components/ui/button.tsx",
"chars": 1901,
"preview": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class"
},
{
"path": "components/ui/card.tsx",
"chars": 1858,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n HTMLDivElement,\n Rea"
},
{
"path": "components/ui/carousel.tsx",
"chars": 6224,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport useEmblaCarousel, {\n type UseEmblaCarouselType,\n} from \"embla-carou"
},
{
"path": "components/ui/checkbox.tsx",
"chars": 1070,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { Chec"
},
{
"path": "components/ui/dialog.tsx",
"chars": 3849,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from"
},
{
"path": "components/ui/drawer.tsx",
"chars": 3021,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { Drawer as DrawerPrimitive } from \"vaul\"\n\nimport { cn } from \"@/lib"
},
{
"path": "components/ui/dropdown-menu.tsx",
"chars": 7433,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimpo"
},
{
"path": "components/ui/form.tsx",
"chars": 4099,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } fro"
},
{
"path": "components/ui/input.tsx",
"chars": 791,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React"
},
{
"path": "components/ui/label.tsx",
"chars": 724,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type "
},
{
"path": "components/ui/radio-group.tsx",
"chars": 1481,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport {"
},
{
"path": "components/ui/select.tsx",
"chars": 5629,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, C"
},
{
"path": "components/ui/sheet.tsx",
"chars": 4281,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\"\nimport { cva, type"
},
{
"path": "components/ui/table.tsx",
"chars": 2765,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n HTMLTableElement,\n "
},
{
"path": "components/ui/textarea.tsx",
"chars": 689,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Textarea = React.forwardRef<\n HTMLTextAreaEleme"
},
{
"path": "components/ui/toast.tsx",
"chars": 4859,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type"
},
{
"path": "components/ui/toaster.tsx",
"chars": 786,
"preview": "\"use client\"\n\nimport { useToast } from \"@/hooks/use-toast\"\nimport {\n Toast,\n ToastClose,\n ToastDescription,\n ToastPr"
},
{
"path": "components/view-all-products-button.tsx",
"chars": 373,
"preview": "import { Button } from './ui/button';\nimport Link from 'next/link';\n\nconst ViewAllProductsButton = () => {\n return (\n "
},
{
"path": "components.json",
"chars": 451,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "db/prisma.ts",
"chars": 2441,
"preview": "import { Pool, neonConfig } from '@neondatabase/serverless';\nimport { PrismaNeon } from '@prisma/adapter-neon';\nimport {"
},
{
"path": "db/sample-data.ts",
"chars": 3296,
"preview": "const sampleData = {\r\n users: [\r\n {\r\n name: 'John',\r\n email: 'admin@example.com',\r\n password: '123456"
},
{
"path": "db/seed.ts",
"chars": 853,
"preview": "import { PrismaClient } from '@prisma/client';\nimport sampleData from './sample-data';\nimport { hash } from '@/lib/encry"
},
{
"path": "email/index.tsx",
"chars": 574,
"preview": "import { Resend } from 'resend';\nimport { SENDER_EMAIL, APP_NAME } from '@/lib/constants';\nimport { Order } from '@/type"
},
{
"path": "email/purchase-receipt.tsx",
"chars": 4440,
"preview": "import {\n Body,\n Column,\n Container,\n Head,\n Heading,\n Html,\n Img,\n Preview,\n Row,\n Section,\n Tailwind,\n Tex"
},
{
"path": "hooks/use-toast.ts",
"chars": 3948,
"preview": "\"use client\"\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type {\n ToastActionElement,"
},
{
"path": "jest.config.ts",
"chars": 6594,
"preview": "/**\n * For a detailed explanation regarding each configuration property, visit:\n * https://jestjs.io/docs/configuration\n"
},
{
"path": "jest.setup.ts",
"chars": 28,
"preview": "require('dotenv').config();\n"
},
{
"path": "lib/actions/cart.actions.ts",
"chars": 5673,
"preview": "'use server';\n\nimport { cookies } from 'next/headers';\nimport { CartItem } from '@/types';\nimport { convertToPlainObject"
},
{
"path": "lib/actions/order.actions.ts",
"chars": 10655,
"preview": "'use server';\n\nimport { isRedirectError } from 'next/dist/client/components/redirect';\nimport { convertToPlainObject, fo"
},
{
"path": "lib/actions/product.actions.ts",
"chars": 4556,
"preview": "'use server';\nimport { prisma } from '@/db/prisma';\nimport { convertToPlainObject, formatError } from '../utils';\nimport"
},
{
"path": "lib/actions/review.actions.ts",
"chars": 2936,
"preview": "'use server';\n\nimport { z } from 'zod';\nimport { insertReviewSchema } from '../validators';\nimport { formatError } from "
},
{
"path": "lib/actions/user.actions.ts",
"chars": 6033,
"preview": "'use server';\n\nimport {\n shippingAddressSchema,\n signInFormSchema,\n signUpFormSchema,\n paymentMethodSchema,\n update"
},
{
"path": "lib/auth-guard.ts",
"chars": 240,
"preview": "import { auth } from '@/auth'\nimport { redirect } from 'next/navigation'\n\nexport async function requireAdmin() {\n const"
},
{
"path": "lib/constants/index.ts",
"chars": 1509,
"preview": "export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || 'Prostore';\nexport const APP_DESCRIPTION =\n process.env.NEX"
},
{
"path": "lib/encrypt.ts",
"chars": 1695,
"preview": "const encoder = new TextEncoder();\nconst key = new TextEncoder().encode(process.env.ENCRYPTION_KEY); // Retrieve key fro"
},
{
"path": "lib/paypal.ts",
"chars": 1946,
"preview": "const base = process.env.PAYPAL_API_URL || 'https://api-m.sandbox.paypal.com';\n\nexport const paypal = {\n createOrder: a"
},
{
"path": "lib/uploadthing.ts",
"chars": 293,
"preview": "import {\n generateUploadButton,\n generateUploadDropzone,\n} from '@uploadthing/react';\n\nimport type { OurFileRouter } f"
},
{
"path": "lib/utils.ts",
"chars": 4214,
"preview": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\nimport qs from 'query-string';\n\n"
},
{
"path": "lib/validators.ts",
"chars": 4856,
"preview": "import { z } from 'zod';\nimport { formatNumberWithDecimal } from './utils';\nimport { PAYMENT_METHODS } from './constants"
},
{
"path": "middleware.ts",
"chars": 137,
"preview": "import NextAuth from 'next-auth';\nimport { authConfig } from './auth.config';\n\nexport const { auth: middleware } = NextA"
},
{
"path": "next.config.ts",
"chars": 242,
"preview": "import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n images: {\n remotePatterns: [\n {\n "
},
{
"path": "package.json",
"chars": 2303,
"preview": "{\n \"name\": \"prostore\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\": \"nex"
},
{
"path": "postcss.config.mjs",
"chars": 135,
"preview": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n plugins: {\n tailwindcss: {},\n },\n};\n\nexport d"
},
{
"path": "prisma/migrations/20241116125832_init/migration.sql",
"chars": 669,
"preview": "-- CreateTable\nCREATE TABLE \"Product\" (\n \"id\" UUID NOT NULL DEFAULT gen_random_uuid(),\n \"name\" TEXT NOT NULL,\n "
},
{
"path": "prisma/migrations/20241118183645_add_user_based_tables/migration.sql",
"chars": 1912,
"preview": "-- CreateTable\nCREATE TABLE \"User\" (\n \"id\" UUID NOT NULL DEFAULT gen_random_uuid(),\n \"name\" TEXT NOT NULL DEFAULT "
},
{
"path": "prisma/migrations/20241121210251_add_cart/migration.sql",
"chars": 620,
"preview": "-- CreateTable\nCREATE TABLE \"Cart\" (\n \"id\" UUID NOT NULL DEFAULT gen_random_uuid(),\n \"userId\" UUID,\n \"sessionCa"
},
{
"path": "prisma/migrations/20241125173259_add_order/migration.sql",
"chars": 1483,
"preview": "-- CreateTable\nCREATE TABLE \"Order\" (\n \"id\" UUID NOT NULL DEFAULT gen_random_uuid(),\n \"userId\" UUID NOT NULL,\n "
},
{
"path": "prisma/migrations/20241205162619_add_featured_default/migration.sql",
"chars": 81,
"preview": "-- AlterTable\nALTER TABLE \"Product\" ALTER COLUMN \"isFeatured\" SET DEFAULT false;\n"
},
{
"path": "prisma/migrations/20241209181915_add_review/migration.sql",
"chars": 739,
"preview": "-- CreateTable\nCREATE TABLE \"Review\" (\n \"id\" UUID NOT NULL DEFAULT gen_random_uuid(),\n \"userId\" UUID NOT NULL,\n "
},
{
"path": "prisma/migrations/migration_lock.toml",
"chars": 126,
"preview": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"postg"
},
{
"path": "prisma/schema.prisma",
"chars": 4689,
"preview": "generator client {\n provider = \"prisma-client-js\"\n previewFeatures = [\"driverAdapters\"]\n}\n\ndatasource db {\n pr"
},
{
"path": "tailwind.config.ts",
"chars": 1642,
"preview": "import type { Config } from \"tailwindcss\";\n\nexport default {\n darkMode: [\"class\"],\n content: [\n \"./pages/**/*.{"
},
{
"path": "tests/paypal.test.ts",
"chars": 1162,
"preview": "import { generateAccessToken, paypal } from '../lib/paypal';\n\n// Test to generate access token from paypal\ntest('generat"
},
{
"path": "tsconfig.json",
"chars": 598,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2017\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n "
},
{
"path": "types/index.ts",
"chars": 1094,
"preview": "import { z } from 'zod';\nimport {\n insertProductSchema,\n insertCartSchema,\n cartItemSchema,\n shippingAddressSchema,\n"
},
{
"path": "types/next-auth.d.ts",
"chars": 714,
"preview": "import { DefaultSession } from 'next-auth';\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nimport NextAu"
}
]
About this extraction
This page contains the full source code of the bradtraversy/prostore GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 134 files (299.6 KB), approximately 77.8k tokens, and a symbol index with 140 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.