├── .env.local.example ├── .eslintrc ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── README.md ├── components ├── auth │ ├── SignInButton.tsx │ └── hooks │ │ └── useAuth.ts ├── cart │ ├── CartItem.tsx │ ├── CartItems.tsx │ ├── Checkout.tsx │ ├── api │ │ └── checkoutCart.ts │ ├── context │ │ ├── cartContext.tsx │ │ ├── reducers │ │ │ └── cartReducer.ts │ │ └── types.ts │ └── hooks │ │ ├── useCart.ts │ │ └── useCheckout.ts ├── layout │ ├── Layout.tsx │ ├── header │ │ └── Header.tsx │ └── logo │ │ └── Logo.tsx └── products │ ├── Product.tsx │ ├── Products.tsx │ ├── api │ ├── buyProduct.ts │ └── getProducts.ts │ ├── hooks │ ├── useBuyProduct.ts │ └── useGetProducts.ts │ └── utils │ ├── schemas.ts │ └── transforms.ts ├── docker-compose.yml ├── next-env.d.ts ├── package-lock.json ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── api │ ├── auth │ │ └── [...nextauth].tsx │ ├── checkout │ │ └── products │ │ │ └── index.ts │ └── products │ │ └── index.ts ├── auth │ └── signin.tsx └── index.tsx ├── postcss.config.js ├── prisma ├── migrations │ ├── 20211126172202_init │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── favicon.ico └── vercel.svg ├── sentry.client.config.js ├── sentry.server.config.js ├── tailwind.config.js ├── tsconfig.json ├── typings ├── next-auth.d.ts └── next.d.ts └── utils ├── env.ts ├── fetcher.ts ├── responseError.ts ├── stripe.ts └── types.ts /.env.local.example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER= 2 | POSTGRES_PASSWORD= 3 | POSTGRES_DB= 4 | DATABASE_URL="postgresql://:@:/?schema=public&sslmode=prefer" 5 | GITHUB_SECRET= 6 | GITHUB_ID= 7 | SECRET= 8 | NEXTAUTH_URL= 9 | NEXTAUTH_CALLBACK_URL= 10 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 11 | STRIPE_SECRET_KEY= 12 | NEXT_PUBLIC_STRIPE_SUCCESS_REDIRECT_URL= 13 | NEXT_PUBLIC_STRIPE_ERROR_REDIRECT_URL 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "prettier", 6 | "plugin:react/recommended", 7 | "plugin:react-hooks/recommended", 8 | "plugin:jsx-a11y/strict", 9 | "plugin:testing-library/recommended", 10 | "plugin:jest-dom/recommended", 11 | "next", 12 | "next/core-web-vitals" 13 | ], 14 | "plugins": [ 15 | "jsx-a11y", 16 | "react-app", 17 | "react-hooks", 18 | "jest-dom", 19 | "testing-library", 20 | "@typescript-eslint", 21 | "prettier" 22 | ], 23 | 24 | "env": { 25 | "es6": true, 26 | "browser": true, 27 | "jest": true, 28 | "node": true 29 | }, 30 | "rules": { 31 | "react-hooks/rules-of-hooks": "error", 32 | "react-hooks/exhaustive-deps": "warn", 33 | "jsx-a11y/anchor-is-valid": 0, 34 | "@typescript-eslint/no-unused-vars": 0, 35 | "react/react-in-jsx-scope": 0, 36 | "react/display-name": 0, 37 | "react/prop-types": 0, 38 | "@typescript-eslint/explicit-member-accessibility": 0, 39 | "@typescript-eslint/indent": 0, 40 | "@typescript-eslint/member-delimiter-style": 0, 41 | "@typescript-eslint/no-var-requires": 0, 42 | "@typescript-eslint/no-use-before-define": 0, 43 | "@typescript-eslint/explicit-function-return-type": 0, 44 | "@typescript-eslint/no-explicit-any": 0, 45 | "@typescript-eslint/no-non-null-assertion": 0, 46 | "no-undef": 0, 47 | "no-unused-vars": 0, 48 | "jsx-a11y/label-has-for": 0, 49 | "jsx-a11y/no-noninteractive-tabindex": 0, 50 | "prettier/prettier": 0, 51 | "react/no-unescaped-entities": 0 52 | }, 53 | "parserOptions": { 54 | "ecmaVersion": 12, 55 | "sourceType": "module" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.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 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | .env 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | 2 | 3 | npx lint-staged 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | 5 | 6 | 7 |
8 | 9 |
10 | 11 | Fullstack Next.js E-commerce 12 | 13 |

14 | 15 | ## Technologies 🔧 16 | 17 | - Next.js(React) 18 | - TypeScript 19 | - Prisma 20 | - NextAuth 21 | - Stripe 22 | - Tailwind 23 | - React Query 24 | - Sentry 25 | - Yup 26 | 27 | ## Screenshots 📸 28 | 29 |
30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 |
38 | 39 | ## Code Example/Issues 🔍 40 | 41 | If you have any issues, please let me know in the issues section or directly to olafsulich@gmail.com 42 | 43 | ## Installation 💾 44 | 45 | ```bash 46 | git clone https://github.com/olafsulich/fullstack-nextjs-ecommerce.git 47 | ``` 48 | 49 | Fill your `.env` variables: 50 | 51 | ``` 52 | POSTGRES_USER= 53 | POSTGRES_PASSWORD= 54 | POSTGRES_DB= 55 | DATABASE_URL="postgresql://:@:/?schema=public&sslmode=prefer" 56 | GITHUB_SECRET= 57 | GITHUB_ID= 58 | SECRET= 59 | NEXTAUTH_URL= 60 | NEXTAUTH_CALLBACK_URL= 61 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 62 | STRIPE_SECRET_KEY= 63 | NEXT_PUBLIC_STRIPE_SUCCESS_REDIRECT_URL= 64 | NEXT_PUBLIC_STRIPE_ERROR_REDIRECT_URL 65 | ``` 66 | 67 | Install deps: 68 | 69 | ```bash 70 | npm install 71 | ``` 72 | 73 | Generate Prisma Client: 74 | 75 | ```bash 76 | npx prisma generate 77 | ``` 78 | 79 | Run docker-compose: 80 | 81 | ```bash 82 | docker-compose up -d 83 | ``` 84 | 85 | Run Next dev server: 86 | 87 | ```bash 88 | npm run dev 89 | ``` 90 | 91 | ## Contributing 92 | 93 | This is an open source project, and contributions of any kind are welcome and appreciated. Open issues, bugs, and feature requests are all listed on the [issues](https://github.com/olafsulich/fullstack-nextjs-ecommerce/issues) tab and labeled accordingly. Feel free to open bug tickets and make feature requests. 94 | -------------------------------------------------------------------------------- /components/auth/SignInButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from './hooks/useAuth'; 2 | 3 | export const SignInButton = () => { 4 | const { signIn } = useAuth(); 5 | const handleSignIn = () => signIn('github'); 6 | 7 | return ( 8 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /components/auth/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useSession, getProviders, signIn, signOut } from "next-auth/react"; 3 | 4 | export const useAuth = () => { 5 | const { data: session, status } = useSession(); 6 | 7 | return useMemo( 8 | () => 9 | ({ 10 | session, 11 | status, 12 | signIn, 13 | signOut, 14 | } as const), 15 | [session, status] 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /components/cart/CartItem.tsx: -------------------------------------------------------------------------------- 1 | import type Prisma from "@prisma/client"; 2 | import Image from "next/image"; 3 | import { useCart } from "./hooks/useCart"; 4 | 5 | type CartItemProps = Prisma.Product; 6 | 7 | export const CartItem = (product: CartItemProps) => { 8 | const { id, name, price, image } = product; 9 | const { dispatch } = useCart(); 10 | 11 | const handleDelete = (product: Prisma.Product) => { 12 | dispatch({ type: "deleteProduct", payload: product }); 13 | }; 14 | 15 | return ( 16 |
  • 17 |
    18 | 23 |
    24 |
    25 |
    26 |

    {name}

    27 |

    {price / 100}

    28 |
    29 |
    30 |
    31 | 38 |
    39 |
    40 |
    41 |
  • 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /components/cart/CartItems.tsx: -------------------------------------------------------------------------------- 1 | import type Prisma from "@prisma/client"; 2 | import { CartItem } from "./CartItem"; 3 | 4 | type CartItemsProps = { 5 | readonly products: Array; 6 | }; 7 | 8 | export const CartItems = ({ products }: CartItemsProps) => ( 9 |
      10 | {products.map((product) => ( 11 | 12 | ))} 13 |
    14 | ); 15 | -------------------------------------------------------------------------------- /components/cart/Checkout.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, Fragment, SetStateAction } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | import { XIcon } from "@heroicons/react/outline"; 4 | import { CartItems } from "./CartItems"; 5 | import { useCart } from "./hooks/useCart"; 6 | import { useCheckout } from "./hooks/useCheckout"; 7 | 8 | export const Checkout = () => { 9 | const { 10 | state: { totalPrice, products, isOpen }, 11 | dispatch, 12 | } = useCart(); 13 | const { mutate } = useCheckout(); 14 | 15 | const handleOpenMenu = () => dispatch({ type: "openMenu" }); 16 | const handleCloseMenu = () => dispatch({ type: "closeMenu" }); 17 | 18 | const handleCheckout = () => mutate(products); 19 | 20 | return ( 21 | 22 | 27 |
    28 | 37 | 38 | 39 | 40 |
    41 | 50 |
    51 |
    52 |
    53 |
    54 | 55 | Koszyk 56 | 57 |
    58 | 66 |
    67 |
    68 |
    69 | 70 |
    71 |
    72 |
    73 |
    74 |

    Razem:

    75 |

    {totalPrice / 100} zł

    76 |
    77 | 78 |
    79 | {products.length > 0 ? ( 80 | 86 | ) : null} 87 |
    88 |
    89 |
    90 |
    91 |
    92 |
    93 |
    94 |
    95 |
    96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /components/cart/api/checkoutCart.ts: -------------------------------------------------------------------------------- 1 | import type Prisma from "@prisma/client"; 2 | import { fetcher } from "../../../utils/fetcher"; 3 | import { stripeSessionSchema } from "../../../utils/stripe"; 4 | import { transformProduct } from "../../products/utils/transforms"; 5 | 6 | export const checkoutCart = async (products: Array) => { 7 | const stripeItems = products.map((product) => transformProduct(product)); 8 | 9 | return await fetcher(`/api/checkout/products/`, { 10 | method: "POST", 11 | body: stripeItems, 12 | schema: stripeSessionSchema, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /components/cart/context/cartContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ReactNode, 3 | createContext, 4 | useReducer, 5 | useContext, 6 | useMemo, 7 | useState, 8 | } from "react"; 9 | import { cartReducer } from "./reducers/cartReducer"; 10 | import type { Action, State } from "./types"; 11 | 12 | type Dispatch = (action: Action) => void; 13 | type CartProviderProps = { readonly children: React.ReactNode }; 14 | 15 | export const CartStateContext = createContext< 16 | { state: State; dispatch: Dispatch } | undefined 17 | >(undefined); 18 | 19 | const initialState: State = { products: [], totalPrice: 0, isOpen: false }; 20 | 21 | export const CartProvider = ({ children }: CartProviderProps) => { 22 | const [state, dispatch] = useReducer(cartReducer, initialState); 23 | 24 | const value = useMemo(() => ({ state, dispatch }), [state]); 25 | 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /components/cart/context/reducers/cartReducer.ts: -------------------------------------------------------------------------------- 1 | import type Prisma from "@prisma/client"; 2 | import type { Action, State } from "../types"; 3 | 4 | const calculateTotalPrice = (products: Array) => { 5 | return products.reduce((acc, curr) => acc + curr.price, 0); 6 | }; 7 | 8 | export const cartReducer = (state: State, action: Action) => { 9 | switch (action.type) { 10 | case "addProduct": { 11 | const products = [...state.products]; 12 | const newProduct = action.payload; 13 | const isTheNewProductInCart = products.find( 14 | (product) => product.id === newProduct.id 15 | ); 16 | 17 | const newProducts = [newProduct, ...products]; 18 | 19 | const totalPrice = calculateTotalPrice(newProducts); 20 | 21 | if (!isTheNewProductInCart) { 22 | return { 23 | ...state, 24 | products: newProducts, 25 | totalPrice, 26 | }; 27 | } 28 | } 29 | case "deleteProduct": { 30 | const products = [...state.products]; 31 | const productToDelete = action.payload; 32 | 33 | const newProducts = products.filter( 34 | (product) => product.id !== productToDelete.id 35 | ); 36 | 37 | const totalPrice = calculateTotalPrice(newProducts); 38 | 39 | return { 40 | ...state, 41 | products: [...newProducts], 42 | totalPrice, 43 | }; 44 | } 45 | 46 | case "openMenu": { 47 | return { 48 | ...state, 49 | isOpen: true, 50 | }; 51 | } 52 | case "closeMenu": { 53 | return { 54 | ...state, 55 | isOpen: false, 56 | }; 57 | } 58 | 59 | default: { 60 | throw new Error(`Unhandled action type`); 61 | } 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /components/cart/context/types.ts: -------------------------------------------------------------------------------- 1 | import type Prisma from "@prisma/client"; 2 | 3 | export type Action = 4 | | { type: "addProduct"; payload: Prisma.Product } 5 | | { type: "deleteProduct"; payload: Prisma.Product } 6 | | { type: "openMenu" } 7 | | { type: "closeMenu" }; 8 | 9 | export type State = { 10 | readonly products: Array; 11 | readonly totalPrice: number; 12 | readonly isOpen: boolean; 13 | }; 14 | -------------------------------------------------------------------------------- /components/cart/hooks/useCart.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useContext } from "react"; 2 | 3 | import { CartStateContext } from "../context/cartContext"; 4 | 5 | export const useCart = () => { 6 | const context = useContext(CartStateContext); 7 | if (context === undefined) { 8 | throw new Error("useCount must be used within a CountProvider"); 9 | } 10 | return useMemo(() => context, [context]); 11 | }; 12 | -------------------------------------------------------------------------------- /components/cart/hooks/useCheckout.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-query'; 2 | import type Prisma from '@prisma/client'; 3 | import { checkoutCart } from '../api/checkoutCart'; 4 | import { redirectToCheckout } from '../../../utils/stripe'; 5 | 6 | export const useCheckout = () => { 7 | return useMutation((products: Array) => checkoutCart(products), { 8 | onSuccess: redirectToCheckout, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { Header } from "./header/Header"; 3 | 4 | type LayoutProps = { 5 | readonly children: ReactNode; 6 | }; 7 | 8 | export const Layout = ({ children }: LayoutProps) => ( 9 | <> 10 |
    11 |
    12 |

    13 | FullStack Next.js E-commerce 14 |

    15 | {children} 16 |
    17 | 18 | ); 19 | -------------------------------------------------------------------------------- /components/layout/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from '../../auth/hooks/useAuth'; 2 | import { Logo } from '../logo/Logo'; 3 | 4 | export const Header = () => { 5 | const { session, signIn, signOut } = useAuth(); 6 | 7 | return ( 8 |
    9 |
    10 | 26 |
    27 |
    28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /components/layout/logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | export const Logo = () => ( 2 |
    3 | 4 | 11 | 12 |
    13 | ); 14 | -------------------------------------------------------------------------------- /components/products/Product.tsx: -------------------------------------------------------------------------------- 1 | import type Prisma from '@prisma/client'; 2 | import { useBuyProduct } from './hooks/useBuyProduct'; 3 | import { useCart } from '../cart/hooks/useCart'; 4 | 5 | type ProductProps = Readonly; 6 | 7 | export const Product = (product: ProductProps) => { 8 | const { id, image, name, price } = product; 9 | const { mutate } = useBuyProduct(); 10 | const { dispatch } = useCart(); 11 | 12 | const buyProduct = () => mutate(product); 13 | 14 | const addToCart = () => { 15 | dispatch({ type: 'addProduct', payload: product }); 16 | dispatch({ type: 'openMenu' }); 17 | }; 18 | 19 | return ( 20 |
    21 |
    22 | 27 |
    28 |
    29 |

    30 |

    33 |

    {price / 100} zł

    34 |
    35 | 41 | 47 |
    48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /components/products/Products.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from './Product'; 2 | import { useGetProducts } from './hooks/useGetProducts'; 3 | 4 | export const Products = () => { 5 | const { data: products } = useGetProducts(); 6 | 7 | return ( 8 |
    9 | {products && products.map((product) => )} 10 |
    11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /components/products/api/buyProduct.ts: -------------------------------------------------------------------------------- 1 | import type Prisma from '@prisma/client'; 2 | import { fetcher } from '../../../utils/fetcher'; 3 | import { stripeSessionSchema } from '../../../utils/stripe'; 4 | import { transformProduct } from '../utils/transforms'; 5 | 6 | export const buyProduct = async (product: Prisma.Product) => { 7 | const stripeItem = transformProduct(product); 8 | 9 | return await fetcher(`/api/checkout/products/`, { 10 | method: 'POST', 11 | body: [stripeItem], 12 | schema: stripeSessionSchema, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /components/products/api/getProducts.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../../../utils/fetcher'; 2 | import { productsSchema } from '../utils/schemas'; 3 | 4 | export const getProducts = async () => { 5 | return await fetcher('/api/products', { 6 | method: 'GET', 7 | schema: productsSchema, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /components/products/hooks/useBuyProduct.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-query'; 2 | import type Prisma from '@prisma/client'; 3 | import { buyProduct } from '../api/buyProduct'; 4 | import { redirectToCheckout } from '../../../utils/stripe'; 5 | 6 | export const useBuyProduct = () => { 7 | return useMutation((product: Prisma.Product) => buyProduct(product), { 8 | onSuccess: redirectToCheckout, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /components/products/hooks/useGetProducts.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { getProducts } from '../api/getProducts'; 3 | 4 | export const useGetProducts = () => { 5 | return useQuery('products', getProducts); 6 | }; 7 | -------------------------------------------------------------------------------- /components/products/utils/schemas.ts: -------------------------------------------------------------------------------- 1 | import * as y from 'yup'; 2 | import type Prisma from '@prisma/client'; 3 | 4 | export const productSchema: y.SchemaOf = y.object().shape({ 5 | id: y.string().required(), 6 | description: y.string().required(), 7 | name: y.string().required(), 8 | price: y.number().required(), 9 | image: y.string().required(), 10 | }); 11 | 12 | export const productsSchema = y.array(productSchema); 13 | -------------------------------------------------------------------------------- /components/products/utils/transforms.ts: -------------------------------------------------------------------------------- 1 | import type Prisma from "@prisma/client"; 2 | import type Stripe from "stripe"; 3 | 4 | export const transformProduct = ({ 5 | name, 6 | description, 7 | price, 8 | image 9 | }: Prisma.Product): Stripe.Checkout.SessionCreateParams.LineItem => ({ 10 | name, 11 | description, 12 | amount: price, 13 | currency: 'PLN', 14 | images: [image], 15 | quantity: 1 16 | }); 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | postgres: 4 | image: postgres:14 5 | restart: always 6 | env_file: 7 | - .env 8 | volumes: 9 | - postgres:/var/lib/postgresql/data 10 | ports: 11 | - "5432:5432" 12 | volumes: 13 | postgres: 14 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-next", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "docker": "docker-compose up -d", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "prepare": "husky install", 11 | "eslint": "eslint --ext .js,.jsx,.ts,.tsx --fix .", 12 | "tsc": "tsc --noEmit", 13 | "studio": "prisma studio", 14 | "pre-commit": "lint-staged" 15 | }, 16 | "dependencies": { 17 | "@next-auth/prisma-adapter": "^0.5.2-next.19", 18 | "@prisma/client": "^3.5.0", 19 | "@sentry/nextjs": "^6.15.0", 20 | "@stripe/react-stripe-js": "^1.6.0", 21 | "@stripe/stripe-js": "^1.21.1", 22 | "@tailwindcss/forms": "^0.3.4", 23 | "next": "^12.0.4", 24 | "react": "17.0.2", 25 | "react-dom": "17.0.2", 26 | "react-query": "^3.33.1", 27 | "stripe": "^8.190.0", 28 | "yup": "^0.32.11" 29 | }, 30 | "devDependencies": { 31 | "@headlessui/react": "^1.4.2", 32 | "@heroicons/react": "^1.0.5", 33 | "@tailwindcss/aspect-ratio": "^0.3.0", 34 | "@types/micro": "^7.3.6", 35 | "@types/micro-cors": "^0.1.2", 36 | "@types/node": "16.11.7", 37 | "@types/react": "17.0.35", 38 | "@types/stripe": "^8.0.417", 39 | "autoprefixer": "^10.4.0", 40 | "eslint": "^7.32.0", 41 | "eslint-config-next": "^12.0.4", 42 | "eslint-config-prettier": "^8.3.0", 43 | "eslint-plugin-jsx-a11y": "^6.5.1", 44 | "eslint-plugin-prettier": "^4.0.0", 45 | "eslint-plugin-react": "^7.27.1", 46 | "eslint-plugin-react-hooks": "^4.3.0", 47 | "husky": "^7.0.4", 48 | "lint-staged": "^12.1.2", 49 | "postcss": "^8.3.11", 50 | "prettier": "^2.5.0", 51 | "tailwindcss": "^2.2.19", 52 | "typescript": "4.5.2" 53 | }, 54 | "lint-staged": { 55 | "*.{js,jsx,ts,tsx}": [ 56 | "eslint --fix", 57 | "npx prettier --write" 58 | ], 59 | "*.{json,md,yaml,yml,scss,css}": [ 60 | "npx prettier --write" 61 | ], 62 | "*.js": "eslint --cache --fix" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Error from "next/error"; 2 | 3 | function NotFound() { 4 | return ; 5 | } 6 | 7 | export default NotFound; 8 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import type { AppProps } from 'next/app'; 3 | import { Hydrate, QueryClient, QueryClientProvider } from 'react-query'; 4 | import { ReactQueryDevtools } from 'react-query/devtools'; 5 | import { SessionProvider } from 'next-auth/react'; 6 | import 'tailwindcss/tailwind.css'; 7 | import { CartProvider } from '../components/cart/context/cartContext'; 8 | 9 | export default function App({ Component, pageProps, err }: AppProps & { err: Error }) { 10 | const [queryClient] = useState(() => new QueryClient()); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Main, Html, NextScript, DocumentContext } from 'next/document'; 2 | 3 | export default class MyDocument extends Document { 4 | static async getInitialProps(ctx: DocumentContext) { 5 | const initialProps = await Document.getInitialProps(ctx); 6 | 7 | return { ...initialProps }; 8 | } 9 | render() { 10 | return ( 11 | 18 | 19 | 20 |
    21 | 22 | 23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { NextPageContext, NextPage } from "next"; 3 | import NextErrorComponent, { ErrorProps as NextErrorProps } from "next/error"; 4 | import * as Sentry from "@sentry/nextjs"; 5 | 6 | type ErrorPageProps = { 7 | err: Error; 8 | statusCode: number; 9 | hasGetInitialPropsRun: boolean; 10 | children?: ReactElement; 11 | }; 12 | 13 | type ErrorProps = { 14 | hasGetInitialPropsRun: boolean; 15 | } & NextErrorProps; 16 | 17 | function ErrorPage({ statusCode, hasGetInitialPropsRun, err }: ErrorPageProps) { 18 | if (!hasGetInitialPropsRun && err) { 19 | Sentry.captureException(err); 20 | } 21 | 22 | return ; 23 | } 24 | 25 | ErrorPage.getInitialProps = async ({ res, err, asPath }: NextPageContext) => { 26 | const errorInitialProps = (await NextErrorComponent.getInitialProps({ 27 | res, 28 | err, 29 | } as NextPageContext)) as ErrorProps; 30 | 31 | errorInitialProps.hasGetInitialPropsRun = true; 32 | 33 | if (err) { 34 | Sentry.captureException(err); 35 | 36 | await Sentry.flush(2000); 37 | 38 | return errorInitialProps; 39 | } 40 | 41 | Sentry.captureException( 42 | new Error(`_error.js getInitialProps missing data at path: ${asPath}`) 43 | ); 44 | 45 | await Sentry.flush(2000); 46 | 47 | return errorInitialProps; 48 | }; 49 | 50 | export default ErrorPage; 51 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].tsx: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import NextAuth from 'next-auth'; 3 | import GitHubProvider from 'next-auth/providers/github'; 4 | import { PrismaAdapter } from '@next-auth/prisma-adapter'; 5 | import { getEnv } from '../../../utils/env'; 6 | 7 | const prisma = new PrismaClient(); 8 | 9 | export default NextAuth({ 10 | providers: [ 11 | GitHubProvider({ 12 | clientId: getEnv('GITHUB_ID'), 13 | clientSecret: getEnv('GITHUB_SECRET'), 14 | }), 15 | ], 16 | pages: { 17 | signIn: '/auth/signin', 18 | }, 19 | adapter: PrismaAdapter(prisma), 20 | secret: getEnv('SECRET'), 21 | }); 22 | -------------------------------------------------------------------------------- /pages/api/checkout/products/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Stripe } from 'stripe'; 3 | import { getEnv } from '../../../../utils/env'; 4 | 5 | const stripe = new Stripe(getEnv('STRIPE_SECRET_KEY'), { 6 | apiVersion: '2020-08-27', 7 | }); 8 | 9 | export default async (req: NextApiRequest, res: NextApiResponse) => { 10 | try { 11 | const { id } = await stripe.checkout.sessions.create({ 12 | mode: 'payment', 13 | submit_type: 'donate', 14 | payment_method_types: ['card'], 15 | success_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`, 16 | cancel_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`, 17 | line_items: req.body, 18 | }); 19 | 20 | res.status(200).json({ id }); 21 | res.end(); 22 | } catch { 23 | res.status(500); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /pages/api/products/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | export default async (req: NextApiRequest, res: NextApiResponse) => { 5 | try { 6 | const prisma = new PrismaClient(); 7 | 8 | const products = await prisma.product.findMany(); 9 | 10 | if (products.length) { 11 | res.status(200).json(products); 12 | res.end(); 13 | } else { 14 | res.status(404); 15 | res.end(); 16 | } 17 | } catch { 18 | res.status(500); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /pages/auth/signin.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from "../../components/layout/Layout"; 2 | import { SignInButton } from "../../components/auth/SignInButton"; 3 | 4 | export default function SignIn() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { dehydrate, QueryClient } from 'react-query'; 2 | import type { GetServerSideProps } from 'next'; 3 | import { Products } from '../components/products/Products'; 4 | import { Checkout } from '../components/cart/Checkout'; 5 | import { getSession } from 'next-auth/react'; 6 | import { getEnv } from '../utils/env'; 7 | import { getProducts } from '../components/products/api/getProducts'; 8 | import { Layout } from '../components/layout/Layout'; 9 | 10 | export default function Home() { 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export const getServerSideProps: GetServerSideProps = async (context) => { 20 | const queryClient = new QueryClient(); 21 | const session = await getSession(context); 22 | 23 | await queryClient.prefetchQuery('products', getProducts); 24 | 25 | if (!session) { 26 | return { 27 | redirect: { 28 | destination: getEnv('NEXTAUTH_CALLBACK_URL'), 29 | permanent: false, 30 | }, 31 | props: { 32 | dehydratedState: dehydrate(queryClient), 33 | }, 34 | }; 35 | } 36 | 37 | return { 38 | props: { 39 | session, 40 | dehydratedState: dehydrate(queryClient), 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20211126172202_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "accounts" ( 6 | "id" TEXT NOT NULL, 7 | "user_id" TEXT NOT NULL, 8 | "type" TEXT NOT NULL, 9 | "provider" TEXT NOT NULL, 10 | "provider_account_id" TEXT NOT NULL, 11 | "refresh_token" TEXT, 12 | "access_token" TEXT, 13 | "expires_at" INTEGER, 14 | "token_type" TEXT, 15 | "scope" TEXT, 16 | "id_token" TEXT, 17 | "session_state" TEXT, 18 | 19 | CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "users" ( 24 | "id" TEXT NOT NULL, 25 | "role" "Role" NOT NULL DEFAULT E'USER', 26 | "name" TEXT, 27 | "email" TEXT, 28 | "email_verified" TIMESTAMP(3), 29 | "image" TEXT, 30 | 31 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "Product" ( 36 | "id" TEXT NOT NULL, 37 | "description" TEXT NOT NULL, 38 | "name" TEXT NOT NULL, 39 | "price" INTEGER NOT NULL, 40 | "image" TEXT NOT NULL, 41 | 42 | CONSTRAINT "Product_pkey" PRIMARY KEY ("id") 43 | ); 44 | 45 | -- CreateTable 46 | CREATE TABLE "sessions" ( 47 | "id" TEXT NOT NULL, 48 | "session_token" TEXT NOT NULL, 49 | "user_id" TEXT NOT NULL, 50 | "expires" TIMESTAMP(3) NOT NULL, 51 | 52 | CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") 53 | ); 54 | 55 | -- CreateTable 56 | CREATE TABLE "verificationtokens" ( 57 | "identifier" TEXT NOT NULL, 58 | "token" TEXT NOT NULL, 59 | "expires" TIMESTAMP(3) NOT NULL 60 | ); 61 | 62 | -- CreateIndex 63 | CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); 64 | 65 | -- CreateIndex 66 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 67 | 68 | -- CreateIndex 69 | CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); 70 | 71 | -- CreateIndex 72 | CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token"); 73 | 74 | -- CreateIndex 75 | CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token"); 76 | 77 | -- AddForeignKey 78 | ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 79 | 80 | -- AddForeignKey 81 | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 82 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Account { 14 | id String @id @default(cuid()) 15 | userId String @map("user_id") 16 | type String 17 | provider String 18 | providerAccountId String @map("provider_account_id") 19 | refresh_token String? 20 | access_token String? 21 | expires_at Int? 22 | token_type String? 23 | scope String? 24 | id_token String? 25 | session_state String? 26 | 27 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 28 | 29 | @@unique([provider, providerAccountId]) 30 | @@map("accounts") 31 | } 32 | 33 | enum Role { 34 | USER 35 | ADMIN 36 | } 37 | 38 | model User { 39 | id String @id @default(cuid()) 40 | role Role @default(USER) 41 | name String? 42 | email String? @unique 43 | emailVerified DateTime? @map("email_verified") 44 | image String? 45 | accounts Account[] 46 | sessions Session[] 47 | 48 | @@map("users") 49 | } 50 | 51 | model Product { 52 | id String @id 53 | description String 54 | name String 55 | price Int 56 | image String 57 | } 58 | 59 | model Session { 60 | id String @id @default(cuid()) 61 | sessionToken String @unique @map("session_token") 62 | userId String @map("user_id") 63 | expires DateTime 64 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 65 | 66 | @@map("sessions") 67 | } 68 | 69 | model VerificationToken { 70 | identifier String 71 | token String @unique 72 | expires DateTime 73 | 74 | @@unique([identifier, token]) 75 | @@map("verificationtokens") 76 | } 77 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olafsulich/fullstack-nextjs-ecommerce/f173842c0bed43e2af8000082b9813aaf8a8cee8/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /sentry.client.config.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | import { getConfig } from "./utils/config"; 3 | 4 | Sentry.init({ 5 | dsn: getConfig("SENTRY_DSN"), 6 | }); 7 | -------------------------------------------------------------------------------- /sentry.server.config.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | import { getConfig } from "./utils/config"; 3 | 4 | Sentry.init({ 5 | dsn: getConfig("SENTRY_DNS"), 6 | }); 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: { cursor: ['hover', 'focus'] }, 9 | }, 10 | plugins: [require('@tailwindcss/aspect-ratio'), require('@tailwindcss/forms')], 11 | }; 12 | -------------------------------------------------------------------------------- /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 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /typings/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'next-auth'; 2 | import type Prisma from '@prisma/client'; 3 | 4 | declare module 'next-auth' { 5 | interface Session { 6 | user: Prisma.User; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings/next.d.ts: -------------------------------------------------------------------------------- 1 | import type { NextComponentType, NextPageContext } from 'next'; 2 | import type { Session } from 'next-auth'; 3 | import type { Router } from 'next/router'; 4 | 5 | declare module 'next/app' { 6 | type AppProps

    > = { 7 | Component: NextComponentType; 8 | router: Router; 9 | __N_SSG?: boolean; 10 | __N_SSP?: boolean; 11 | pageProps: P & { 12 | /** Initial session passed in from `getServerSideProps` or `getInitialProps` */ 13 | session?: Session; 14 | }; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /utils/env.ts: -------------------------------------------------------------------------------- 1 | type NameToType = { 2 | readonly ENV: 'production' | 'staging' | 'development' | 'test'; 3 | readonly NEXTAUTH_URL: string; 4 | readonly NODE_ENV: 'production' | 'development'; 5 | readonly POSTGRES_USER: string; 6 | readonly POSTGRES_PASSWORD: string; 7 | readonly POSTGRES_DB: string; 8 | readonly DATABASE_URL: string; 9 | readonly GITHUB_SECRET: string; 10 | readonly GITHUB_ID: string; 11 | readonly SECRET: string; 12 | readonly SENTRY_DSN: string; 13 | readonly NEXTAUTH_CALLBACK_URL: string; 14 | readonly NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string; 15 | readonly STRIPE_SECRET_KEY: string; 16 | readonly NEXT_PUBLIC_STRIPE_SUCCESS_REDIRECT_URL: string; 17 | readonly NEXT_PUBLIC_STRIPE_ERROR_REDIRECT_URL: string; 18 | readonly ABC: number; 19 | }; 20 | 21 | export function getEnv(name: Env): NameToType[Env]; 22 | export function getEnv(name: keyof NameToType): NameToType[keyof NameToType] { 23 | const val = process.env[name]; 24 | 25 | if (!val) { 26 | throw new Error(`Cannot find environmental variable: ${name}`); 27 | } 28 | 29 | return val; 30 | } 31 | -------------------------------------------------------------------------------- /utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | import type { AnySchema, InferType } from "yup"; 2 | import { ResponseError } from "./responseError"; 3 | import type { HTTPMethod } from "./types"; 4 | 5 | type FetcherConfig = { 6 | readonly method: HTTPMethod; 7 | readonly schema: Schema; 8 | readonly body?: object; 9 | readonly config?: RequestInit; 10 | }; 11 | 12 | export async function fetcher( 13 | path: string, 14 | { method, body, config, schema }: FetcherConfig 15 | ): Promise; 16 | 17 | export async function fetcher( 18 | path: string, 19 | { method, body, config, schema }: FetcherConfig 20 | ): Promise>; 21 | 22 | export async function fetcher( 23 | path: string, 24 | { method, body, config, schema }: FetcherConfig 25 | ) { 26 | try { 27 | const response = await fetch(path, { 28 | ...config, 29 | headers: { 30 | "Content-Type": "application/json", 31 | }, 32 | credentials: "include", 33 | method, 34 | ...(body && { body: JSON.stringify(body) }), 35 | }); 36 | if (response.ok) { 37 | if (!schema) { 38 | return null; 39 | } 40 | 41 | const data = await response.json(); 42 | 43 | return schema.cast(data); 44 | } 45 | throw new ResponseError(response.statusText, response.status); 46 | } catch (err) { 47 | if (err instanceof ResponseError) { 48 | throw err; 49 | } 50 | throw new ResponseError("Something went wrong during fetching!"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /utils/responseError.ts: -------------------------------------------------------------------------------- 1 | export class ResponseError extends Error { 2 | constructor(message: string, public readonly status?: number) { 3 | super(message); 4 | this.name = "ResponseError"; 5 | Object.setPrototypeOf(this, ResponseError.prototype); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /utils/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import * as y from 'yup'; 3 | import { getEnv } from './env'; 4 | import { loadStripe } from '@stripe/stripe-js'; 5 | 6 | export const redirectToCheckout = async (session: Pick) => { 7 | const stripe = await loadStripe(getEnv('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY')); 8 | 9 | return stripe!.redirectToCheckout({ 10 | sessionId: session.id, 11 | }); 12 | }; 13 | 14 | export const stripeSessionSchema: y.SchemaOf> = y 15 | .object() 16 | .shape({ 17 | id: y.string().required(), 18 | }); 19 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Nil = T | null | undefined; 2 | 3 | export type HTTPMethod = 4 | | "GET" 5 | | "HEAD" 6 | | "POST" 7 | | "PUT" 8 | | "DELETE" 9 | | "CONNECT" 10 | | "OPTIONS" 11 | | "TRACE" 12 | | "PATCH"; 13 | --------------------------------------------------------------------------------