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. 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 - [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) ## 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/** 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]() ### 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]() 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]() ### 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 ``` So we navigate the user back to the home page with a JavaScript click event, but this should really be a `` (link) instead. So we can change the code to: ```tsx ``` > 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
{children}
; } ================================================ 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 ( ); }; return (
{data && !data.success && (
{data.message}
)}
Don't have an account?{' '} Sign Up
); }; 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 (
{`${APP_NAME} Sign In Sign in to your account
); }; 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 (
{`${APP_NAME} Create Account Enter your information below to sign up
); }; 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 ( ); }; return (
{data && !data.success && (
{data.message}
)}
Already have an account?{' '} Sign In
); }; 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 ( ); } function RemoveButton({ item }: { item: CartItem }) { const { toast } = useToast(); const [isPending, startTransition] = useTransition(); return ( ); } const CartTable = ({ cart }: { cart?: Cart }) => { const router = useRouter(); const [isPending, startTransition] = useTransition(); return ( <>

Shopping Cart

{!cart || cart.items.length === 0 ? (
Cart is empty. Go Shopping
) : (
Item Quantity Price {cart.items.map((item) => ( {item.name} {item.qty} ${item.price} ))}
Subtotal ({cart.items.reduce((a, c) => a + c.qty, 0)}): {formatCurrency(cart.itemsPrice)}
)} ); }; 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 ( <> ); }; 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 (
{children}
); } ================================================ 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; 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 to mark order as delivered const MarkAsDeliveredButton = () => { const [isPending, startTransition] = useTransition(); const { toast } = useToast(); return ( ); }; return ( <>

Order {formatId(id)}

Payment Method

{paymentMethod}

{isPaid ? ( Paid at {formatDateTime(paidAt!).dateTime} ) : ( Not paid )}

Shipping Address

{shippingAddress.fullName}

{shippingAddress.streetAddress}, {shippingAddress.city} {shippingAddress.postalCode}, {shippingAddress.country}

{isDelivered ? ( Delivered at {formatDateTime(deliveredAt!).dateTime} ) : ( Not Delivered )}

Order Items

Item Quantity Price {orderitems.map((item) => ( {item.name} {item.qty} ${item.price} ))}
Items
{formatCurrency(itemsPrice)}
Tax
{formatCurrency(taxPrice)}
Shipping
{formatCurrency(shippingPrice)}
Total
{formatCurrency(totalPrice)}
{/* PayPal Payment */} {!isPaid && paymentMethod === 'PayPal' && (
)} {/* Stripe Payment */} {!isPaid && paymentMethod === 'Stripe' && stripeClientSecret && ( )} {/* Cash On Delivery */} {isAdmin && !isPaid && paymentMethod === 'CashOnDelivery' && ( )} {isAdmin && isPaid && !isDelivered && }
); }; 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 ( ); }; 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 (

Thanks for your purchase

We are processing your order.
); }; 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 (
Stripe Checkout
{errorMessage &&
{errorMessage}
}
setEmail(e.value.email)} />
); }; return ( ); }; 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 && ( )} ); }; 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 ( <> ); }; 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>({ resolver: zodResolver(paymentMethodSchema), defaultValues: { type: preferredPaymentMethod || DEFAULT_PAYMENT_METHOD, }, }); const [isPending, startTransition] = useTransition(); const onSubmit = async (values: z.infer) => { startTransition(async () => { const res = await updateUserPaymentMethod(values); if (!res.success) { toast({ variant: 'destructive', description: res.message, }); return; } router.push('/place-order'); }); }; return ( <>

Payment Method

Please select a payment method

( {PAYMENT_METHODS.map((paymentMethod) => ( {paymentMethod} ))} )} />
); }; 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 ( <>

Place Order

Shipping Address

{userAddress.fullName}

{userAddress.streetAddress}, {userAddress.city}{' '} {userAddress.postalCode}, {userAddress.country}{' '}

Payment Method

{user.paymentMethod}

Order Items

Item Quantity Price {cart.items.map((item) => ( {item.name} {item.qty} ${item.price} ))}
Items
{formatCurrency(cart.itemsPrice)}
Tax
{formatCurrency(cart.taxPrice)}
Shipping
{formatCurrency(cart.shippingPrice)}
Total
{formatCurrency(cart.totalPrice)}
); }; 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 ( ); }; return (
); }; 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 ( <>
{/* Images Column */}
{/* Details Column */}

{product.brand} {product.category}

{product.name}

{product.numReviews} reviews

Description

{product.description}

{/* Action Column */}
Price
Status
{product.stock > 0 ? ( In Stock ) : ( Out Of Stock )}
{product.stock > 0 && (
)}

Customer Reviews

); }; 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>({ 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> = 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 (
Write a Review Share your thoughts with other customers
( Title )} /> { return ( Description