├── .eslintrc.json
├── src
├── app
│ ├── globals.css
│ ├── favicon.ico
│ ├── not-found.tsx
│ ├── SessionProvider.tsx
│ ├── opengraph-image.png
│ ├── loading.tsx
│ ├── error.tsx
│ ├── layout.tsx
│ ├── cart
│ │ ├── page.tsx
│ │ ├── actions.ts
│ │ └── CartEntry.tsx
│ ├── products
│ │ └── [id]
│ │ │ ├── actions.ts
│ │ │ ├── AddToCartButton.tsx
│ │ │ └── page.tsx
│ ├── api
│ │ └── auth
│ │ │ └── [...nextauth]
│ │ │ └── route.ts
│ ├── Footer.tsx
│ ├── search
│ │ └── page.tsx
│ ├── Navbar
│ │ ├── Navbar.tsx
│ │ ├── UserMenuButton.tsx
│ │ └── ShoppingCartButton.tsx
│ ├── page.tsx
│ └── add-product
│ │ └── page.tsx
├── assets
│ ├── logo.png
│ └── profile-pic-placeholder.png
├── lib
│ ├── format.ts
│ ├── env.ts
│ └── db
│ │ ├── prisma.ts
│ │ └── cart.ts
└── components
│ ├── PriceTag.tsx
│ ├── FormSubmitButton.tsx
│ ├── ProductCard.tsx
│ └── PaginationBar.tsx
├── prettier.config.js
├── postcss.config.js
├── @types
└── next-auth.d.ts
├── next.config.js
├── README.md
├── .gitignore
├── public
├── vercel.svg
└── next.svg
├── tsconfig.json
├── tailwind.config.js
├── package.json
└── prisma
└── schema.prisma
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-ecommerce/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-ecommerce/HEAD/src/assets/logo.png
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require("prettier-plugin-tailwindcss")],
3 | };
4 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | export default function NotFoundPage() {
2 | return
Page not found.
;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/SessionProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export { SessionProvider as default } from "next-auth/react";
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-ecommerce/HEAD/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/assets/profile-pic-placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-ecommerce/HEAD/src/assets/profile-pic-placeholder.png
--------------------------------------------------------------------------------
/src/app/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function LoadingPage() {
2 | return ;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export default function ErrorPage() {
4 | return Something went wrong. Please refresh the page.
;
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/format.ts:
--------------------------------------------------------------------------------
1 | export function formatPrice(price: number) {
2 | return (price / 100).toLocaleString("en-US", {
3 | style: "currency",
4 | currency: "USD",
5 | });
6 | }
7 |
--------------------------------------------------------------------------------
/@types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import { DefaultSession } from "next-auth";
2 |
3 | declare module "next-auth" {
4 | interface Session {
5 | user: {
6 | id: string;
7 | } & DefaultSession["user"];
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/PriceTag.tsx:
--------------------------------------------------------------------------------
1 | import { formatPrice } from "@/lib/format";
2 |
3 | interface PriceTagProps {
4 | price: number;
5 | className?: string;
6 | }
7 |
8 | export default function PriceTag({ price, className }: PriceTagProps) {
9 | return {formatPrice(price)};
10 | }
11 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | { hostname: "images.unsplash.com" },
6 | { hostname: "lh3.googleusercontent.com" },
7 | ],
8 | },
9 | experimental: {
10 | serverActions: true,
11 | },
12 | };
13 |
14 | module.exports = nextConfig;
15 |
--------------------------------------------------------------------------------
/src/lib/env.ts:
--------------------------------------------------------------------------------
1 | import zod from "zod";
2 |
3 | const envSchema = zod.object({
4 | DATABASE_URL: zod.string().nonempty(),
5 | GOOGLE_CLIENT_ID: zod.string().nonempty(),
6 | GOOGLE_CLIENT_SECRET: zod.string().nonempty(),
7 | NEXTAUTH_URL: zod.string().nonempty(),
8 | NEXTAUTH_SECRET: zod.string().nonempty(),
9 | });
10 |
11 | export const env = envSchema.parse(process.env);
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.JS 13.4 E-Commerce App
2 |
3 | An e-commerce website similar to Amazon that makes heavy use of **Next.js server actions**.
4 |
5 | Includes user sign-in with **Next-Auth**, **anonymous shopping carts**, **Prisma client extensions**, and more. Written in **TypeScript**. UI built with **TailwindCSS** & **DaisyUI**.
6 |
7 | Learn how to build this project: https://www.youtube.com/watch?v=AaiijESQH5o
8 |
9 | 
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | # My additions
38 | .env
--------------------------------------------------------------------------------
/src/lib/db/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | const globalForPrisma = globalThis as unknown as {
4 | prisma: PrismaClient | undefined;
5 | };
6 |
7 | const prismaBase = globalForPrisma.prisma ?? new PrismaClient();
8 |
9 | export const prisma = prismaBase.$extends({
10 | query: {
11 | cart: {
12 | async update({ args, query }) {
13 | args.data = { ...args.data, updatedAt: new Date() };
14 | return query(args);
15 | },
16 | },
17 | },
18 | });
19 |
20 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismaBase;
21 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/FormSubmitButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ComponentProps } from "react";
4 | import { experimental_useFormStatus as useFormStatus } from "react-dom";
5 |
6 | type FormSubmitButtonProps = {
7 | children: React.ReactNode;
8 | className?: string;
9 | } & ComponentProps<"button">;
10 |
11 | export default function FormSubmitButton({
12 | children,
13 | className,
14 | ...props
15 | }: FormSubmitButtonProps) {
16 | const { pending } = useFormStatus();
17 |
18 | return (
19 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | plugins: [require("daisyui")],
9 | daisyui: {
10 | themes: [
11 | {
12 | lightTheme: {
13 | primary: "#f4aa3a",
14 | secondary: "#f4f4a1",
15 | accent: "#1be885",
16 | neutral: "#272136",
17 | "base-100": "#ffffff",
18 | info: "#778ad4",
19 | success: "#23b893",
20 | warning: "#f79926",
21 | error: "#ea535a",
22 | body: {
23 | "background-color": "#e3e6e6",
24 | },
25 | },
26 | },
27 | ],
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "./Footer";
2 | import Navbar from "./Navbar/Navbar";
3 | import "./globals.css";
4 | import { Inter } from "next/font/google";
5 | import SessionProvider from "./SessionProvider";
6 |
7 | const inter = Inter({ subsets: ["latin"] });
8 |
9 | export const metadata = {
10 | title: "Flowmazon",
11 | description: "We make your wallet cry",
12 | };
13 |
14 | export default function RootLayout({
15 | children,
16 | }: {
17 | children: React.ReactNode;
18 | }) {
19 | return (
20 |
21 |
22 |
23 |
24 | {children}
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-ecommerce",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "postinstall": "prisma generate"
11 | },
12 | "dependencies": {
13 | "@auth/prisma-adapter": "^1.0.0",
14 | "@prisma/client": "^4.16.1",
15 | "@types/node": "20.3.1",
16 | "@types/react": "18.2.14",
17 | "@types/react-dom": "18.2.6",
18 | "autoprefixer": "10.4.14",
19 | "daisyui": "^3.1.6",
20 | "eslint": "8.43.0",
21 | "eslint-config-next": "13.4.7",
22 | "eslint-config-prettier": "^8.8.0",
23 | "next": "13.4.7",
24 | "next-auth": "^4.22.1",
25 | "postcss": "8.4.24",
26 | "prettier": "^2.8.8",
27 | "prettier-plugin-tailwindcss": "^0.3.0",
28 | "prisma": "^4.16.1",
29 | "react": "18.2.0",
30 | "react-dom": "18.2.0",
31 | "tailwindcss": "3.3.2",
32 | "typescript": "5.1.3",
33 | "zod": "^3.21.4"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/cart/page.tsx:
--------------------------------------------------------------------------------
1 | import { getCart } from "@/lib/db/cart";
2 | import { formatPrice } from "@/lib/format";
3 | import CartEntry from "./CartEntry";
4 | import { setProductQuantity } from "./actions";
5 |
6 | export const metadata = {
7 | title: "Your Cart - Flowmazon",
8 | };
9 |
10 | export default async function CartPage() {
11 | const cart = await getCart();
12 |
13 | return (
14 |
15 |
Shopping Cart
16 | {cart?.items.map((cartItem) => (
17 |
22 | ))}
23 | {!cart?.items.length &&
Your cart is empty.
}
24 |
25 |
26 | Total: {formatPrice(cart?.subtotal || 0)}
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/products/[id]/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { createCart, getCart } from "@/lib/db/cart";
4 | import { prisma } from "@/lib/db/prisma";
5 | import { revalidatePath } from "next/cache";
6 |
7 | export async function incrementProductQuantity(productId: string) {
8 | const cart = (await getCart()) ?? (await createCart());
9 |
10 | const articleInCart = cart.items.find((item) => item.productId === productId);
11 |
12 | if (articleInCart) {
13 | await prisma.cart.update({
14 | where: { id: cart.id },
15 | data: {
16 | items: {
17 | update: {
18 | where: { id: articleInCart.id },
19 | data: { quantity: { increment: 1 } },
20 | },
21 | },
22 | },
23 | });
24 | } else {
25 | await prisma.cart.update({
26 | where: { id: cart.id },
27 | data: {
28 | items: {
29 | create: {
30 | productId,
31 | quantity: 1,
32 | },
33 | },
34 | },
35 | });
36 | }
37 |
38 | revalidatePath("/products/[id]");
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { mergeAnonymousCartIntoUserCart } from "@/lib/db/cart";
2 | import { prisma } from "@/lib/db/prisma";
3 | import { env } from "@/lib/env";
4 | import { PrismaAdapter } from "@auth/prisma-adapter";
5 | import { PrismaClient } from "@prisma/client";
6 | import { NextAuthOptions } from "next-auth";
7 | import { Adapter } from "next-auth/adapters";
8 | import NextAuth from "next-auth/next";
9 | import GoogleProvider from "next-auth/providers/google";
10 |
11 | export const authOptions: NextAuthOptions = {
12 | adapter: PrismaAdapter(prisma as PrismaClient) as Adapter,
13 | providers: [
14 | GoogleProvider({
15 | clientId: env.GOOGLE_CLIENT_ID,
16 | clientSecret: env.GOOGLE_CLIENT_SECRET,
17 | }),
18 | ],
19 | callbacks: {
20 | session({ session, user }) {
21 | session.user.id = user.id;
22 | return session;
23 | },
24 | },
25 | events: {
26 | async signIn({ user }) {
27 | await mergeAnonymousCartIntoUserCart(user.id);
28 | },
29 | },
30 | };
31 |
32 | const handler = NextAuth(authOptions);
33 |
34 | export { handler as GET, handler as POST };
35 |
--------------------------------------------------------------------------------
/src/components/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import { Product } from "@prisma/client";
2 | import Link from "next/link";
3 | import PriceTag from "./PriceTag";
4 | import Image from "next/image";
5 |
6 | interface ProductCardProps {
7 | product: Product;
8 | }
9 |
10 | export default function ProductCard({ product }: ProductCardProps) {
11 | const isNew =
12 | Date.now() - new Date(product.createdAt).getTime() <
13 | 1000 * 60 * 60 * 24 * 7;
14 |
15 | return (
16 |
20 |
21 |
28 |
29 |
30 |
{product.name}
31 | {isNew &&
NEW
}
32 |
{product.description}
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/Footer.tsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return (
3 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/search/page.tsx:
--------------------------------------------------------------------------------
1 | import ProductCard from "@/components/ProductCard";
2 | import { prisma } from "@/lib/db/prisma";
3 | import { Metadata } from "next";
4 |
5 | interface SearchPageProps {
6 | searchParams: { query: string };
7 | }
8 |
9 | export function generateMetadata({
10 | searchParams: { query },
11 | }: SearchPageProps): Metadata {
12 | return {
13 | title: `Search: ${query} - Flowmazon`,
14 | };
15 | }
16 |
17 | export default async function SearchPage({
18 | searchParams: { query },
19 | }: SearchPageProps) {
20 | const products = await prisma.product.findMany({
21 | where: {
22 | OR: [
23 | { name: { contains: query, mode: "insensitive" } },
24 | { description: { contains: query, mode: "insensitive" } },
25 | ],
26 | },
27 | orderBy: { id: "desc" },
28 | });
29 |
30 | if (products.length === 0) {
31 | return No products found
;
32 | }
33 |
34 | return (
35 |
36 | {products.map((product) => (
37 |
38 | ))}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/cart/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { createCart, getCart } from "@/lib/db/cart";
4 | import { prisma } from "@/lib/db/prisma";
5 | import { revalidatePath } from "next/cache";
6 |
7 | export async function setProductQuantity(productId: string, quantity: number) {
8 | const cart = (await getCart()) ?? (await createCart());
9 |
10 | const articleInCart = cart.items.find((item) => item.productId === productId);
11 |
12 | if (quantity === 0) {
13 | if (articleInCart) {
14 | await prisma.cart.update({
15 | where: { id: cart.id },
16 | data: {
17 | items: {
18 | delete: { id: articleInCart.id },
19 | },
20 | },
21 | });
22 | }
23 | } else {
24 | if (articleInCart) {
25 | await prisma.cart.update({
26 | where: { id: cart.id },
27 | data: {
28 | items: {
29 | update: {
30 | where: { id: articleInCart.id },
31 | data: { quantity },
32 | },
33 | },
34 | },
35 | });
36 | } else {
37 | await prisma.cart.update({
38 | where: { id: cart.id },
39 | data: {
40 | items: {
41 | create: {
42 | productId,
43 | quantity,
44 | },
45 | },
46 | },
47 | });
48 | }
49 | }
50 |
51 | revalidatePath("/cart");
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/PaginationBar.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | interface PaginationBarProps {
4 | currentPage: number;
5 | totalPages: number;
6 | }
7 |
8 | export default function PaginationBar({
9 | currentPage,
10 | totalPages,
11 | }: PaginationBarProps) {
12 | const maxPage = Math.min(totalPages, Math.max(currentPage + 4, 10));
13 | const minPage = Math.max(1, Math.min(currentPage - 5, maxPage - 9));
14 |
15 | const numberedPageItems: JSX.Element[] = [];
16 |
17 | for (let page = minPage; page <= maxPage; page++) {
18 | numberedPageItems.push(
19 |
26 | {page}
27 |
28 | );
29 | }
30 |
31 | return (
32 | <>
33 | {numberedPageItems}
34 |
35 | {currentPage > 1 && (
36 |
37 | «
38 |
39 | )}
40 |
43 | {currentPage < totalPages && (
44 |
45 | »
46 |
47 | )}
48 |
49 | >
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/products/[id]/AddToCartButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useTransition } from "react";
4 |
5 | interface AddToCartButtonProps {
6 | productId: string;
7 | incrementProductQuantity: (productId: string) => Promise;
8 | }
9 |
10 | export default function AddToCartButton({
11 | productId,
12 | incrementProductQuantity,
13 | }: AddToCartButtonProps) {
14 | const [isPending, startTransition] = useTransition();
15 | const [success, setSuccess] = useState(false);
16 |
17 | return (
18 |
19 |
45 | {isPending &&
}
46 | {!isPending && success && (
47 |
Added to Cart.
48 | )}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/Navbar/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import logo from "@/assets/logo.png";
2 | import { getCart } from "@/lib/db/cart";
3 | import { getServerSession } from "next-auth";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 | import { redirect } from "next/navigation";
7 | import { authOptions } from "../api/auth/[...nextauth]/route";
8 | import ShoppingCartButton from "./ShoppingCartButton";
9 | import UserMenuButton from "./UserMenuButton";
10 |
11 | async function searchProducts(formData: FormData) {
12 | "use server";
13 |
14 | const searchQuery = formData.get("searchQuery")?.toString();
15 |
16 | if (searchQuery) {
17 | redirect("/search?query=" + searchQuery);
18 | }
19 | }
20 |
21 | export default async function Navbar() {
22 | const session = await getServerSession(authOptions);
23 | const cart = await getCart();
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
31 | Flowmazon
32 |
33 |
34 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/products/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import PriceTag from "@/components/PriceTag";
2 | import { prisma } from "@/lib/db/prisma";
3 | import { Metadata } from "next";
4 | import Image from "next/image";
5 | import { notFound } from "next/navigation";
6 | import { cache } from "react";
7 | import AddToCartButton from "./AddToCartButton";
8 | import { incrementProductQuantity } from "./actions";
9 |
10 | interface ProductPageProps {
11 | params: {
12 | id: string;
13 | };
14 | }
15 |
16 | const getProduct = cache(async (id: string) => {
17 | const product = await prisma.product.findUnique({ where: { id } });
18 | if (!product) notFound();
19 | return product;
20 | });
21 |
22 | export async function generateMetadata({
23 | params: { id },
24 | }: ProductPageProps): Promise {
25 | const product = await getProduct(id);
26 |
27 | return {
28 | title: product.name + " - Flowmazon",
29 | description: product.description,
30 | openGraph: {
31 | images: [{ url: product.imageUrl }],
32 | },
33 | };
34 | }
35 |
36 | export default async function ProductPage({
37 | params: { id },
38 | }: ProductPageProps) {
39 | const product = await getProduct(id);
40 |
41 | return (
42 |
43 |
51 |
52 |
53 |
{product.name}
54 |
55 |
{product.description}
56 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/Navbar/UserMenuButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import profilePicPlaceholder from "@/assets/profile-pic-placeholder.png";
4 | import { Session } from "next-auth";
5 | import { signIn, signOut } from "next-auth/react";
6 | import Image from "next/image";
7 |
8 | interface UserMenuButtonProps {
9 | session: Session | null;
10 | }
11 |
12 | export default function UserMenuButton({ session }: UserMenuButtonProps) {
13 | const user = session?.user;
14 |
15 | return (
16 |
17 |
42 |
46 | -
47 | {user ? (
48 |
51 | ) : (
52 |
53 | )}
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/Navbar/ShoppingCartButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ShoppingCart } from "@/lib/db/cart";
4 | import { formatPrice } from "@/lib/format";
5 | import Link from "next/link";
6 |
7 | interface ShoppingCartButtonProps {
8 | cart: ShoppingCart | null;
9 | }
10 |
11 | export default function ShoppingCartButton({ cart }: ShoppingCartButtonProps) {
12 | function closeDropdown() {
13 | const elem = document.activeElement as HTMLElement;
14 | if (elem) {
15 | elem.blur();
16 | }
17 | }
18 |
19 | return (
20 |
21 |
42 |
46 |
47 |
{cart?.size || 0} Items
48 |
49 | Subtotal: {formatPrice(cart?.subtotal || 0)}
50 |
51 |
52 |
57 | View cart
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import PaginationBar from "@/components/PaginationBar";
2 | import ProductCard from "@/components/ProductCard";
3 | import { prisma } from "@/lib/db/prisma";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 |
7 | interface HomeProps {
8 | searchParams: { page: string };
9 | }
10 |
11 | export default async function Home({
12 | searchParams: { page = "1" },
13 | }: HomeProps) {
14 | const currentPage = parseInt(page);
15 |
16 | const pageSize = 6;
17 | const heroItemCount = 1;
18 |
19 | const totalItemCount = await prisma.product.count();
20 |
21 | const totalPages = Math.ceil((totalItemCount - heroItemCount) / pageSize);
22 |
23 | const products = await prisma.product.findMany({
24 | orderBy: { id: "desc" },
25 | skip:
26 | (currentPage - 1) * pageSize + (currentPage === 1 ? 0 : heroItemCount),
27 | take: pageSize + (currentPage === 1 ? heroItemCount : 0),
28 | });
29 |
30 | return (
31 |
32 | {currentPage === 1 && (
33 |
34 |
35 |
43 |
44 |
{products[0].name}
45 |
{products[0].description}
46 |
50 | Check it out
51 |
52 |
53 |
54 |
55 | )}
56 |
57 |
58 | {(currentPage === 1 ? products.slice(1) : products).map((product) => (
59 |
60 | ))}
61 |
62 |
63 | {totalPages > 1 && (
64 |
65 | )}
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/cart/CartEntry.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CartItemWithProduct } from "@/lib/db/cart";
4 | import { formatPrice } from "@/lib/format";
5 | import Image from "next/image";
6 | import Link from "next/link";
7 | import { useTransition } from "react";
8 |
9 | interface CartEntryProps {
10 | cartItem: CartItemWithProduct;
11 | setProductQuantity: (productId: string, quantity: number) => Promise;
12 | }
13 |
14 | export default function CartEntry({
15 | cartItem: { product, quantity },
16 | setProductQuantity,
17 | }: CartEntryProps) {
18 | const [isPending, startTransition] = useTransition();
19 |
20 | const quantityOptions: JSX.Element[] = [];
21 | for (let i = 1; i <= 99; i++) {
22 | quantityOptions.push(
23 |
26 | );
27 | }
28 |
29 | return (
30 |
31 |
32 |
39 |
40 |
41 | {product.name}
42 |
43 |
Price: {formatPrice(product.price)}
44 |
45 | Quantity:
46 |
59 |
60 |
61 | Total: {formatPrice(product.price * quantity)}
62 | {isPending && (
63 |
64 | )}
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/app/add-product/page.tsx:
--------------------------------------------------------------------------------
1 | import FormSubmitButton from "@/components/FormSubmitButton";
2 | import { prisma } from "@/lib/db/prisma";
3 | import { getServerSession } from "next-auth";
4 | import { redirect } from "next/navigation";
5 | import { authOptions } from "../api/auth/[...nextauth]/route";
6 |
7 | export const metadata = {
8 | title: "Add Product - Flowmazon",
9 | };
10 |
11 | async function addProduct(formData: FormData) {
12 | "use server";
13 |
14 | const session = await getServerSession(authOptions);
15 |
16 | if (!session) {
17 | redirect("/api/auth/signin?callbackUrl=/add-product");
18 | }
19 |
20 | const name = formData.get("name")?.toString();
21 | const description = formData.get("description")?.toString();
22 | const imageUrl = formData.get("imageUrl")?.toString();
23 | const price = Number(formData.get("price") || 0);
24 |
25 | if (!name || !description || !imageUrl || !price) {
26 | throw Error("Missing required fields");
27 | }
28 |
29 | await prisma.product.create({
30 | data: { name, description, imageUrl, price },
31 | });
32 |
33 | redirect("/");
34 | }
35 |
36 | export default async function AddProductPage() {
37 | const session = await getServerSession(authOptions);
38 |
39 | if (!session) {
40 | redirect("/api/auth/signin?callbackUrl=/add-product");
41 | }
42 |
43 | return (
44 |
45 |
Add Product
46 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "mongodb"
7 | url = env("DATABASE_URL")
8 | }
9 |
10 | model Product {
11 | id String @id @default(auto()) @map("_id") @db.ObjectId
12 | description String
13 | imageUrl String
14 | name String
15 | price Int
16 | createdAt DateTime @default(now())
17 | updatedAt DateTime @updatedAt
18 | CartItem CartItem[]
19 |
20 | @@map("products")
21 | }
22 |
23 | model Cart {
24 | id String @id @default(auto()) @map("_id") @db.ObjectId
25 | items CartItem[]
26 | userId String? @db.ObjectId
27 | user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
28 | createdAt DateTime @default(now())
29 | updatedAt DateTime @updatedAt
30 |
31 | @@map("carts")
32 | }
33 |
34 | model CartItem {
35 | id String @id @default(auto()) @map("_id") @db.ObjectId
36 | productId String @db.ObjectId
37 | product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
38 | quantity Int
39 | cartId String @db.ObjectId
40 | cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
41 |
42 | @@map("cartitems")
43 | }
44 |
45 | model Account {
46 | id String @id @default(auto()) @map("_id") @db.ObjectId
47 | userId String @db.ObjectId
48 | type String
49 | provider String
50 | providerAccountId String
51 | refresh_token String? @db.String
52 | access_token String? @db.String
53 | expires_at Int?
54 | token_type String?
55 | scope String?
56 | id_token String? @db.String
57 | session_state String?
58 |
59 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
60 |
61 | @@unique([provider, providerAccountId])
62 | @@map("accounts")
63 | }
64 |
65 | model Session {
66 | id String @id @default(auto()) @map("_id") @db.ObjectId
67 | sessionToken String @unique
68 | userId String @db.ObjectId
69 | expires DateTime
70 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
71 |
72 | @@map("sessions")
73 | }
74 |
75 | model User {
76 | id String @id @default(auto()) @map("_id") @db.ObjectId
77 | name String?
78 | email String? @unique
79 | emailVerified DateTime?
80 | image String?
81 | accounts Account[]
82 | sessions Session[]
83 | Cart Cart[]
84 |
85 | @@map("users")
86 | }
87 |
88 | model VerificationToken {
89 | id String @id @default(auto()) @map("_id") @db.ObjectId
90 | identifier String
91 | token String @unique
92 | expires DateTime
93 |
94 | @@unique([identifier, token])
95 | @@map("verificationtokens")
96 | }
97 |
--------------------------------------------------------------------------------
/src/lib/db/cart.ts:
--------------------------------------------------------------------------------
1 | import { authOptions } from "@/app/api/auth/[...nextauth]/route";
2 | import { Cart, CartItem, Prisma } from "@prisma/client";
3 | import { getServerSession } from "next-auth";
4 | import { cookies } from "next/dist/client/components/headers";
5 | import { prisma } from "./prisma";
6 |
7 | export type CartWithProducts = Prisma.CartGetPayload<{
8 | include: { items: { include: { product: true } } };
9 | }>;
10 |
11 | export type CartItemWithProduct = Prisma.CartItemGetPayload<{
12 | include: { product: true };
13 | }>;
14 |
15 | export type ShoppingCart = CartWithProducts & {
16 | size: number;
17 | subtotal: number;
18 | };
19 |
20 | export async function getCart(): Promise {
21 | const session = await getServerSession(authOptions);
22 |
23 | let cart: CartWithProducts | null = null;
24 |
25 | if (session) {
26 | cart = await prisma.cart.findFirst({
27 | where: { userId: session.user.id },
28 | include: { items: { include: { product: true } } },
29 | });
30 | } else {
31 | const localCartId = cookies().get("localCartId")?.value;
32 | cart = localCartId
33 | ? await prisma.cart.findUnique({
34 | where: { id: localCartId },
35 | include: { items: { include: { product: true } } },
36 | })
37 | : null;
38 | }
39 |
40 | if (!cart) {
41 | return null;
42 | }
43 |
44 | return {
45 | ...cart,
46 | size: cart.items.reduce((acc, item) => acc + item.quantity, 0),
47 | subtotal: cart.items.reduce(
48 | (acc, item) => acc + item.quantity * item.product.price,
49 | 0
50 | ),
51 | };
52 | }
53 |
54 | export async function createCart(): Promise {
55 | const session = await getServerSession(authOptions);
56 |
57 | let newCart: Cart;
58 |
59 | if (session) {
60 | newCart = await prisma.cart.create({
61 | data: { userId: session.user.id },
62 | });
63 | } else {
64 | newCart = await prisma.cart.create({
65 | data: {},
66 | });
67 |
68 | // Note: Needs encryption + secure settings in real production app
69 | cookies().set("localCartId", newCart.id);
70 | }
71 |
72 | return {
73 | ...newCart,
74 | items: [],
75 | size: 0,
76 | subtotal: 0,
77 | };
78 | }
79 |
80 | export async function mergeAnonymousCartIntoUserCart(userId: string) {
81 | const localCartId = cookies().get("localCartId")?.value;
82 |
83 | const localCart = localCartId
84 | ? await prisma.cart.findUnique({
85 | where: { id: localCartId },
86 | include: { items: true },
87 | })
88 | : null;
89 |
90 | if (!localCart) return;
91 |
92 | const userCart = await prisma.cart.findFirst({
93 | where: { userId },
94 | include: { items: true },
95 | });
96 |
97 | await prisma.$transaction(async (tx) => {
98 | if (userCart) {
99 | const mergedCartItems = mergeCartItems(localCart.items, userCart.items);
100 |
101 | await tx.cartItem.deleteMany({
102 | where: { cartId: userCart.id },
103 | });
104 |
105 | await tx.cart.update({
106 | where: { id: userCart.id },
107 | data: {
108 | items: {
109 | createMany: {
110 | data: mergedCartItems.map((item) => ({
111 | productId: item.productId,
112 | quantity: item.quantity,
113 | })),
114 | },
115 | },
116 | },
117 | });
118 | } else {
119 | await tx.cart.create({
120 | data: {
121 | userId,
122 | items: {
123 | createMany: {
124 | data: localCart.items.map((item) => ({
125 | productId: item.productId,
126 | quantity: item.quantity,
127 | })),
128 | },
129 | },
130 | },
131 | });
132 | }
133 |
134 | await tx.cart.delete({
135 | where: { id: localCart.id },
136 | });
137 | // throw Error("Transaction failed");
138 | cookies().set("localCartId", "");
139 | });
140 | }
141 |
142 | function mergeCartItems(...cartItems: CartItem[][]): CartItem[] {
143 | return cartItems.reduce((acc, items) => {
144 | items.forEach((item) => {
145 | const existingItem = acc.find((i) => i.productId === item.productId);
146 | if (existingItem) {
147 | existingItem.quantity += item.quantity;
148 | } else {
149 | acc.push(item);
150 | }
151 | });
152 | return acc;
153 | }, [] as CartItem[]);
154 | }
155 |
--------------------------------------------------------------------------------