├── .env.example ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── README.md ├── app ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── test-if-user-already-exists │ │ └── route.ts ├── create-account │ └── page.tsx ├── dashboard │ ├── (overview) │ │ ├── loading.tsx │ │ └── page.tsx │ ├── customers │ │ ├── [id] │ │ │ └── edit │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ ├── create │ │ │ └── page.tsx │ │ └── page.tsx │ ├── error.tsx │ ├── invoices │ │ ├── [id] │ │ │ └── edit │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ ├── create │ │ │ └── page.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── settings │ │ └── page.tsx │ └── user-profile │ │ └── page.tsx ├── forgot │ ├── instructions │ │ └── [email] │ │ │ └── page.tsx │ └── page.tsx ├── icon.png ├── layout.tsx ├── lib │ ├── actions.ts │ ├── data.ts │ ├── definitions.ts │ ├── theme.ts │ └── utils.ts ├── login │ └── page.tsx ├── opengraph-image.png ├── page.tsx ├── reset-password │ └── [token] │ │ └── page.tsx └── ui │ ├── acme-logo.tsx │ ├── button.tsx │ ├── create-account-form.tsx │ ├── customers │ ├── create-form.tsx │ ├── edit-form.tsx │ ├── pagination.tsx │ └── table.tsx │ ├── dashboard │ ├── cards.tsx │ ├── latest-invoices.tsx │ ├── nav-links.tsx │ ├── revenue-chart.tsx │ └── sidenav.tsx │ ├── fonts.ts │ ├── forgot-form.tsx │ ├── global.css │ ├── invoices │ ├── breadcrumbs.tsx │ ├── buttons.tsx │ ├── create-form.tsx │ ├── edit-form.tsx │ ├── pagination.tsx │ ├── status.tsx │ └── table.tsx │ ├── login-form.tsx │ ├── reset-password-form.tsx │ ├── search.tsx │ ├── settings │ └── settings-form.tsx │ ├── skeletons.tsx │ └── user-profile │ └── edit-form.tsx ├── auth.config.ts ├── auth.ts ├── dockerfile ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── hero-desktop.png ├── hero-mobile.png └── oauth-logos │ ├── github.svg │ └── google.svg ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Used for redirecting by the API routes 2 | BASE_URL= 3 | 4 | # Copy from .env.local on the Vercel dashboard 5 | # https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database 6 | POSTGRES_URL= 7 | POSTGRES_PRISMA_URL= 8 | POSTGRES_URL_NON_POOLING= 9 | POSTGRES_USER= 10 | POSTGRES_HOST= 11 | POSTGRES_PASSWORD= 12 | POSTGRES_DATABASE= 13 | 14 | # Linux: `openssl rand -hex 32` or go to https://generate-secret.vercel.app/32 15 | AUTH_SECRET= 16 | AUTH_URL= 17 | 18 | GITHUB_ID= 19 | GITHUB_SECRET= 20 | GOOGLE_ID= 21 | GOOGLE_SECRET= 22 | 23 | GOOGLE_ACCOUNT= 24 | GOOGLE_APP_PASSWORD= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dashboard App 2 | A Dashboard App where users can create an account (with their credentials or using an OAuth provider), create customers and assign invoices to them. Invoices will be shown at the Dashboard page as a summary. This project is based on the Next Learn Course, the official Next.js 14 tutorial and created by Vercel. 3 | 4 | > It is based on the Next Learn Course, the official Next.js 14 tutorial and created by Vercel and was created to showcase my developer skills. 5 | > [Click here](https://nextjs.org/learn) to visit the official tutorial page. 6 | > 7 | > You can access the current live version of the live project and see what I'm capable of by clicking 8 | > [here](https://josiasbudaydeveloper-next-14-dashboard-app.vercel.app/dashboard)! 9 | 10 | ![Course explainer](https://nextjs.org/_next/image?url=%2Flearn%2Fcourse-explainer.png&w=1920&q=75&dpl=dpl_DiW2ecigo2JKHD1ioFP2oTFMkZS8) 11 | 12 | ## This project is a Dashboard App that is a single page application (SPA) with client-side navigation and three main pages: 13 | - Dashboard - A summary of all invoices. 14 | - Invoices - A list of all invoices and the possibility of searching, creating, editing or deleting any invoice. It also has pagination at the bottom of the page. 15 | - Customers - A list of all customers with a search bar for searching for specific customers. There's no pagination here in this version, so all customers are displayed at the same time. 16 | - Dark Theme - Based on Tailwind.css and that only works based on the browser theme. 17 | 18 | ## Here’s everything I did in the version 2.0: 19 | - Multiuser system 20 | - OAuth Authentication 21 | - Pagination for the customers, as they're going to be flexible at this version 22 | - Dark Theme feature based on the user's browser theme 23 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth" 2 | export const { GET, POST } = handlers; -------------------------------------------------------------------------------- /app/api/test-if-user-already-exists/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import { sql } from '@vercel/postgres'; 3 | 4 | type AccountUser = { 5 | name: string, 6 | email: string 7 | } 8 | 9 | const BASE_URL = process.env.BASE_URL; 10 | 11 | export async function GET(req: any) { 12 | const session = await auth(); 13 | 14 | if (session) { 15 | const { name, email } = session.user as AccountUser; 16 | const user = await sql`SELECT * FROM users where email = ${email}`; 17 | 18 | const date = new Date().toISOString().split('T')[0]; 19 | if (!user.rowCount) { 20 | try { 21 | await sql` 22 | INSERT INTO users (name, email, isoauth, creation_date) 23 | VALUES (${name}, ${email}, ${true}, ${date}) 24 | ` 25 | } catch(error) { 26 | console.log(error); 27 | } 28 | } 29 | 30 | return Response.redirect(`${BASE_URL}/dashboard`); 31 | } 32 | 33 | return Response.redirect(`${BASE_URL}/login`); 34 | } -------------------------------------------------------------------------------- /app/create-account/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import CreateAccountForm from '@/app/ui/create-account-form'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Create Account', 7 | }; 8 | 9 | export default function LoginPage() { 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | } -------------------------------------------------------------------------------- /app/dashboard/(overview)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { getUser } from '@/app/lib/data'; 2 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 3 | import DashboardSkeleton from '@/app/ui/skeletons'; 4 | import { auth } from '@/auth'; 5 | 6 | export default async function Loading() { 7 | const session = await auth(); 8 | const userEmail = session?.user?.email!; 9 | const user = await getUser(userEmail); 10 | let theme: themeType; 11 | 12 | switch(user.theme) { 13 | case 'system': 14 | theme = systemDefault; 15 | break; 16 | case 'dark': 17 | theme = darkTheme; 18 | break; 19 | case 'light': 20 | theme = lightTheme; 21 | break; 22 | } 23 | 24 | return ; 25 | } -------------------------------------------------------------------------------- /app/dashboard/(overview)/page.tsx: -------------------------------------------------------------------------------- 1 | import CardWrapper from '@/app/ui/dashboard/cards'; 2 | import RevenueChart from '@/app/ui/dashboard/revenue-chart'; 3 | import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { Suspense } from 'react'; 6 | import { RevenueChartSkeleton, LatestInvoicesSkeleton, CardsSkeleton } from '@/app/ui/skeletons'; 7 | import { Metadata } from 'next'; 8 | import { auth } from '@/auth'; 9 | import { getUser } from '@/app/lib/data'; 10 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 11 | 12 | export const metadata: Metadata = { 13 | title: 'Dashboard', 14 | }; 15 | export default async function Page() { 16 | const session = await auth(); 17 | const userEmail = session?.user?.email!; 18 | const user = await getUser(userEmail); 19 | let theme: themeType; 20 | 21 | switch(user.theme) { 22 | case 'system': 23 | theme = systemDefault; 24 | break; 25 | case 'dark': 26 | theme = darkTheme; 27 | break; 28 | case 'light': 29 | theme = lightTheme; 30 | break; 31 | } 32 | 33 | return ( 34 |
35 |

36 | Dashboard 37 |

38 |
39 | }> 40 | 41 | 42 |
43 |
44 | }> 45 | 46 | 47 | }> 48 | 49 | 50 |
51 |
52 | ); 53 | } -------------------------------------------------------------------------------- /app/dashboard/customers/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FaceFrownIcon } from '@heroicons/react/24/outline'; 3 | import { Metadata } from 'next'; 4 | import { auth } from '@/auth'; 5 | import { getUser } from '@/app/lib/data'; 6 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Not-Found Invoice', 10 | }; 11 | 12 | export default async function NotFound() { 13 | const session = await auth(); 14 | const userEmail = session?.user?.email!; 15 | const user = await getUser(userEmail); 16 | let theme: themeType; 17 | 18 | switch(user.theme) { 19 | case 'system': 20 | theme = systemDefault; 21 | break; 22 | case 'dark': 23 | theme = darkTheme; 24 | break; 25 | case 'light': 26 | theme = lightTheme; 27 | break; 28 | } 29 | 30 | return ( 31 |
32 | 33 |

404 Not Found

34 |

Could not find the requested customer.

35 | 39 | Go Back 40 | 41 |
42 | ); 43 | } -------------------------------------------------------------------------------- /app/dashboard/customers/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/customers/edit-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchCustomerById, getUser } from '@/app/lib/data'; 4 | import { notFound } from 'next/navigation'; 5 | import { Metadata } from 'next'; 6 | import { auth } from '@/auth'; 7 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 8 | 9 | export const metadata: Metadata = { 10 | title: 'Edit Customer', 11 | }; 12 | 13 | export default async function Page({ params }: { params: { id: string } }) { 14 | const id = params.id; 15 | const session = await auth(); 16 | const userEmail = session?.user?.email!; 17 | 18 | const customer = await fetchCustomerById(id, userEmail); 19 | 20 | const user = await getUser(userEmail); 21 | let theme: themeType; 22 | 23 | switch(user.theme) { 24 | case 'system': 25 | theme = systemDefault; 26 | break; 27 | case 'dark': 28 | theme = darkTheme; 29 | break; 30 | case 'light': 31 | theme = lightTheme; 32 | break; 33 | } 34 | 35 | if (!customer) { 36 | return notFound(); 37 | } 38 | 39 | return ( 40 |
41 | 52 |
53 |
54 | ) 55 | } -------------------------------------------------------------------------------- /app/dashboard/customers/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUser } from '@/app/lib/data'; 2 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 3 | import Form from '@/app/ui/customers/create-form'; 4 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 5 | import { auth } from '@/auth'; 6 | import { Metadata } from 'next'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Create Customer', 10 | }; 11 | 12 | export default async function Page() { 13 | const session = await auth(); 14 | const userEmail = session?.user?.email!; 15 | const user = await getUser(userEmail); 16 | let theme: themeType; 17 | 18 | switch(user.theme) { 19 | case 'system': 20 | theme = systemDefault; 21 | break; 22 | case 'dark': 23 | theme = darkTheme; 24 | break; 25 | case 'light': 26 | theme = lightTheme; 27 | break; 28 | } 29 | 30 | return ( 31 |
32 | 43 | 44 |
45 | ); 46 | } -------------------------------------------------------------------------------- /app/dashboard/customers/page.tsx: -------------------------------------------------------------------------------- 1 | import Table from '@/app/ui/customers/table'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; 4 | import { Suspense } from 'react'; 5 | import { Metadata } from 'next'; 6 | import { systemDefault, darkTheme, lightTheme, themeType } from '@/app/lib/theme'; 7 | import Pagination from '@/app/ui/customers/pagination'; 8 | import { fetchCustomersPages, getUser } from '@/app/lib/data'; 9 | import { auth } from '@/auth'; 10 | 11 | export const metadata: Metadata = { 12 | title: 'Invoices', 13 | }; 14 | 15 | export default async function Page({ 16 | searchParams, 17 | }: { 18 | searchParams?: { 19 | query?: string; 20 | page?: string; 21 | }; 22 | }) { 23 | const query = searchParams?.query || ''; 24 | const currentPage = Number(searchParams?.page) || 1; 25 | 26 | const session = await auth(); 27 | const userEmail = session?.user?.email!; 28 | const totalPages = await fetchCustomersPages(query, userEmail); 29 | 30 | const user = await getUser(userEmail); 31 | let theme: themeType; 32 | 33 | switch(user.theme) { 34 | case 'system': 35 | theme = systemDefault; 36 | break; 37 | case 'dark': 38 | theme = darkTheme; 39 | break; 40 | case 'light': 41 | theme = lightTheme; 42 | break; 43 | } 44 | 45 | return ( 46 |
47 |
48 |

Customers

49 |
50 | }> 51 | 52 | 53 |
54 | 55 |
56 | 57 | ); 58 | } -------------------------------------------------------------------------------- /app/dashboard/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { Metadata } from 'next'; 5 | import { systemDefault } from '@/app/lib/theme'; 6 | 7 | export const metadata: Metadata = { 8 | title: 'Error', 9 | }; 10 | 11 | export default function Error({ 12 | error, 13 | reset, 14 | }: { 15 | error: Error & { digest?: string }; 16 | reset: () => void; 17 | }) { 18 | useEffect(() => { 19 | // Optionally log the error to an error reporting service 20 | console.error(error); 21 | }, [error]); 22 | 23 | return ( 24 |
25 |

Something went wrong!

26 | 35 |
36 | ); 37 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FaceFrownIcon } from '@heroicons/react/24/outline'; 3 | import { Metadata } from 'next'; 4 | import { auth } from '@/auth'; 5 | import { getUser } from '@/app/lib/data'; 6 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Not-Found Invoice', 10 | }; 11 | 12 | export default async function NotFound() { 13 | const session = await auth(); 14 | const userEmail = session?.user?.email!; 15 | const user = await getUser(userEmail); 16 | let theme: themeType; 17 | 18 | switch(user.theme) { 19 | case 'system': 20 | theme = systemDefault; 21 | break; 22 | case 'dark': 23 | theme = darkTheme; 24 | break; 25 | case 'light': 26 | theme = lightTheme; 27 | break; 28 | } 29 | 30 | return ( 31 |
32 | 33 |

404 Not Found

34 |

Could not find the requested invoice.

35 | 39 | Go Back 40 | 41 |
42 | ); 43 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/edit-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchInvoiceById, fetchCustomers, getUser } from '@/app/lib/data'; 4 | import { notFound } from 'next/navigation'; 5 | import { Metadata } from 'next'; 6 | import { auth } from '@/auth'; 7 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 8 | 9 | export const metadata: Metadata = { 10 | title: 'Edit Invoice', 11 | }; 12 | 13 | export default async function Page({ params }: { params: { id: string } }) { 14 | const id = params.id; 15 | 16 | const session = await auth(); 17 | const userEmail = session?.user?.email!; 18 | 19 | const [invoice, customers] = await Promise.all([ 20 | fetchInvoiceById(id, userEmail), 21 | fetchCustomers(userEmail), 22 | ]); 23 | 24 | if (!invoice) { 25 | notFound(); 26 | } 27 | 28 | const user = await getUser(userEmail); 29 | let theme: themeType; 30 | 31 | switch(user.theme) { 32 | case 'system': 33 | theme = systemDefault; 34 | break; 35 | case 'dark': 36 | theme = darkTheme; 37 | break; 38 | case 'light': 39 | theme = lightTheme; 40 | break; 41 | } 42 | 43 | return ( 44 |
45 | 56 | 57 |
58 | ) 59 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/create/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/create-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchCustomers, getUser } from '@/app/lib/data'; 4 | import { Metadata } from 'next'; 5 | import { auth } from '@/auth'; 6 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Create Invoice', 10 | }; 11 | 12 | export default async function Page() { 13 | const session = await auth(); 14 | const userEmail = session?.user!.email!; 15 | const customers = await fetchCustomers(userEmail); 16 | 17 | const user = await getUser(userEmail); 18 | let theme: themeType; 19 | 20 | switch(user.theme) { 21 | case 'system': 22 | theme = systemDefault; 23 | break; 24 | case 'dark': 25 | theme = darkTheme; 26 | break; 27 | case 'light': 28 | theme = lightTheme; 29 | break; 30 | } 31 | 32 | return ( 33 |
34 | 45 | 46 |
47 | ); 48 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/page.tsx: -------------------------------------------------------------------------------- 1 | import Pagination from '@/app/ui/invoices/pagination'; 2 | import Search from '@/app/ui/search'; 3 | import Table from '@/app/ui/invoices/table'; 4 | import { CreateInvoice } from '@/app/ui/invoices/buttons'; 5 | import { lusitana } from '@/app/ui/fonts'; 6 | import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; 7 | import { Suspense } from 'react'; 8 | import { fetchInvoicesPages, getUser } from '@/app/lib/data'; 9 | import { Metadata } from 'next'; 10 | import { auth } from '@/auth'; 11 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 12 | 13 | export const metadata: Metadata = { 14 | title: 'Invoices', 15 | }; 16 | 17 | export default async function Page({ 18 | searchParams, 19 | }: { 20 | searchParams?: { 21 | query?: string; 22 | page?: string; 23 | }; 24 | }) { 25 | const query = searchParams?.query || ''; 26 | const currentPage = Number(searchParams?.page) || 1; 27 | 28 | const session = await auth(); 29 | const userEmail = session?.user?.email!; 30 | 31 | const totalPages = await fetchInvoicesPages(query, userEmail); 32 | 33 | const user = await getUser(userEmail); 34 | let theme: themeType; 35 | 36 | switch(user.theme) { 37 | case 'system': 38 | theme = systemDefault; 39 | break; 40 | case 'dark': 41 | theme = darkTheme; 42 | break; 43 | case 'light': 44 | theme = lightTheme; 45 | break; 46 | } 47 | 48 | return ( 49 |
50 |
51 |

Invoices

52 |
53 |
54 | 55 | 56 |
57 | }> 58 |
59 | 60 |
61 | 62 |
63 | 64 | ); 65 | } -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | import { auth } from '@/auth'; 3 | import { getUser } from '../lib/data'; 4 | import { darkTheme, lightTheme, systemDefault, themeType } from '../lib/theme'; 5 | 6 | export default async function Layout({ children }: { children: React.ReactNode }) { 7 | const session = await auth(); 8 | const userEmail = session?.user?.email!; 9 | const user = await getUser(userEmail); 10 | let theme: themeType; 11 | 12 | switch(user.theme) { 13 | case 'system': 14 | theme = systemDefault; 15 | break; 16 | case 'dark': 17 | theme = darkTheme; 18 | break; 19 | case 'light': 20 | theme = lightTheme; 21 | break; 22 | } 23 | 24 | return ( 25 |
26 |
27 | 28 |
29 |
{children}
30 |
31 | ); 32 | } -------------------------------------------------------------------------------- /app/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/settings/settings-form'; 2 | import { Metadata } from 'next'; 3 | import { auth } from '@/auth'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { getUser } from '@/app/lib/data'; 6 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Settings', 10 | }; 11 | 12 | export default async function Page() { 13 | const session = await auth(); 14 | const userEmail = session?.user?.email!; 15 | const user = await getUser(userEmail); 16 | let theme: themeType; 17 | 18 | switch(user.theme) { 19 | case 'system': 20 | theme = systemDefault; 21 | break; 22 | case 'dark': 23 | theme = darkTheme; 24 | break; 25 | case 'light': 26 | theme = lightTheme; 27 | break; 28 | } 29 | 30 | return ( 31 |
32 |
33 |

Settings

34 |
35 | 36 |
37 | ) 38 | } -------------------------------------------------------------------------------- /app/dashboard/user-profile/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/user-profile/edit-form'; 2 | import { Metadata } from 'next'; 3 | import { auth } from '@/auth'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { getUser } from '@/app/lib/data'; 6 | import { darkTheme, lightTheme, systemDefault, themeType } from '@/app/lib/theme'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'User Profile', 10 | }; 11 | 12 | export default async function Page() { 13 | const session = await auth(); 14 | const userEmail = session?.user?.email!; 15 | const user = await getUser(userEmail); 16 | let theme: themeType; 17 | 18 | switch(user.theme) { 19 | case 'system': 20 | theme = systemDefault; 21 | break; 22 | case 'dark': 23 | theme = darkTheme; 24 | break; 25 | case 'light': 26 | theme = lightTheme; 27 | break; 28 | } 29 | 30 | return ( 31 |
32 |
33 |

User Profile

34 |
35 | 36 |
37 | ) 38 | } -------------------------------------------------------------------------------- /app/forgot/instructions/[email]/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import { Metadata } from 'next'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { systemDefault } from '@/app/lib/theme'; 5 | 6 | export const metadata: Metadata = { 7 | title: 'Forgot password', 8 | }; 9 | 10 | export default function LoginPage({params}: {params: {email: string}}) { 11 | let email = params.email.replace('%40','@'); 12 | return ( 13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 |
23 |

24 | If you typed your email address correctly, a message with instructions 25 | to reset your password was sent to {email} 26 |

27 |
28 |
29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /app/forgot/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import ForgotForm from '@/app/ui/forgot-form'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Forgot password', 7 | }; 8 | 9 | export default function LoginPage() { 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | } -------------------------------------------------------------------------------- /app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josiasbudaydeveloper/nextjs-14-dashboard-app-router-tutorial/c4718b048f18a41bca11d000f68da923bdb563b6/app/icon.png -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/ui/global.css'; 2 | import { inter } from '@/app/ui/fonts'; 3 | import { Metadata } from 'next'; 4 | import { systemDefault } from './lib/theme' 5 | 6 | export const metadata: Metadata = { 7 | title: { 8 | template: '%s | Acme Dashboard', 9 | default: 'Acme Dashboard', 10 | }, 11 | metadataBase: new URL('https://josiasbudaydeveloper-next-14-dashboard-app.vercel.app/'), 12 | description: 'A Dashboard App where users can create an account (with their credentials or using an OAuth provider), create customers and assign invoices to them. Invoices will be shown at the Dashboard page as a summary. This project is based on the Next Learn Course, the official Next.js 14 tutorial and created by Vercel.', 13 | openGraph: { 14 | title: 'Dashboard App, created by Vercel and modified by Josias Buday Developer', 15 | description: 'A Dashboard App where users can create an account (with their credentials or using an OAuth provider), create customers and assign invoices to them. Invoices will be shown at the Dashboard page as a summary. This project is based on the Next Learn Course, the official Next.js 14 tutorial and created by Vercel.', 16 | siteName: 'Acme Dashboard', 17 | locale: 'en_US' 18 | } 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: { 24 | children: React.ReactNode; 25 | }) { 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | } -------------------------------------------------------------------------------- /app/lib/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { z } from 'zod'; 4 | import { sql } from '@vercel/postgres'; 5 | import { redirect } from 'next/navigation'; 6 | import { signIn } from '@/auth'; 7 | import { AuthError } from 'next-auth'; 8 | import bcrypt from 'bcrypt'; 9 | import jwt from 'jsonwebtoken'; 10 | import nodemailer from 'nodemailer'; 11 | import type { User } from '@/app/lib/definitions'; 12 | import { unstable_noStore } from 'next/cache'; 13 | 14 | // A regular expression to check for valid email format 15 | const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; 16 | 17 | // A regular expression to check for at least one special character, one upper case 18 | // letter, one lower case letter and at least 8 characters 19 | const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[-_!@#$%^&*]).{8,}$/; 20 | 21 | // A Zod schema for the name field 22 | const nameSchema = z.string().min(3, "Name must have at least 3 characters"); 23 | 24 | // A Zod schema for the email field 25 | const emailSchema = z.string().regex(emailRegex, "Invalid email format"); 26 | 27 | // A Zod schema for the password field 28 | const passwordSchema = z.string().regex(passwordRegex, ` 29 | The password does not meet the minimum security requirements. 30 | `); 31 | 32 | // A Zod schema for the object with name, email and password fields 33 | const UserSchema = z.object({ 34 | name: nameSchema, 35 | email: emailSchema, 36 | password: passwordSchema 37 | // theme: z.coerce.number({ 38 | // invalid_type_error: 'Please select a theme', 39 | // }) 40 | }); 41 | 42 | const InvoicesSchema = z.object({ 43 | id: z.string(), 44 | customerId: z.string({ 45 | invalid_type_error: 'Please select a customer.', 46 | }), 47 | amount: z.coerce 48 | .number() 49 | .gt(0, { message: 'Please enter an amount greater than $0.' }), 50 | status: z.enum(['pending', 'paid'], { 51 | invalid_type_error: 'Please select an invoice status.', 52 | }), 53 | date: z.string(), 54 | }); 55 | 56 | const CustomerSchema = z.object({ 57 | name: nameSchema, 58 | email: emailSchema, 59 | userEmail: emailSchema 60 | }) 61 | 62 | // Use Zod to update the expected types 63 | const CreateInvoice = InvoicesSchema.omit({ id: true, date: true }); 64 | const UpdateInvoice = InvoicesSchema.omit({ id: true, date: true }); 65 | 66 | // This is temporary until @types/react-dom is updated 67 | export type InvoiceState = { 68 | errors?: { 69 | customerId?: string[]; 70 | amount?: string[]; 71 | status?: string[]; 72 | }; 73 | message?: string | null; 74 | }; 75 | 76 | export type UserState = { 77 | errors?: { 78 | name?: string[]; 79 | email?: string[]; 80 | password?: string[]; 81 | confirmPassword?: string[]; 82 | isoauth?: string[]; 83 | } 84 | message?: string | null; 85 | } 86 | 87 | export type CustomerState = { 88 | errors?: { 89 | name?: string[]; 90 | email?: string[]; 91 | } 92 | message?: string | null; 93 | } 94 | 95 | type ResetPasswordToken = { 96 | email: string; 97 | } 98 | 99 | export async function createInvoice(prevState: InvoiceState, formData: FormData) { 100 | // Validate form using Zod 101 | const validatedFields = CreateInvoice.safeParse({ 102 | customerId: formData.get('customerId'), 103 | amount: formData.get('amount'), 104 | status: formData.get('status'), 105 | }); 106 | 107 | // If form validation fails, return errors early. Otherwise, continue. 108 | if (!validatedFields.success) { 109 | return { 110 | errors: validatedFields.error.flatten().fieldErrors, 111 | message: 'Missing Fields. Failed to Create Invoice.', 112 | }; 113 | } 114 | 115 | // Prepare data for insertion into the database 116 | const { customerId, amount, status } = validatedFields.data; 117 | const amountInCents = amount * 100; 118 | const date = new Date().toISOString().split('T')[0]; 119 | 120 | // Insert data into the database 121 | try { 122 | await sql` 123 | INSERT INTO invoices (customer_id, amount, status, date) 124 | VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) 125 | `; 126 | } catch (error) { 127 | // If a database error occurs, return a more specific error. 128 | return { 129 | message: 'Database Error: Failed to Create Invoice.', 130 | }; 131 | } 132 | 133 | redirect('/dashboard/invoices'); 134 | } 135 | 136 | export async function updateInvoice( 137 | id: string, 138 | prevState: InvoiceState, 139 | formData: FormData, 140 | ) { 141 | const validatedFields = UpdateInvoice.safeParse({ 142 | customerId: formData.get('customerId'), 143 | amount: formData.get('amount'), 144 | status: formData.get('status'), 145 | }); 146 | 147 | if (!validatedFields.success) { 148 | return { 149 | errors: validatedFields.error.flatten().fieldErrors, 150 | message: 'Missing Fields. Failed to Update Invoice.', 151 | }; 152 | } 153 | 154 | const { customerId, amount, status } = validatedFields.data; 155 | const amountInCents = amount * 100; 156 | 157 | try { 158 | await sql` 159 | UPDATE invoices 160 | SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} 161 | WHERE id = ${id} 162 | `; 163 | } catch (error) { 164 | return { message: 'Database Error: Failed to Update Invoice.' }; 165 | } 166 | 167 | redirect('/dashboard/invoices'); 168 | } 169 | 170 | export async function deleteInvoice(id: string) { 171 | try { 172 | await sql`DELETE FROM invoices WHERE id = ${id}`; 173 | return { message: 'Deleted Invoice.' }; 174 | } catch (error) { 175 | return { message: 'Database Error: Failed to Delete Invoice.' }; 176 | } 177 | } 178 | 179 | export async function createCustomer(prevState: CustomerState, formData: FormData) { 180 | // Validate form using Zod 181 | const validatedFields = CustomerSchema.safeParse({ 182 | name: formData.get('name'), 183 | email: formData.get('email'), 184 | userEmail: formData.get('userEmail') 185 | }); 186 | 187 | // If form validation fails, return errors early. Otherwise, continue. 188 | if (!validatedFields.success) { 189 | return { 190 | errors: validatedFields.error.flatten().fieldErrors, 191 | message: 'Missing Fields. Failed to Create Customer.', 192 | }; 193 | } 194 | 195 | // Prepare data for insertion into the database 196 | const { name, email, userEmail} = validatedFields.data; 197 | 198 | // Insert data into the database 199 | try { 200 | await sql` 201 | INSERT INTO customers (name, email, user_email) 202 | VALUES (${name}, ${email}, ${userEmail}) 203 | `; 204 | } catch (error) { 205 | // If a database error occurs, return a more specific error. 206 | return { 207 | message: 'Database Error: Failed to Create Customer.', 208 | }; 209 | } 210 | 211 | redirect('/dashboard/customers'); 212 | } 213 | 214 | export async function updateCustomer( 215 | id: string, 216 | prevState: CustomerState, 217 | formData: FormData 218 | ) { 219 | const validatedFields = CustomerSchema.safeParse({ 220 | name: formData.get('name'), 221 | email: formData.get('email'), 222 | userEmail: formData.get('userEmail') 223 | }); 224 | 225 | if (!validatedFields.success) { 226 | return { 227 | errors: validatedFields.error.flatten().fieldErrors, 228 | message: 'Missing Fields. Failed to Update Customer.', 229 | }; 230 | } 231 | 232 | const { name, email, userEmail } = validatedFields.data; 233 | 234 | try { 235 | await sql` 236 | UPDATE customers 237 | SET name = ${name}, email = ${email} 238 | WHERE 239 | customers.user_email = ${userEmail} 240 | AND 241 | id = ${id} 242 | `; 243 | } catch (error) { 244 | return { message: 'Database Error: Failed to Update Customer.' }; 245 | } 246 | 247 | redirect('/dashboard/customers'); 248 | } 249 | 250 | export async function deleteCustomer(id: string) { 251 | try { 252 | await sql`DELETE FROM customers WHERE id = ${id}`; 253 | return { message: 'Deleted Customer.' }; 254 | } catch (error) { 255 | return { message: 'Database Error: Failed to Delete Customer.' }; 256 | } 257 | } 258 | 259 | export async function createUserWithCredentials(prevState: UserState, formData: FormData) { 260 | // Validate form using Zod 261 | const validatedFields = UserSchema.safeParse({ 262 | name: formData.get('name'), 263 | email: formData.get('email'), 264 | password: formData.get('password'), 265 | }); 266 | 267 | // If form validation fails, return errors early. Otherwise, continue. 268 | if (!validatedFields.success) { 269 | return { 270 | errors: validatedFields.error.flatten().fieldErrors, 271 | message: 'Missing or wrong fields. Failed to create Account.', 272 | }; 273 | } 274 | 275 | const { name, email, password } = validatedFields.data; 276 | const confirmPassword = formData.get('confirm-password'); 277 | if (password != confirmPassword) { 278 | return { 279 | message: 'Passwords are different.' 280 | }; 281 | } 282 | 283 | const hashedPassword = await bcrypt.hash(password, 10); 284 | const account = await sql`SELECT * FROM users WHERE email=${email}`; 285 | 286 | if (account.rowCount) { 287 | return { 288 | message: `This email address is already in use, please use another one!` 289 | } 290 | } 291 | 292 | const date = new Date().toISOString().split('T')[0]; 293 | try { 294 | await sql`INSERT INTO users (name, email, password, isoauth, creation_date) VALUES 295 | (${name}, ${email}, ${hashedPassword}, ${false}, ${date})`; 296 | } catch (error) { 297 | console.log(` 298 | Database Error: Failed to create account: 299 | ${error} 300 | `); 301 | return { 302 | message: ` 303 | Database Error: Failed to create account. 304 | Please try again or contact the support team. 305 | ` 306 | } 307 | } 308 | 309 | redirect('/login?account-created=true'); 310 | } 311 | 312 | export async function authenticateWithCredentials( 313 | prevState: string | undefined, 314 | formData: FormData, 315 | ) { 316 | 317 | try { 318 | await signIn('credentials', formData); 319 | } catch (error) { 320 | if (error instanceof AuthError) { 321 | console.log(error.type); 322 | switch (error.type) { 323 | case 'CredentialsSignin': 324 | return 'Invalid credentials.'; 325 | default: 326 | return 'Something went wrong.'; 327 | } 328 | } 329 | throw error; 330 | } 331 | } 332 | 333 | export async function authenticateWithOAuth(provider: string) { 334 | await signIn(provider); 335 | } 336 | 337 | export async function updateUser( 338 | prevState: UserState, 339 | formData: FormData 340 | ) { 341 | 342 | // Validate form using Zod 343 | const validatedFields = UserSchema.safeParse({ 344 | name: formData.get('name'), 345 | password: formData.get('password'), 346 | // theme: formData.get('theme'), 347 | email: formData.get('userEmail') 348 | }); 349 | 350 | // If form validation fails, return errors early. Otherwise, continue. 351 | if (!validatedFields.success) { 352 | return { 353 | errors: validatedFields.error.flatten().fieldErrors, 354 | message: 'Missing Fields. Failed to Update User.', 355 | }; 356 | } 357 | 358 | // Prepare data for insertion into the database 359 | // const { name, email, password, theme} = validatedFields.data; // If the theme is enabled 360 | const { name, email, password } = validatedFields.data; 361 | 362 | const confirmPassword = formData.get('confirm-password'); 363 | if (password != confirmPassword) { 364 | return { 365 | message: 'Passwords are different' 366 | } 367 | } 368 | 369 | const hashedPassword = await bcrypt.hash(password, 10); 370 | 371 | 372 | // Insert data into the database 373 | try { 374 | await sql` 375 | UPDATE users 376 | SET 377 | name = ${name}, 378 | password = ${hashedPassword}, 379 | isoauth = false 380 | WHERE 381 | email = ${email} 382 | `; 383 | } catch (error) { 384 | // If a database error occurs, return a more specific error. 385 | 386 | return { 387 | message: 'Database Error: Failed to Update User.', 388 | }; 389 | } 390 | 391 | redirect('/dashboard/user-profile?user-updated=true'); 392 | } 393 | 394 | export async function updateTheme( 395 | formData: FormData 396 | ) { 397 | unstable_noStore(); 398 | let theme = formData.get('theme') as 'system' | 'dark' | 'light'; 399 | const email = formData.get('user-email') as string; 400 | 401 | try { 402 | await sql` 403 | UPDATE users 404 | SET 405 | theme = ${theme} 406 | WHERE 407 | email = ${email} 408 | `; 409 | } catch (error) { 410 | console.log(error); 411 | } 412 | 413 | redirect('/dashboard/settings'); 414 | } 415 | 416 | export async function forgotPassword( 417 | prevState: string | undefined, 418 | formData: FormData) 419 | { 420 | const email = formData.get('email'); 421 | const resetToken = jwt.sign({ 422 | email 423 | }, 424 | process.env.AUTH_SECRET!, 425 | { 426 | algorithm: 'HS256', 427 | expiresIn: '30min' 428 | } 429 | ); 430 | 431 | const transporter = nodemailer.createTransport({ 432 | service: 'gmail', 433 | auth: { 434 | user: process.env.GOOGLE_ACCOUNT!, // Your Gmail email address 435 | pass: process.env.GOOGLE_APP_PASSWORD!, // The app password you generated 436 | }, 437 | }); 438 | 439 | try { 440 | await transporter.sendMail({ 441 | from: process.env.GOOGLE_ACCOUNT!, // Same as the 'user' above 442 | to: email as string, // Recipient email(s) 443 | subject: 'Your password reset link', // Subject of the email 444 | text: `Click the link to reset your password: ${process.env.BASE_URL}/reset-password/${resetToken}`, // Customize the email content 445 | }); 446 | } catch(error) { 447 | console.log(error); 448 | return "Something went wrong."; 449 | } 450 | 451 | redirect(`/forgot/instructions/${email}`); 452 | } 453 | 454 | export async function resetPassword( 455 | token: string, 456 | prevState: string | undefined, 457 | formData: FormData 458 | ) { 459 | // checking whether the token is still valid 460 | try { 461 | var decoded = jwt.verify(token, process.env.AUTH_SECRET!) as ResetPasswordToken; 462 | } catch(error) { 463 | console.log(error); 464 | return 'This token is invalid or has expired.'; 465 | } 466 | 467 | // checking whether there is an user with this email 468 | const email = decoded.email; 469 | try { 470 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 471 | if (!user.rows[0]) { 472 | return `There's no user with this email: ${email}`; 473 | } 474 | } catch(error) { 475 | console.log('Something went wrong.'); 476 | return 'Something went wrong.'; 477 | } 478 | 479 | // updating the password 480 | const ValidatePassword = passwordSchema.safeParse(formData.get('password')); 481 | 482 | // If form validation fails, return errors early. Otherwise, continue. 483 | if (!ValidatePassword.success) { 484 | return 'Passwords must have at least 8 characters,' + 485 | 'one special character, one upper case letter and one lower case letter.'; 486 | } 487 | 488 | // Insert data into the database 489 | const password = ValidatePassword.data; 490 | const confirmPassword = formData.get('confirm-password'); 491 | if (password != confirmPassword) { 492 | return 'Passwords are different.'; 493 | } 494 | 495 | const hashedPassword = await bcrypt.hash(password, 10); 496 | try { 497 | await sql` 498 | UPDATE users 499 | SET 500 | password = ${hashedPassword} 501 | WHERE 502 | email = ${email} 503 | `; 504 | } catch (error) { 505 | console.log(error); 506 | 507 | return 'Database Error: Failed to Update User.'; 508 | } 509 | 510 | redirect('/login?password-updated=true'); 511 | } -------------------------------------------------------------------------------- /app/lib/data.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { 3 | CustomerField, 4 | CustomersTableType, 5 | InvoiceForm, 6 | InvoicesTable, 7 | LatestInvoiceRaw, 8 | User, 9 | Revenue, 10 | CustomerForm, 11 | } from './definitions'; 12 | import { formatCurrency } from './utils'; 13 | import { unstable_noStore as noStore } from 'next/cache'; 14 | import { auth } from '@/auth'; 15 | 16 | const ITEMS_PER_PAGE = 6; 17 | 18 | (async ()=> { 19 | // automatically deleting registries that has more than 1 week of existence 20 | try { 21 | await sql`DELETE FROM users WHERE creation_date < NOW() - INTERVAL '1 week';`; 22 | } catch(error) { 23 | console.log(error); 24 | } 25 | })(); 26 | 27 | export async function fetchRevenue() { 28 | // Add noStore() here prevent the response from being cached. 29 | // This is equivalent to in fetch(..., {cache: 'no-store'}). 30 | noStore(); 31 | 32 | try { 33 | // Artificially delay a response for demo purposes. 34 | // Don't do this in production :) 35 | 36 | // console.log('Fetching revenue data...'); 37 | // await new Promise((resolve) => setTimeout(resolve, 3000)); 38 | const session = await auth(); 39 | const userEmail = session?.user?.email!; 40 | 41 | const data = await sql` 42 | SELECT SUM(i.amount) AS revenue, 43 | CASE EXTRACT(MONTH FROM i.date) 44 | WHEN 1 THEN 'Jan' 45 | WHEN 2 THEN 'Feb' 46 | WHEN 3 THEN 'Mar' 47 | WHEN 4 THEN 'Apr' 48 | WHEN 5 THEN 'May' 49 | WHEN 6 THEN 'Jun' 50 | WHEN 7 THEN 'Jul' 51 | WHEN 8 THEN 'Aug' 52 | WHEN 9 THEN 'Sep' 53 | WHEN 10 THEN 'Oct' 54 | WHEN 11 THEN 'Nov' 55 | WHEN 12 THEN 'Dec' 56 | END AS month 57 | FROM invoices AS i 58 | INNER JOIN customers AS c ON i.customer_id = c.id 59 | WHERE c.user_email = ${userEmail} 60 | AND i.status = 'paid' 61 | AND EXTRACT(YEAR FROM i.date) = EXTRACT(YEAR FROM CURRENT_DATE) 62 | GROUP BY EXTRACT(MONTH FROM i.date) 63 | ORDER BY month; 64 | `; 65 | 66 | // console.log('Data fetch completed after 3 seconds.'); 67 | 68 | return data.rows; 69 | } catch (error) { 70 | console.error('Database Error:', error); 71 | throw new Error('Failed to fetch revenue data.'); 72 | } 73 | } 74 | 75 | export async function fetchLatestInvoices(userEmail: string) { 76 | noStore(); 77 | 78 | try { 79 | const data = await sql` 80 | SELECT invoices.amount, customers.name, customers.email, invoices.id 81 | FROM invoices 82 | JOIN customers ON invoices.customer_id = customers.id 83 | WHERE 84 | customers.user_email = ${userEmail} 85 | ORDER BY invoices.date DESC 86 | LIMIT 5`; 87 | 88 | const latestInvoices = data.rows.map((invoice) => ({ 89 | ...invoice, 90 | amount: formatCurrency(invoice.amount), 91 | })); 92 | return latestInvoices; 93 | } catch (error) { 94 | console.error('Database Error:', error); 95 | throw new Error('Failed to fetch the latest invoices.'); 96 | } 97 | } 98 | 99 | export async function fetchCardData(userEmail: string) { 100 | noStore(); 101 | 102 | try { 103 | // You can probably combine these into a single SQL query 104 | // However, we are intentionally splitting them to demonstrate 105 | // how to initialize multiple queries in parallel with JS. 106 | const invoiceCountPromise = sql` 107 | SELECT 108 | COUNT(*) 109 | FROM 110 | invoices 111 | JOIN 112 | customers ON invoices.customer_id = customers.id 113 | WHERE 114 | customers.user_email = ${userEmail}`; 115 | 116 | const customerCountPromise = sql`SELECT COUNT(*) FROM customers 117 | WHERE customers.user_email = ${userEmail};`; 118 | 119 | const invoiceStatusPromise = sql` 120 | SELECT 121 | SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid", 122 | SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending" 123 | FROM 124 | invoices 125 | JOIN 126 | customers ON invoices.customer_id = customers.id 127 | WHERE 128 | customers.user_email = ${userEmail}`; 129 | 130 | const data = await Promise.all([ 131 | invoiceCountPromise, 132 | customerCountPromise, 133 | invoiceStatusPromise, 134 | ]); 135 | 136 | const numberOfInvoices = Number(data[0].rows[0].count ?? '0'); 137 | const numberOfCustomers = Number(data[1].rows[0].count ?? '0'); 138 | const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0'); 139 | const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0'); 140 | 141 | return { 142 | numberOfCustomers, 143 | numberOfInvoices, 144 | totalPaidInvoices, 145 | totalPendingInvoices, 146 | }; 147 | } catch (error) { 148 | console.error('Database Error:', error); 149 | throw new Error('Failed to fetch card data.'); 150 | } 151 | } 152 | 153 | export async function fetchFilteredInvoices( 154 | query: string, 155 | currentPage: number, 156 | userEmail: string 157 | ) { 158 | noStore(); 159 | 160 | const offset = (currentPage - 1) * ITEMS_PER_PAGE; 161 | 162 | try { 163 | const invoices = await sql` 164 | SELECT 165 | invoices.id, 166 | invoices.amount, 167 | invoices.date, 168 | invoices.status, 169 | customers.name, 170 | customers.email 171 | FROM invoices 172 | JOIN customers ON invoices.customer_id = customers.id 173 | WHERE 174 | customers.user_email = ${userEmail} AND 175 | (customers.name ILIKE ${`%${query}%`} OR 176 | customers.email ILIKE ${`%${query}%`} OR 177 | invoices.amount::text ILIKE ${`%${query}%`} OR 178 | invoices.date::text ILIKE ${`%${query}%`} OR 179 | invoices.status ILIKE ${`%${query}%`}) 180 | ORDER BY invoices.date DESC 181 | LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset} 182 | `; 183 | 184 | return invoices.rows; 185 | } catch (error) { 186 | console.error('Database Error:', error); 187 | throw new Error('Failed to fetch invoices.'); 188 | } 189 | } 190 | 191 | export async function fetchInvoicesPages(query: string, userEmail: string) { 192 | noStore(); 193 | 194 | try { 195 | const count = await sql`SELECT COUNT(*) 196 | FROM invoices 197 | JOIN customers ON invoices.customer_id = customers.id 198 | WHERE 199 | customers.user_email = ${userEmail} AND 200 | (customers.name ILIKE ${`%${query}%`} OR 201 | customers.email ILIKE ${`%${query}%`} OR 202 | invoices.amount::text ILIKE ${`%${query}%`} OR 203 | invoices.date::text ILIKE ${`%${query}%`} OR 204 | invoices.status ILIKE ${`%${query}%`}) 205 | `; 206 | 207 | const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE); 208 | return totalPages; 209 | } catch (error) { 210 | console.error('Database Error:', error); 211 | throw new Error('Failed to fetch total number of invoices.'); 212 | } 213 | } 214 | 215 | export async function fetchInvoiceById(id: string, userEmail: string) { 216 | noStore(); 217 | 218 | try { 219 | const data = await sql` 220 | SELECT 221 | invoices.id, 222 | invoices.customer_id, 223 | invoices.amount, 224 | invoices.status 225 | FROM 226 | invoices 227 | JOIN 228 | customers ON invoices.customer_id = customers.id 229 | WHERE 230 | customers.user_email = ${userEmail} 231 | AND 232 | invoices.id = ${id}; 233 | `; 234 | 235 | const invoice = data.rows.map((invoice) => ({ 236 | ...invoice, 237 | // Convert amount from cents to dollars 238 | amount: invoice.amount / 100, 239 | })); 240 | 241 | return invoice[0]; 242 | } catch (error) { 243 | console.error('Database Error:', error); 244 | // throw new Error('Failed to fetch invoice.'); 245 | 246 | return false // we can't return an error, because it can break the not-found functionality at app\dashboard\invoices\[id]\edit\not-found.tsx 247 | } 248 | } 249 | 250 | export async function fetchCustomers(userEmail: string) { 251 | noStore(); 252 | 253 | try { 254 | const data = await sql` 255 | SELECT 256 | id, 257 | name 258 | FROM customers 259 | where customers.user_email = ${userEmail} 260 | ORDER BY name ASC 261 | `; 262 | 263 | const customers = data.rows; 264 | return customers; 265 | } catch (err) { 266 | console.error('Database Error:', err); 267 | throw new Error('Failed to fetch all customers.'); 268 | } 269 | } 270 | 271 | export async function fetchFilteredCustomers(query: string, currentPage: number, userEmail: string) { 272 | noStore(); 273 | 274 | const offset = (currentPage - 1) * ITEMS_PER_PAGE; 275 | 276 | try { 277 | const data = await sql` 278 | SELECT 279 | customers.id, 280 | customers.name, 281 | customers.email, 282 | COUNT(invoices.id) AS total_invoices, 283 | SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending, 284 | SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid 285 | FROM customers 286 | LEFT JOIN invoices ON customers.id = invoices.customer_id 287 | WHERE 288 | customers.user_email = ${userEmail} AND 289 | (customers.name ILIKE ${`%${query}%`} OR 290 | customers.email ILIKE ${`%${query}%`}) 291 | GROUP BY customers.id, customers.name, customers.email, customers 292 | ORDER BY customers.name ASC 293 | LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset} 294 | `; 295 | 296 | const customers = data.rows.map((customer) => ({ 297 | ...customer, 298 | total_pending: formatCurrency(customer.total_pending), 299 | total_paid: formatCurrency(customer.total_paid), 300 | })); 301 | 302 | return customers; 303 | } catch (err) { 304 | console.error('Database Error:', err); 305 | throw new Error('Failed to fetch customer table.'); 306 | } 307 | } 308 | 309 | export async function fetchCustomersPages(query: string, userEmail: string) { 310 | noStore(); 311 | 312 | try { 313 | const count = await sql`SELECT COUNT(*) 314 | FROM customers 315 | WHERE 316 | customers.user_email = ${userEmail} AND 317 | (customers.name ILIKE ${`%${query}%`} OR 318 | customers.email ILIKE ${`%${query}%`}) 319 | `; 320 | 321 | const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE); 322 | return totalPages; 323 | } catch (error) { 324 | console.error('Database Error:', error); 325 | throw new Error('Failed to fetch total number of customers.'); 326 | } 327 | } 328 | 329 | export async function fetchCustomerById(id: string, userEmail: string) { 330 | noStore(); 331 | 332 | try { 333 | const customer = await sql` 334 | SELECT 335 | id, name, email 336 | FROM customers 337 | WHERE 338 | customers.user_email = ${userEmail} 339 | AND 340 | id = ${id}; 341 | `; 342 | 343 | return customer.rows[0]; 344 | } catch (error) { 345 | console.error('Database Error:', error); 346 | // throw new Error('Failed to fetch customer.'); 347 | 348 | return false // we can't return an error, because it can break the not-found functionality at app\dashboard\invoices\[id]\edit\not-found.tsx 349 | } 350 | } 351 | 352 | export async function getUser(userEmail: string) { 353 | noStore(); 354 | 355 | try { 356 | const user = await sql`SELECT * FROM users WHERE email = ${userEmail}`; 357 | return user.rows[0] as User; 358 | } catch (error) { 359 | console.error('Failed to fetch user:', error); 360 | throw new Error('Failed to fetch user.'); 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /app/lib/definitions.ts: -------------------------------------------------------------------------------- 1 | // This file contains type definitions for your data. 2 | // It describes the shape of the data, and what data type each property should accept. 3 | // For simplicity of teaching, we're manually defining these types. 4 | // However, these types are generated automatically if you're using an ORM such as Prisma. 5 | export type User = { 6 | id: string; 7 | name: string; 8 | email: string; 9 | password: string; 10 | isoauth: boolean; 11 | theme: 'system' | 'dark' | 'light'; 12 | }; 13 | 14 | export type Customer = { 15 | id: string; 16 | name: string; 17 | email: string; 18 | }; 19 | 20 | export type Invoice = { 21 | id: string; 22 | customer_id: string; 23 | amount: number; 24 | date: string; 25 | // In TypeScript, this is called a string union type. 26 | // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'. 27 | status: 'pending' | 'paid'; 28 | }; 29 | 30 | export type Revenue = { 31 | month: string; 32 | revenue: number; 33 | }; 34 | 35 | export type LatestInvoice = { 36 | id: string; 37 | name: string; 38 | email: string; 39 | amount: string; 40 | }; 41 | 42 | // The database returns a number for amount, but we later format it to a string with the formatCurrency function 43 | export type LatestInvoiceRaw = Omit & { 44 | amount: number; 45 | }; 46 | 47 | export type InvoicesTable = { 48 | id: string; 49 | customer_id: string; 50 | name: string; 51 | email: string; 52 | date: string; 53 | amount: number; 54 | status: 'pending' | 'paid'; 55 | }; 56 | 57 | export type CustomersTableType = { 58 | id: string; 59 | name: string; 60 | email: string; 61 | total_invoices: number; 62 | total_pending: number; 63 | total_paid: number; 64 | }; 65 | 66 | export type FormattedCustomersTable = { 67 | id: string; 68 | name: string; 69 | email: string; 70 | total_invoices: number; 71 | total_pending: string; 72 | total_paid: string; 73 | }; 74 | 75 | export type CustomerField = { 76 | id: string; 77 | name: string; 78 | }; 79 | 80 | export type InvoiceForm = { 81 | id: string; 82 | customer_id: string; 83 | amount: number; 84 | status: 'pending' | 'paid'; 85 | }; 86 | 87 | export type CustomerForm = { 88 | id: string; 89 | name: string; 90 | email: string; 91 | }; 92 | -------------------------------------------------------------------------------- /app/lib/theme.ts: -------------------------------------------------------------------------------- 1 | export type themeType = { 2 | bg: string; 3 | container: string; 4 | title: string; 5 | text: string; 6 | border: string; 7 | notActiveText: string; 8 | divide: string; 9 | activeLink: string; 10 | hoverBg: string; 11 | hoverText: string; 12 | hoverBorder: string; 13 | inputIcon: string; 14 | } 15 | 16 | export const lightTheme : themeType = { 17 | bg: 'bg-white', 18 | container: 'bg-gray-50', 19 | title: 'text-black', 20 | text: 'text-gray-900', 21 | border: 'border-gray-200', 22 | notActiveText: '', 23 | divide: 'divide-gray-200', 24 | 25 | // Actions 26 | activeLink: 'bg-sky-100', 27 | hoverBg: 'hover:bg-sky-100', 28 | hoverText: 'hover:text-blue-600', 29 | hoverBorder: 'hover:border-blue-600', 30 | inputIcon: 'peer-focus:text-gray-900' 31 | } 32 | 33 | export const darkTheme : themeType = { 34 | bg: 'bg-[#181818]', 35 | container: 'bg-[#212121]', 36 | title: 'text-white', 37 | text: 'text-[#ebebeb]', 38 | border: 'border-gray-500', 39 | notActiveText: 'text-gray-500', 40 | divide: 'divide-gray-500', 41 | 42 | // Actions 43 | activeLink: 'bg-[#1c2932] hover:bg-[#1c2932]', 44 | hoverBg: 'hover:bg-[#1c2932]', 45 | hoverText: 'hover:text-blue-600', 46 | hoverBorder: 'hover:border-blue-600', 47 | inputIcon: 'peer-focus:text-gray-500' 48 | } 49 | 50 | /* export const systemDefault : themeType = { 51 | bg: `${lightTheme.bg} dark:${darkTheme.bg}`, 52 | container: `${lightTheme.container} dark:${darkTheme.container}`, 53 | title: `${lightTheme.title} dark:${darkTheme.title}`, 54 | text: `${lightTheme.text} dark:${darkTheme.text}`, 55 | border: `${lightTheme.border} dark:${darkTheme.border}`, 56 | notActiveText: `${lightTheme.notActiveText} dark:${darkTheme.notActiveText}`, 57 | divide: `${lightTheme.divide} dark:${darkTheme.divide}`, 58 | 59 | // Actions 60 | activeLink: `${lightTheme.activeLink} dark:${darkTheme.activeLink}`, 61 | hoverBg: `${lightTheme.hoverBg} dark:${darkTheme.hoverBg}`, 62 | hoverText: `${lightTheme.hoverText} dark:${darkTheme.hoverText}`, 63 | hoverBorder: `${lightTheme.hoverBorder} dark:${darkTheme.hoverBorder}`, 64 | inputIcon: `${lightTheme.inputIcon} dark:${darkTheme.inputIcon}` 65 | } */ 66 | 67 | export const systemDefault = { 68 | bg: 'bg-white', 69 | container: 'bg-gray-50', 70 | title: 'text-black', 71 | text: 'text-gray-900', 72 | border: 'border-gray-200', 73 | notActiveText: '', 74 | divide: 'divide-gray-200', 75 | 76 | // Actions 77 | activeLink: 'bg-sky-100', 78 | hoverBg: 'hover:bg-sky-100', 79 | hoverText: 'hover:text-blue-600', 80 | hoverBorder: 'hover:border-blue-600', 81 | inputIcon: 'peer-focus:text-gray-900' 82 | } -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Revenue } from './definitions'; 2 | 3 | export const formatCurrency = (amount: number) => { 4 | return (amount / 100).toLocaleString('en-US', { 5 | style: 'currency', 6 | currency: 'USD', 7 | }); 8 | }; 9 | 10 | export const formatDateToLocal = ( 11 | dateStr: string, 12 | locale: string = 'en-US', 13 | ) => { 14 | const date = new Date(dateStr); 15 | const options: Intl.DateTimeFormatOptions = { 16 | day: 'numeric', 17 | month: 'short', 18 | year: 'numeric', 19 | }; 20 | const formatter = new Intl.DateTimeFormat(locale, options); 21 | return formatter.format(date); 22 | }; 23 | 24 | export const generateYAxis = (revenue: Revenue[]) => { 25 | // Calculate what labels we need to display on the y-axis 26 | // based on highest record and in 1000s 27 | const yAxisLabels = []; 28 | const highestRecord = Math.max(...revenue.map((month) => month.revenue)); 29 | const topLabel = Math.ceil(highestRecord / 1000) * 1000; 30 | 31 | for (let i = topLabel; i >= 0; i -= 1000) { 32 | yAxisLabels.push(`$${i / 1000}K`); 33 | } 34 | 35 | return { yAxisLabels, topLabel }; 36 | }; 37 | 38 | export const generatePagination = (currentPage: number, totalPages: number) => { 39 | // If the total number of pages is 7 or less, 40 | // display all pages without any ellipsis. 41 | if (totalPages <= 7) { 42 | return Array.from({ length: totalPages }, (_, i) => i + 1); 43 | } 44 | 45 | // If the current page is among the first 3 pages, 46 | // show the first 3, an ellipsis, and the last 2 pages. 47 | if (currentPage <= 3) { 48 | return [1, 2, 3, '...', totalPages - 1, totalPages]; 49 | } 50 | 51 | // If the current page is among the last 3 pages, 52 | // show the first 2, an ellipsis, and the last 3 pages. 53 | if (currentPage >= totalPages - 2) { 54 | return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages]; 55 | } 56 | 57 | // If the current page is somewhere in the middle, 58 | // show the first page, an ellipsis, the current page and its neighbors, 59 | // another ellipsis, and the last page. 60 | return [ 61 | 1, 62 | '...', 63 | currentPage - 1, 64 | currentPage, 65 | currentPage + 1, 66 | '...', 67 | totalPages, 68 | ]; 69 | }; 70 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import LoginForm from '@/app/ui/login-form'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Login', 7 | }; 8 | 9 | export default async function LoginPage() { 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | } -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josiasbudaydeveloper/nextjs-14-dashboard-app-router-tutorial/c4718b048f18a41bca11d000f68da923bdb563b6/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import { ArrowRightIcon } from '@heroicons/react/24/outline'; 3 | import Link from 'next/link'; 4 | import { lusitana } from './ui/fonts'; 5 | import Image from 'next/image'; 6 | import { systemDefault } from './lib/theme'; 7 | 8 | export default function Page() { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 |
18 |

21 | Welcome to Acme. This is the example for the{' '} 22 | 23 | Next.js Learn Course 24 | 25 | , brought to you by Vercel. 26 |

27 | 31 | Log in 32 | 33 |
34 |
35 | 42 | 49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/reset-password/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import ResetPasswordForm from '@/app/ui/reset-password-form'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Reset password', 7 | }; 8 | 9 | export default function LoginPage({ params }: { params: { token: string } }) { 10 | const token = params.token; 11 | 12 | return ( 13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | ); 24 | } -------------------------------------------------------------------------------- /app/ui/acme-logo.tsx: -------------------------------------------------------------------------------- 1 | import { GlobeAltIcon } from '@heroicons/react/24/outline'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | 4 | export default function AcmeLogo() { 5 | return ( 6 |
9 | 10 |

Acme

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function Button({ children, className, ...rest }: ButtonProps) { 8 | return ( 9 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/ui/create-account-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { 5 | UserIcon, 6 | AtSymbolIcon, 7 | KeyIcon, 8 | ExclamationCircleIcon, 9 | } from '@heroicons/react/24/outline'; 10 | import { ArrowRightIcon, ArrowLeftIcon } from '@heroicons/react/20/solid'; 11 | import { Button } from '@/app/ui/button'; 12 | import { useFormState } from 'react-dom'; 13 | import { createUserWithCredentials } from '@/app/lib/actions'; 14 | import { systemDefault } from '../lib/theme'; 15 | import { useRouter } from 'next/navigation'; 16 | 17 | export default function LoginForm() { 18 | const initialState = { message: null, errors: {} }; 19 | const [state, dispatch] = useFormState(createUserWithCredentials, initialState); 20 | 21 | return ( 22 |
25 |

26 | Fill in the blanks to create a new account 27 |

28 | 29 |
30 |
31 |
63 |
64 | 70 |
71 | 82 | 86 |
87 |
88 | {state.errors?.email && 89 | state.errors.email.map((error: string) => ( 90 |

91 | {error} 92 |

93 | ))} 94 |
95 |
96 |
97 | 103 |

104 | The password must have at least 8 characters, 105 | one special character, one upper case letter and one lower case letter. 106 |

107 |
108 | 120 | 124 |
125 |
126 | {state.errors?.password && 127 | state.errors.password.map((error: string) => ( 128 |

129 | {error} 130 |

131 | ))} 132 |
133 |
134 |
135 | 136 |
137 | 142 |
143 |
144 | 155 | 159 |
160 |
161 |
162 | 163 | {state.message && ( 164 |
172 | 176 |

{state.message}

177 |
178 | )} 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |
187 | ); 188 | } 189 | 190 | function CreateAccountButton() { 191 | return ( 192 | 195 | ) 196 | } 197 | 198 | function ReturnToLoginPageButton() { 199 | const { replace } = useRouter(); 200 | 201 | return ( 202 | 207 | ) 208 | } -------------------------------------------------------------------------------- /app/ui/customers/create-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField } from '@/app/lib/definitions'; 4 | import Link from 'next/link'; 5 | import { 6 | AtSymbolIcon, 7 | UserCircleIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import { Button } from '@/app/ui/button'; 10 | import { createCustomer } from '@/app/lib/actions'; 11 | import { useFormState } from 'react-dom'; 12 | import { themeType } from '@/app/lib/theme'; 13 | 14 | export default function Form({ 15 | userEmail, 16 | theme 17 | } : { 18 | userEmail: string; 19 | theme: themeType; 20 | }) { 21 | 22 | const initialState = { message: null, errors: {} }; 23 | const [state, dispatch] = useFormState(createCustomer, initialState); 24 | 25 | return ( 26 |
27 | 28 | 29 |
30 |
31 | 36 |
37 | 48 | 51 |
52 |
53 | {state.errors?.name && 54 | state.errors.name.map((error: string) => ( 55 |

56 | {error} 57 |

58 | ))} 59 |
60 |
61 | 62 | {/* Invoice Amount */} 63 |
64 | 69 |
70 |
71 | 82 | 86 |
87 |
88 | {state.errors?.email && 89 | state.errors.email.map((error: string) => ( 90 |

91 | {error} 92 |

93 | ))} 94 |
95 |
96 |
97 | 98 | {state.message && ( 99 |

100 | {state.message} 101 |

102 | )} 103 |
104 |
105 | 114 | Cancel 115 | 116 | 117 |
118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/ui/customers/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | AtSymbolIcon, 5 | UserCircleIcon 6 | } from '@heroicons/react/24/outline'; 7 | import Link from 'next/link'; 8 | import { Button } from '@/app/ui/button'; 9 | import { updateCustomer } from '@/app/lib/actions'; 10 | import { useFormState } from 'react-dom'; 11 | import { Customer } from '@/app/lib/definitions'; 12 | import { themeType } from '@/app/lib/theme'; 13 | 14 | export default function EditInvoiceForm({ 15 | customer, 16 | userEmail, 17 | theme 18 | }: { 19 | customer: Customer; 20 | userEmail: string; 21 | theme: themeType 22 | }) { 23 | const updateCustomerWithId = updateCustomer.bind(null, customer.id); 24 | const initialState = { message: null, errors: {} }; 25 | const [state, dispatch] = useFormState(updateCustomerWithId, initialState); 26 | 27 | return ( 28 |
29 | 30 | 31 |
32 |
33 | 38 |
39 | 51 | 54 |
55 |
56 | {state?.errors?.name && 57 | state.errors.name.map((error: string) => ( 58 |

59 | {error} 60 |

61 | ))} 62 |
63 |
64 | 65 | {/* Invoice Amount */} 66 |
67 | 72 |
73 |
74 | 86 | 90 |
91 |
92 | {state?.errors?.email && 93 | state.errors.email.map((error: string) => ( 94 |

95 | {error} 96 |

97 | ))} 98 |
99 |
100 |
101 | 102 | {state?.message && ( 103 |

104 | {state.message} 105 |

106 | )} 107 |
108 |
109 | 118 | Cancel 119 | 120 | 121 |
122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /app/ui/customers/pagination.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; 4 | import clsx from 'clsx'; 5 | import Link from 'next/link'; 6 | import { generatePagination } from '@/app/lib/utils'; 7 | import { usePathname, useSearchParams } from 'next/navigation'; 8 | import { themeType } from '@/app/lib/theme'; 9 | 10 | export default function Pagination({ 11 | totalPages, 12 | theme 13 | }: 14 | { 15 | totalPages: number; 16 | theme: themeType; 17 | }) { 18 | const pathname = usePathname(); 19 | const searchParams = useSearchParams(); 20 | const currentPage = Number(searchParams.get('page')) || 1; 21 | 22 | const createPageURL = (pageNumber: number | string) => { 23 | const params = new URLSearchParams(searchParams); 24 | params.set('page', pageNumber.toString()); 25 | return `${pathname}?${params.toString()}`; 26 | }; 27 | 28 | // NOTE: comment in this code when you get to this point in the course 29 | 30 | const allPages = generatePagination(currentPage, totalPages); 31 | 32 | return ( 33 | <> 34 | {/* NOTE: comment in this code when you get to this point in the course */} 35 | 36 |
37 | 43 | 44 |
45 | {allPages.map((page, index) => { 46 | let position: 'first' | 'last' | 'single' | 'middle' | undefined; 47 | 48 | if (index === 0) position = 'first'; 49 | if (index === allPages.length - 1) position = 'last'; 50 | if (allPages.length === 1) position = 'single'; 51 | if (page === '...') position = 'middle'; 52 | 53 | return ( 54 | 62 | ); 63 | })} 64 |
65 | 66 | = totalPages} 70 | theme={theme} 71 | /> 72 |
73 | 74 | ); 75 | } 76 | 77 | function PaginationNumber({ 78 | page, 79 | href, 80 | isActive, 81 | position, 82 | theme 83 | }: { 84 | page: number | string; 85 | href: string; 86 | position?: 'first' | 'last' | 'middle' | 'single'; 87 | isActive: boolean; 88 | theme: themeType; 89 | }) { 90 | const className = clsx( 91 | `flex h-10 w-10 items-center justify-center text-sm border 92 | ${theme.border} ${theme.text} 93 | ${(!isActive && position !== 'middle') && 94 | `${theme.hoverBorder} ${theme.hoverBg} ${theme.hoverText}` 95 | } 96 | `, 97 | { 98 | 'rounded-l-md': position === 'first' || position === 'single', 99 | 'rounded-r-md': position === 'last' || position === 'single', 100 | 'z-10 bg-blue-600 border-blue-600 text-white': isActive, 101 | 'text-gray-300': position === 'middle', 102 | }, 103 | ); 104 | 105 | return isActive || position === 'middle' ? ( 106 |
{page}
107 | ) : ( 108 | 109 | {page} 110 | 111 | ); 112 | } 113 | 114 | function PaginationArrow({ 115 | href, 116 | direction, 117 | isDisabled, 118 | theme 119 | }: { 120 | href: string; 121 | direction: 'left' | 'right'; 122 | isDisabled?: boolean; 123 | theme: themeType; 124 | }) { 125 | const className = clsx( 126 | `flex h-10 w-10 items-center justify-center rounded-md border 127 | ${theme.border} ${theme.text} 128 | ${isDisabled && `${theme.border} ${theme.notActiveText}`} 129 | ${!isDisabled && `${theme.hoverBorder} ${theme.hoverBg} ${theme.hoverText}`} 130 | `, 131 | { 132 | 'pointer-events-none text-gray-300': isDisabled, 133 | 'mr-2 md:mr-4': direction === 'left', 134 | 'ml-2 md:ml-4': direction === 'right', 135 | }, 136 | ); 137 | 138 | const icon = 139 | direction === 'left' ? ( 140 | 141 | ) : ( 142 | 143 | ); 144 | 145 | return isDisabled ? ( 146 |
{icon}
147 | ) : ( 148 | 149 | {icon} 150 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /app/ui/customers/table.tsx: -------------------------------------------------------------------------------- 1 | import Search from '@/app/ui/search'; 2 | import { fetchFilteredCustomers } from '@/app/lib/data'; 3 | import { CreateCustomer, DeleteCustomer, UpdateCustomer } from '../invoices/buttons'; 4 | import { auth } from '@/auth'; 5 | import { themeType } from '@/app/lib/theme'; 6 | 7 | export default async function CustomersTable({ 8 | query, 9 | currentPage, 10 | theme 11 | }: { 12 | query: string; 13 | currentPage: number; 14 | theme: themeType; 15 | }) { 16 | const session = await auth(); 17 | const userEmail = session?.user!.email!; 18 | const customers = await fetchFilteredCustomers(query, currentPage, userEmail); 19 | 20 | return ( 21 |
22 |
23 | 24 | 25 |
26 | 27 |
28 |
29 |
30 |
34 |
35 | {customers?.map((customer) => ( 36 |
41 |
42 |
43 |
44 |
45 |

{customer.name}

46 |
47 |
48 |

49 | {customer.email} 50 |

51 |
52 |
53 |
54 |
55 |

Pending

56 |

{customer.total_pending}

57 |
58 |
59 |

Paid

60 |

{customer.total_paid}

61 |
62 |
63 |
64 |

{customer.total_invoices} invoices

65 |
66 |
67 | 68 | 69 |
70 |
71 | ))} 72 |
73 |
76 | 80 | 81 | 84 | 87 | 90 | 93 | 96 | 97 | 98 | 99 | 103 | {customers.map((customer) => ( 104 | 105 | 113 | 118 | 123 | 128 | 133 | 139 | 140 | ))} 141 | 142 | 143 |
144 | 145 | 146 | 147 | 148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /app/ui/dashboard/cards.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BanknotesIcon, 3 | ClockIcon, 4 | UserGroupIcon, 5 | InboxIcon, 6 | } from '@heroicons/react/24/outline'; 7 | import { lusitana } from '@/app/ui/fonts'; 8 | import { fetchCardData } from '@/app/lib/data'; 9 | import { auth } from '@/auth'; 10 | import { themeType } from '@/app/lib/theme'; 11 | 12 | const iconMap = { 13 | collected: BanknotesIcon, 14 | customers: UserGroupIcon, 15 | pending: ClockIcon, 16 | invoices: InboxIcon, 17 | }; 18 | 19 | export default async function CardWrapper({theme}:{theme: themeType}) { 20 | const session = await auth(); 21 | const userEmail = session?.user?.email!; 22 | 23 | const { 24 | numberOfInvoices, 25 | numberOfCustomers, 26 | totalPaidInvoices, 27 | totalPendingInvoices, 28 | } = await fetchCardData(userEmail); 29 | 30 | return ( 31 | <> 32 | {/* NOTE: comment in this code when you get to this point in the course */} 33 | 34 | 35 | 36 | 37 | 43 | 44 | ); 45 | } 46 | 47 | export function Card({ 48 | title, 49 | value, 50 | type, 51 | theme 52 | }: { 53 | title: string; 54 | value: number | string; 55 | type: 'invoices' | 'customers' | 'pending' | 'collected'; 56 | theme: themeType 57 | }) { 58 | const Icon = iconMap[type]; 59 | 60 | return ( 61 |
62 |
63 | {Icon ? : null} 64 |

{title}

65 |
66 |

69 | {value} 70 |

71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /app/ui/dashboard/latest-invoices.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowPathIcon } from '@heroicons/react/24/outline'; 2 | import clsx from 'clsx'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { fetchLatestInvoices } from '@/app/lib/data'; 5 | import { auth } from '@/auth'; 6 | import { themeType } from '@/app/lib/theme'; 7 | 8 | export default async function LatestInvoices({theme}:{theme: themeType}) { 9 | const session = await auth(); 10 | const userEmail = session?.user?.email!; 11 | 12 | const latestInvoices = await fetchLatestInvoices(userEmail); 13 | 14 | return ( 15 |
16 |

17 | Latest Invoices 18 |

19 |
22 | {/* NOTE: comment in this code when you get to this point in the course */} 23 | 24 |
25 | {latestInvoices.map((invoice, i) => { 26 | return ( 27 |
38 |
39 |
40 |

41 | {invoice.name} 42 |

43 |

44 | {invoice.email} 45 |

46 |
47 |
48 |

52 | {invoice.amount} 53 |

54 |
55 | ); 56 | })} 57 |
58 |
59 | 60 |

Updated just now

61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | UserGroupIcon, 5 | HomeIcon, 6 | DocumentDuplicateIcon, 7 | UserIcon, 8 | Cog6ToothIcon, 9 | 10 | } from '@heroicons/react/24/outline'; 11 | import Link from 'next/link'; 12 | import { usePathname } from 'next/navigation'; 13 | import clsx from 'clsx'; 14 | import { themeType } from '@/app/lib/theme'; 15 | 16 | // Map of links to display in the side navigation. 17 | // Depending on the size of the application, this would be stored in a database. 18 | const links = [ 19 | { name: 'Dashboard', href: '/dashboard', icon: HomeIcon }, 20 | { 21 | name: 'Invoices', 22 | href: '/dashboard/invoices', 23 | icon: DocumentDuplicateIcon, 24 | }, 25 | { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon }, 26 | { name: 'My Account', href: '/dashboard/user-profile', icon: UserIcon }, 27 | { name: 'Settings', href: '/dashboard/settings', icon: Cog6ToothIcon } 28 | ]; 29 | 30 | export default function NavLinks({theme}: {theme: themeType}) { 31 | const pathname = usePathname(); 32 | 33 | return ( 34 | <> 35 | {links.map((link) => { 36 | const LinkIcon = link.icon; 37 | return ( 38 | 50 | 51 |

{link.name}

52 | 53 | ); 54 | })} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/ui/dashboard/revenue-chart.tsx: -------------------------------------------------------------------------------- 1 | import { generateYAxis } from '@/app/lib/utils'; 2 | import { CalendarIcon } from '@heroicons/react/24/outline'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { Revenue } from '@/app/lib/definitions'; 5 | import { fetchRevenue } from '@/app/lib/data'; 6 | import { themeType } from '@/app/lib/theme'; 7 | 8 | // This component is representational only. 9 | // For data visualization UI, check out: 10 | // https://www.tremor.so/ 11 | // https://www.chartjs.org/ 12 | // https://airbnb.io/visx/ 13 | 14 | export default async function RevenueChart({theme}:{theme:themeType}) { 15 | const revenue: Revenue[] = await fetchRevenue(); 16 | for (let i in revenue) { 17 | revenue[i].revenue = revenue[i].revenue / 100; 18 | } 19 | 20 | const chartHeight = 350; 21 | // NOTE: comment in this code when you get to this point in the course 22 | 23 | const { yAxisLabels, topLabel } = generateYAxis(revenue); 24 | 25 | if (!revenue || revenue.length === 0) { 26 | return

No data available.

; 27 | } 28 | 29 | return ( 30 |
31 |

34 | Recent Revenue 35 |

36 | {/* NOTE: comment in this code when you get to this point in the course */} 37 | 38 |
39 |
42 |
46 | {yAxisLabels.map((label) => ( 47 |

{label}

48 | ))} 49 |
50 | 51 | {revenue.map((month) => ( 52 |
53 |
60 |

61 | {month.month} 62 |

63 |
64 | ))} 65 |
66 |
67 | 68 |

From the current year

69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /app/ui/dashboard/sidenav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import NavLinks from '@/app/ui/dashboard/nav-links'; 3 | import AcmeLogo from '@/app/ui/acme-logo'; 4 | import { PowerIcon } from '@heroicons/react/24/outline'; 5 | import { signOut } from '@/auth'; 6 | import { themeType } from '@/app/lib/theme'; 7 | 8 | export default function SideNav({ theme }: {theme: themeType }) { 9 | return ( 10 |
11 | 15 |
16 | 17 |
18 | 19 |
20 | 21 |
22 |
23 |
{ 24 | 'use server'; 25 | await signOut(); 26 | }}> 27 | 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter, Lusitana } from 'next/font/google'; 2 | 3 | export const inter = Inter({ subsets: ['latin'] }); 4 | 5 | export const lusitana = Lusitana({ 6 | weight: ['400', '700'], 7 | subsets: ['latin'], 8 | }); -------------------------------------------------------------------------------- /app/ui/forgot-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { 5 | AtSymbolIcon, 6 | ExclamationCircleIcon, 7 | } from '@heroicons/react/24/outline'; 8 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; 9 | import { Button } from '@/app/ui/button'; 10 | import { useFormState, useFormStatus } from 'react-dom'; 11 | import { forgotPassword } from '@/app/lib/actions'; 12 | import { systemDefault } from '../lib/theme'; 13 | import { useRouter } from 'next/navigation'; 14 | 15 | export default function LoginForm() { 16 | const [errorMessage, dispatch] = useFormState(forgotPassword, undefined); 17 | 18 | return ( 19 |
22 |

23 | Please provide your email address for password reset 24 |

25 |
26 |
27 |
28 | 34 |
35 | 46 | 50 |
51 |
52 |
53 | 54 | 55 | 56 | {errorMessage && ( 57 |
62 | 63 |

{errorMessage}

64 |
65 | )} 66 | 67 | 68 | 69 | 70 |
71 | ); 72 | } 73 | 74 | function ResetPassword() { 75 | const { pending } = useFormStatus(); 76 | 77 | return ( 78 | 81 | ); 82 | } 83 | 84 | function GoBack() { 85 | const { pending } = useFormStatus(); 86 | 87 | const { replace } = useRouter(); 88 | 89 | return ( 90 | 95 | ); 96 | } -------------------------------------------------------------------------------- /app/ui/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input[type='number'] { 6 | -moz-appearance: textfield; 7 | appearance: textfield; 8 | } 9 | 10 | input[type='number']::-webkit-inner-spin-button { 11 | -webkit-appearance: none; 12 | margin: 0; 13 | } 14 | 15 | input[type='number']::-webkit-outer-spin-button { 16 | -webkit-appearance: none; 17 | margin: 0; 18 | } 19 | -------------------------------------------------------------------------------- /app/ui/invoices/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import Link from 'next/link'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { themeType } from '@/app/lib/theme'; 5 | 6 | interface Breadcrumb { 7 | label: string; 8 | href: string; 9 | active?: boolean; 10 | } 11 | 12 | export default function Breadcrumbs({ 13 | breadcrumbs, 14 | theme 15 | }: { 16 | breadcrumbs: Breadcrumb[]; 17 | theme: themeType; 18 | }) { 19 | return ( 20 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/invoices/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; 2 | import Link from 'next/link'; 3 | import { deleteInvoice, deleteCustomer } from '@/app/lib/actions'; 4 | import { themeType } from '@/app/lib/theme'; 5 | 6 | export function CreateInvoice() { 7 | return ( 8 | 12 | Create Invoice{' '} 13 | 14 | 15 | ); 16 | } 17 | 18 | export function UpdateInvoice({ 19 | id, 20 | theme 21 | }: 22 | { 23 | id: string; 24 | theme: themeType 25 | }) { 26 | return ( 27 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export function DeleteInvoice({ 40 | id, 41 | theme 42 | }: 43 | { 44 | id: string; 45 | theme: themeType 46 | }) { 47 | const deleteInvoiceWithId = deleteInvoice.bind(null, id); 48 | 49 | return ( 50 |
51 | 58 |
59 | ); 60 | } 61 | 62 | export function CreateCustomer() { 63 | return ( 64 | 68 | Create Customer{' '} 69 | 70 | 71 | ); 72 | } 73 | 74 | export function UpdateCustomer({ 75 | id, 76 | theme 77 | }: 78 | { 79 | id: string; 80 | theme: themeType 81 | }) { 82 | return ( 83 | 90 | 91 | 92 | ); 93 | } 94 | 95 | export function DeleteCustomer({ 96 | id, 97 | theme 98 | }: 99 | { 100 | id: string; 101 | theme: themeType 102 | }) { 103 | const deleteCustomerWithId = deleteCustomer.bind(null, id); 104 | 105 | return ( 106 |
107 | 114 |
115 | ); 116 | } -------------------------------------------------------------------------------- /app/ui/invoices/create-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField } from '@/app/lib/definitions'; 4 | import Link from 'next/link'; 5 | import { 6 | CheckIcon, 7 | ClockIcon, 8 | CurrencyDollarIcon, 9 | UserCircleIcon, 10 | } from '@heroicons/react/24/outline'; 11 | import { Button } from '@/app/ui/button'; 12 | import { createInvoice } from '@/app/lib/actions'; 13 | import { useFormState } from 'react-dom'; 14 | import { themeType } from '@/app/lib/theme'; 15 | 16 | export default function Form({ 17 | customers, 18 | theme 19 | }: 20 | { 21 | customers: CustomerField[]; 22 | theme: themeType; 23 | }) { 24 | const initialState = { message: null, errors: {} }; 25 | const [state, dispatch] = useFormState(createInvoice, initialState); 26 | 27 | return ( 28 |
29 |
30 | {/* Customer Name */} 31 |
32 | 37 |
38 | 57 | 60 |
61 |
62 | {state.errors?.customerId && 63 | state.errors.customerId.map((error: string) => ( 64 |

65 | {error} 66 |

67 | ))} 68 |
69 |
70 | 71 | {/* Invoice Amount */} 72 |
73 | 78 |
79 |
80 | 92 | 96 |
97 |
98 | {state.errors?.amount && 99 | state.errors.amount.map((error: string) => ( 100 |

101 | {error} 102 |

103 | ))} 104 |
105 |
106 |
107 | 108 | {/* Invoice Status */} 109 |
110 | 111 | Set the invoice status 112 | 113 |
116 |
117 |
118 | 128 | 137 |
138 |
139 | 149 | 155 |
156 |
157 |
158 |
159 | {state.errors?.status && 160 | state.errors.status.map((error: string) => ( 161 |

162 | {error} 163 |

164 | ))} 165 |
166 |
167 | 168 | {state.message && ( 169 |

170 | {state.message} 171 |

172 | )} 173 |
174 |
175 | 184 | Cancel 185 | 186 | 187 |
188 |
189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /app/ui/invoices/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField, InvoiceForm } from '@/app/lib/definitions'; 4 | import { 5 | CheckIcon, 6 | ClockIcon, 7 | CurrencyDollarIcon, 8 | UserCircleIcon, 9 | } from '@heroicons/react/24/outline'; 10 | import Link from 'next/link'; 11 | import { Button } from '@/app/ui/button'; 12 | import { updateInvoice } from '@/app/lib/actions'; 13 | import { useFormState } from 'react-dom'; 14 | import { themeType } from '@/app/lib/theme'; 15 | 16 | export default function EditInvoiceForm({ 17 | invoice, 18 | customers, 19 | theme 20 | }: { 21 | invoice: InvoiceForm; 22 | customers: CustomerField[]; 23 | theme: themeType; 24 | }) { 25 | const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); 26 | const initialState = { message: null, errors: {} }; 27 | const [state, dispatch] = useFormState(updateInvoiceWithId, initialState); 28 | 29 | return ( 30 |
31 |
34 | {/* Customer Name */} 35 |
36 | 41 |
42 | 61 | 64 |
65 |
66 | {state?.errors?.customerId && 67 | state.errors.customerId.map((error: string) => ( 68 |

69 | {error} 70 |

71 | ))} 72 |
73 |
74 | 75 | {/* Invoice Amount */} 76 |
77 | 80 |
81 |
82 | 95 | 98 |
99 |
100 | {state?.errors?.amount && 101 | state.errors.amount.map((error: string) => ( 102 |

103 | {error} 104 |

105 | ))} 106 |
107 |
108 |
109 | 110 | {/* Invoice Status */} 111 |
112 | 113 | Set the invoice status 114 | 115 |
118 |
119 |
120 | 131 | 140 |
141 |
142 | 153 | 159 |
160 |
161 |
162 |
163 | {state?.errors?.status && 164 | state.errors.status.map((error: string) => ( 165 |

166 | {error} 167 |

168 | ))} 169 |
170 |
171 | 172 | {state?.message && ( 173 |

174 | {state.message} 175 |

176 | )} 177 |
178 |
179 | 188 | Cancel 189 | 190 | 191 |
192 |
193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /app/ui/invoices/pagination.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; 4 | import clsx from 'clsx'; 5 | import Link from 'next/link'; 6 | import { generatePagination } from '@/app/lib/utils'; 7 | import { usePathname, useSearchParams } from 'next/navigation'; 8 | import { themeType } from '@/app/lib/theme'; 9 | 10 | export default function Pagination({ 11 | totalPages, 12 | theme 13 | }: 14 | { 15 | totalPages: number; 16 | theme: themeType; 17 | }) { 18 | const pathname = usePathname(); 19 | const searchParams = useSearchParams(); 20 | const currentPage = Number(searchParams.get('page')) || 1; 21 | 22 | const createPageURL = (pageNumber: number | string) => { 23 | const params = new URLSearchParams(searchParams); 24 | params.set('page', pageNumber.toString()); 25 | return `${pathname}?${params.toString()}`; 26 | }; 27 | 28 | // NOTE: comment in this code when you get to this point in the course 29 | 30 | const allPages = generatePagination(currentPage, totalPages); 31 | 32 | return ( 33 | <> 34 | {/* NOTE: comment in this code when you get to this point in the course */} 35 | 36 |
37 | 43 | 44 |
45 | {allPages.map((page, index) => { 46 | let position: 'first' | 'last' | 'single' | 'middle' | undefined; 47 | 48 | if (index === 0) position = 'first'; 49 | if (index === allPages.length - 1) position = 'last'; 50 | if (allPages.length === 1) position = 'single'; 51 | if (page === '...') position = 'middle'; 52 | 53 | return ( 54 | 62 | ); 63 | })} 64 |
65 | 66 | = totalPages} 70 | theme={theme} 71 | /> 72 |
73 | 74 | ); 75 | } 76 | 77 | function PaginationNumber({ 78 | page, 79 | href, 80 | isActive, 81 | position, 82 | theme 83 | }: { 84 | page: number | string; 85 | href: string; 86 | position?: 'first' | 'last' | 'middle' | 'single'; 87 | isActive: boolean; 88 | theme: themeType; 89 | }) { 90 | const className = clsx( 91 | `flex h-10 w-10 items-center justify-center text-sm border 92 | ${theme.border} ${theme.text} 93 | ${(!isActive && position !== 'middle') && 94 | `${theme.hoverBorder} ${theme.hoverBg} ${theme.hoverText}` 95 | } 96 | `, 97 | { 98 | 'rounded-l-md': position === 'first' || position === 'single', 99 | 'rounded-r-md': position === 'last' || position === 'single', 100 | 'z-10 bg-blue-600 border-blue-600 text-white': isActive, 101 | 'text-gray-300': position === 'middle', 102 | }, 103 | ); 104 | 105 | return isActive || position === 'middle' ? ( 106 |
{page}
107 | ) : ( 108 | 109 | {page} 110 | 111 | ); 112 | } 113 | 114 | function PaginationArrow({ 115 | href, 116 | direction, 117 | isDisabled, 118 | theme 119 | }: { 120 | href: string; 121 | direction: 'left' | 'right'; 122 | isDisabled?: boolean; 123 | theme: themeType; 124 | }) { 125 | const className = clsx( 126 | `flex h-10 w-10 items-center justify-center rounded-md border 127 | ${theme.border} ${theme.text} 128 | ${isDisabled && `${theme.border} ${theme.notActiveText}`} 129 | ${!isDisabled && `${theme.hoverBorder} ${theme.hoverBg} ${theme.hoverText}`} 130 | `, 131 | { 132 | 'pointer-events-none': isDisabled, 133 | 'mr-2 md:mr-4': direction === 'left', 134 | 'ml-2 md:ml-4': direction === 'right', 135 | }, 136 | ); 137 | 138 | const icon = 139 | direction === 'left' ? ( 140 | 141 | ) : ( 142 | 143 | ); 144 | 145 | return isDisabled ? ( 146 |
{icon}
147 | ) : ( 148 | 149 | {icon} 150 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /app/ui/invoices/status.tsx: -------------------------------------------------------------------------------- 1 | import { themeType } from '@/app/lib/theme'; 2 | import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline'; 3 | import clsx from 'clsx'; 4 | 5 | export default function InvoiceStatus({ 6 | status, 7 | theme 8 | }: 9 | { 10 | status: string; 11 | theme: themeType; 12 | }) { 13 | return ( 14 | 25 | {status === 'pending' ? ( 26 | <> 27 | Pending 28 | 29 | 30 | ) : null} 31 | {status === 'paid' ? ( 32 | <> 33 | Paid 34 | 35 | 36 | ) : null} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/invoices/table.tsx: -------------------------------------------------------------------------------- 1 | import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons'; 2 | import InvoiceStatus from '@/app/ui/invoices/status'; 3 | import { formatDateToLocal, formatCurrency } from '@/app/lib/utils'; 4 | import { fetchFilteredInvoices, getUser } from '@/app/lib/data'; 5 | import { auth } from '@/auth'; 6 | import { themeType } from '@/app/lib/theme'; 7 | 8 | export default async function InvoicesTable({ 9 | query, 10 | currentPage, 11 | theme 12 | }: { 13 | query: string; 14 | currentPage: number; 15 | theme: themeType; 16 | }) { 17 | const session = await auth(); 18 | const userEmail = session?.user!.email!; 19 | 20 | const invoices = await fetchFilteredInvoices(query, currentPage, userEmail); 21 | 22 | return ( 23 |
24 |
25 |
26 |
27 | {invoices?.map((invoice) => ( 28 |
34 |
35 |
36 |
37 |

{invoice.name}

38 |
39 |

{invoice.email}

40 |
41 | 42 |
43 |
44 |
45 |

46 | {formatCurrency(invoice.amount)} 47 |

48 |

{formatDateToLocal(invoice.date)}

49 |
50 |
51 | 52 | 53 |
54 |
55 |
56 | ))} 57 |
58 | 59 | 60 | 61 | 64 | 67 | 70 | 73 | 76 | 79 | 80 | 81 | 82 | {invoices?.map((invoice) => ( 83 | td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg 87 | [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg 88 | ${theme.border} 89 | `} 90 | > 91 | 96 | 99 | 102 | 105 | 108 | 114 | 115 | ))} 116 | 117 |
62 | Customer 63 | 65 | Email 66 | 68 | Amount 69 | 71 | Date 72 | 74 | Status 75 | 77 | Edit 78 |
92 |
93 |

{invoice.name}

94 |
95 |
97 | {invoice.email} 98 | 100 | {formatCurrency(invoice.amount)} 101 | 103 | {formatDateToLocal(invoice.date)} 104 | 106 | 107 | 109 |
110 | 111 | 112 |
113 |
118 |
119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /app/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { 5 | AtSymbolIcon, 6 | KeyIcon, 7 | ExclamationCircleIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import { ArrowRightIcon } from '@heroicons/react/20/solid'; 10 | import { Button } from '@/app/ui/button'; 11 | import Image from 'next/image'; 12 | import { useFormState, useFormStatus } from 'react-dom'; 13 | import { authenticateWithCredentials } from '@/app/lib/actions'; 14 | import { systemDefault } from '../lib/theme'; 15 | import { authenticateWithOAuth } from '@/app/lib/actions'; 16 | import { useRouter } from 'next/navigation'; 17 | import { useSearchParams } from 'next/navigation'; 18 | import { ToastContainer, toast } from 'react-toastify'; 19 | import 'react-toastify/dist/ReactToastify.css'; 20 | import { useEffect } from 'react'; 21 | 22 | const GitHubSignIn = authenticateWithOAuth.bind(null, 'github'); 23 | // const GoogleSignIn = authenticateWithOAuth.bind(null, 'google'); 24 | function GoogleSignIn() { 25 | toast.error( 26 | <> 27 | This login option does not work due to Google's privacy protection rules.
28 |
29 | As this is a test project, I cannot provide all the necessary bureaucracy. 30 | 31 | , { 32 | autoClose: 15000 33 | }); 34 | } 35 | 36 | export default function LoginForm() { 37 | const [errorMessage, dispatch] = useFormState(authenticateWithCredentials, undefined); 38 | 39 | const searchParams = useSearchParams(); 40 | const params = { 41 | accountCreated: searchParams.get('account-created'), 42 | passwordUpdated: searchParams.get('password-updated') 43 | }; 44 | 45 | useEffect(() => { 46 | if (params.accountCreated) { 47 | toast.success("Account created successfully!!"); 48 | } 49 | if (params.passwordUpdated) { 50 | toast.success("Password updated successfully!!"); 51 | } 52 | if (!params.accountCreated && !params.passwordUpdated) { 53 | toast.warning(<>Note: accounts are now automatically deleted after one week.); 54 | } 55 | }, []); 56 | 57 | return ( 58 |
61 | 62 |

63 | Please log in to continue. 64 |

65 |
66 |
67 |
68 | 74 |
75 | 86 | 90 |
91 |
92 |
93 | 99 |
100 | 112 | 116 |
117 |
118 |
119 | 120 | 121 | 122 | {errorMessage && ( 123 |
128 | 129 |

{errorMessage}

130 |
131 | )} 132 | 133 | 134 | 135 | 136 | 137 | 138 |

141 | or 142 |

143 | 144 | 145 | 146 |
147 | ); 148 | } 149 | 150 | function LoginButton() { 151 | const { pending } = useFormStatus(); 152 | 153 | return ( 154 | 157 | ); 158 | } 159 | 160 | function CreateAccount() { 161 | const { pending } = useFormStatus(); 162 | 163 | const { replace } = useRouter(); 164 | 165 | return ( 166 | 171 | ); 172 | } 173 | 174 | function ForgotPassword() { 175 | const { pending } = useFormStatus(); 176 | 177 | const { replace } = useRouter(); 178 | 179 | return ( 180 | 185 | ); 186 | } 187 | 188 | function GitHubSignInButton() { 189 | return ( 190 |
191 | 206 |
207 | ) 208 | } 209 | 210 | function GoogleSignInButton() { 211 | return ( 212 |
213 | 229 |
230 | ) 231 | } -------------------------------------------------------------------------------- /app/ui/reset-password-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | KeyIcon 5 | } from '@heroicons/react/24/outline'; 6 | import { Button } from '@/app/ui/button'; 7 | import { resetPassword } from '@/app/lib/actions'; 8 | import { useFormState, useFormStatus } from 'react-dom'; 9 | import { systemDefault } from '@/app/lib/theme'; 10 | import { ArrowLeftIcon } from '@heroicons/react/20/solid'; 11 | import { useRouter } from 'next/navigation'; 12 | 13 | export default function Form({token} : 14 | { token: string} 15 | ) { 16 | 17 | const resetPasswordWithToken = resetPassword.bind(null, token); 18 | const [errorMessage, dispatch] = useFormState(resetPasswordWithToken, undefined); 19 | 20 | return ( 21 |
22 |
23 |
24 | 29 |

32 | The password must have at least 8 characters, 33 | one special character, one upper case letter and one lower case letter. 34 |

35 |
36 |
37 | 48 | 52 |
53 |
54 |
55 | 56 |
57 | 62 |
63 |
64 | 75 | 79 |
80 |
81 |
82 | 83 | {errorMessage && ( 84 |

85 | {errorMessage} 86 |

87 | )} 88 | 89 | 90 | 91 |
92 |
93 | ); 94 | } 95 | 96 | function GoBack() { 97 | const { pending } = useFormStatus(); 98 | 99 | const { replace } = useRouter(); 100 | 101 | return ( 102 | 107 | ); 108 | } -------------------------------------------------------------------------------- /app/ui/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 4 | import { useSearchParams, usePathname, useRouter } from 'next/navigation'; 5 | import { useDebouncedCallback } from 'use-debounce'; 6 | import { themeType } from '../lib/theme'; 7 | 8 | export default function Search( 9 | { 10 | placeholder, 11 | theme 12 | }: 13 | { 14 | placeholder: string; 15 | theme: themeType 16 | }) { 17 | const searchParams = useSearchParams(); 18 | const pathname = usePathname(); 19 | const { replace } = useRouter(); 20 | 21 | const handleSearch = useDebouncedCallback((term) => { 22 | const params = new URLSearchParams(searchParams); 23 | if (term) { 24 | params.set('query', term); 25 | } else { 26 | params.delete('query'); 27 | } 28 | replace(`${pathname}?${params.toString()}`); 29 | }, 300); 30 | 31 | return ( 32 |
33 | 36 | { 43 | handleSearch(e.target.value); 44 | }} 45 | defaultValue={searchParams.get('query')?.toString()} 46 | /> 47 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/ui/settings/settings-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | MoonIcon, 5 | SunIcon, 6 | } from '@heroicons/react/24/outline'; 7 | import 'react-toastify/dist/ReactToastify.css'; 8 | import { updateTheme } from '@/app/lib/actions'; 9 | import { Button } from '../button'; 10 | import { User } from '@/app/lib/definitions'; 11 | import { themeType } from '@/app/lib/theme'; 12 | 13 | export default function Form({ 14 | user, 15 | theme 16 | } : 17 | { 18 | user: User; 19 | theme: themeType; 20 | }) { 21 | 22 | return ( 23 |
24 | 25 |
26 |
27 | 32 |
33 | 53 | { 54 | (!user.theme || user.theme == 'system' ) ? 55 | <> 56 | 59 | 62 | : 63 | (user.theme == 'dark') ? 64 | <> 65 | 68 | : 69 | <> 70 | 73 | 74 | } 75 |
76 |
77 |
78 | 79 |
80 | 81 |
82 |
83 | ); 84 | } -------------------------------------------------------------------------------- /app/ui/skeletons.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { themeType } from "../lib/theme"; 4 | 5 | // Loading animation 6 | const shimmer = 7 | 'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent'; 8 | 9 | export function CardSkeleton({ theme }:{ theme: themeType }) { 10 | return ( 11 |
16 |
17 |
18 |
19 |
20 |
23 |
24 |
25 |
26 | ); 27 | } 28 | 29 | export function CardsSkeleton({ theme }:{ theme: themeType }) { 30 | return ( 31 | <> 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | export function RevenueChartSkeleton({ theme }:{ theme: themeType }) { 41 | return ( 42 |
43 |
44 |
45 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | 57 | export function InvoiceSkeleton({ theme }:{ theme: themeType }) { 58 | return ( 59 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | ); 71 | } 72 | 73 | export function LatestInvoicesSkeleton({ theme }:{ theme: themeType }) { 74 | return ( 75 |
78 |
79 |
82 |
83 | 84 | 85 | 86 | 87 | 88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | ); 96 | } 97 | 98 | export default function DashboardSkeleton({ theme }:{ theme: themeType }) { 99 | return ( 100 | <> 101 |
104 |
105 | 106 | 107 | 108 | 109 |
110 |
111 | 112 | 113 |
114 | 115 | ); 116 | } 117 | 118 | export function TableRowSkeleton({ theme }:{ theme: themeType }) { 119 | return ( 120 | td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg 122 | [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg 123 | ${theme.bg} ${theme.border} 124 | `}> 125 | {/* Customer Name and Image */} 126 | 127 |
128 |
129 |
130 | 131 | {/* Email */} 132 | 133 |
134 | 135 | {/* Amount */} 136 | 137 |
138 | 139 | {/* Date */} 140 | 141 |
142 | 143 | {/* Status */} 144 | 145 |
146 | 147 | {/* Actions */} 148 | 149 |
150 |
151 |
152 |
153 | 154 | 155 | ); 156 | } 157 | 158 | export function InvoicesMobileSkeleton({ theme }:{ theme: themeType }) { 159 | return ( 160 |
161 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | ); 182 | } 183 | 184 | export function InvoicesTableSkeleton({ theme }:{ theme: themeType }) { 185 | return ( 186 |
187 |
188 |
189 |
190 | 191 | 192 | 193 | 194 | 195 | 196 |
197 | 198 | 199 | 200 | 203 | 206 | 209 | 212 | 215 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 |
201 | Customer 202 | 204 | Email 205 | 207 | Amount 208 | 210 | Date 211 | 213 | Status 214 | 219 | Edit 220 |
232 |
233 |
234 |
235 | ); 236 | } 237 | -------------------------------------------------------------------------------- /app/ui/user-profile/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | KeyIcon, 5 | UserCircleIcon, 6 | } from '@heroicons/react/24/outline'; 7 | import { Button } from '@/app/ui/button'; 8 | import { updateUser } from '@/app/lib/actions'; 9 | import { useFormState } from 'react-dom'; 10 | import { User } from '@/app/lib/definitions'; 11 | import { useSearchParams } from 'next/navigation'; 12 | import { ToastContainer, toast } from 'react-toastify'; 13 | import 'react-toastify/dist/ReactToastify.css'; 14 | import { useEffect } from 'react'; 15 | import { themeType } from '@/app/lib/theme'; 16 | 17 | export default function Form({ 18 | user, 19 | theme 20 | } : { 21 | user: User; 22 | theme: themeType; 23 | }) { 24 | 25 | const initialState = { message: null, errors: {} }; 26 | const [state, dispatch] = useFormState(updateUser, initialState); 27 | 28 | const searchParams = useSearchParams(); 29 | const updatedUser = searchParams.get('user-updated'); 30 | 31 | useEffect(() => { 32 | if (updatedUser) { 33 | toast.success("User updated successfully!!"); 34 | } 35 | }); 36 | 37 | return ( 38 |
39 | 40 | 41 | 42 |
43 |
44 | { user.password === null ? ( 45 |

48 | To allow you to login with your credentials (email and password), 49 |
50 | in addition to your 51 | OAuth provider, you just need to define a password. 52 |

53 | ) : ( 54 |

57 | You're already able to login with your credentials (Login and Password).

58 | 59 | If your first login was through GitHub or Google and you don't now
60 | what's your credential email, it is the same as the OAuth provider
61 | (GitHub or Google) account you used to login.

62 | 63 | You can also change your password whenever you want on this page. 64 |

65 | )} 66 |
67 | 68 |
69 | 74 |
75 | 87 | 90 |
91 | 92 |
93 | {state?.errors?.name && 94 | state.errors.name.map((error: string) => ( 95 |

96 | {error} 97 |

98 | ))} 99 |
100 |
101 | 102 |
103 | 109 |

110 | The password must have at least 8 characters,
111 | one special character, one upper case letter and one lower case letter. 112 |

113 |
114 |
115 | 126 | 129 |
130 | 131 |
132 | {state?.errors?.password && 133 | state.errors.password.map((error: string) => ( 134 |

135 | {error} 136 |

137 | ))} 138 |
139 |
140 |
141 | 142 |
143 | 148 |
149 |
150 | 161 | 164 |
165 | 166 |
167 | {state?.errors?.confirmPassword && 168 | state.errors.confirmPassword.map((error: string) => ( 169 |

170 | {error} 171 |

172 | ))} 173 |
174 |
175 |
176 | 177 | {state?.message && ( 178 |

179 | {state.message} 180 |

181 | )} 182 |
183 | 184 |
185 | 186 |
187 | 188 | ); 189 | } -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from 'next-auth'; 2 | 3 | export const authConfig = { 4 | pages: { 5 | signIn: '/login', 6 | }, 7 | callbacks: { 8 | authorized({ auth, request: { nextUrl }}) { 9 | const isLoggedIn = !!auth?.user; 10 | const isOnDashboard = nextUrl.pathname.startsWith('/dashboard'); 11 | if (isOnDashboard) { 12 | if (isLoggedIn) return true; 13 | return false; // Redirect unauthenticated users to login page 14 | } else if (isLoggedIn) { 15 | return Response.redirect(new URL('/api/test-if-user-already-exists', nextUrl)); 16 | } 17 | return true; 18 | } 19 | }, 20 | providers: [], // Add providers with an empty array for now 21 | } satisfies NextAuthConfig; -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authConfig } from './auth.config'; 3 | import Credentials from 'next-auth/providers/credentials'; 4 | import GitHubProvider from 'next-auth/providers/github'; 5 | import GoogleProvider from 'next-auth/providers/google'; 6 | import { z } from 'zod'; 7 | import { sql } from '@vercel/postgres'; 8 | import type { User } from '@/app/lib/definitions'; 9 | import bcrypt from 'bcrypt'; 10 | 11 | async function getUser(email: string): Promise { 12 | try { 13 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 14 | if (user.rows[0].isoauth === true) { 15 | throw new Error('User tried to login using an OAuth account without defining a password first'); 16 | } 17 | 18 | return user.rows[0]; 19 | } catch (error) { 20 | console.log(error); 21 | throw new Error('Failed to fetch user.'); 22 | } 23 | } 24 | 25 | export const { handlers, auth, signIn, signOut } = NextAuth({ 26 | ...authConfig, 27 | providers: [ 28 | Credentials({ 29 | async authorize(credentials) { 30 | const parsedCredentials = z 31 | .object({ email: z.string().email(), password: z.string().min(6) }) 32 | .safeParse(credentials); 33 | 34 | if (parsedCredentials.success) { 35 | const { email, password } = parsedCredentials.data; 36 | const user = await getUser(email); 37 | if (!user) return null; 38 | const passwordsMatch = await bcrypt.compare(password, user.password); 39 | 40 | if (passwordsMatch) return user; 41 | } 42 | 43 | console.log('Invalid credentials'); 44 | return null; 45 | }, 46 | }), 47 | GitHubProvider({ 48 | clientId: process.env.GITHUB_ID as string, 49 | clientSecret: process.env.GITHUB_SECRET as string 50 | }), 51 | GoogleProvider({ 52 | clientId: process.env.GOOGLE_ID as string, 53 | clientSecret: process.env.GOOGLE_SECRET as string 54 | }) 55 | ], 56 | }); -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json . 6 | 7 | COPY . . 8 | 9 | RUN npm install 10 | 11 | RUN npm run build 12 | 13 | EXPOSE 3000 14 | 15 | CMD npm start -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authConfig } from './auth.config'; 3 | 4 | export default NextAuth(authConfig).auth; 5 | 6 | export const config = { 7 | // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher 8 | matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], 9 | }; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "next build", 5 | "dev": "next dev", 6 | "prettier": "prettier --write --ignore-unknown .", 7 | "prettier:check": "prettier --check --ignore-unknown .", 8 | "start": "next start", 9 | "seed": "node -r dotenv/config ./scripts/seed.js" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.0.18", 13 | "@tailwindcss/forms": "^0.5.7", 14 | "@types/node": "20.5.7", 15 | "@vercel/postgres": "^0.5.1", 16 | "autoprefixer": "10.4.15", 17 | "bcrypt": "^5.1.1", 18 | "clsx": "^2.0.0", 19 | "jsonwebtoken": "^9.0.2", 20 | "next": "^14.0.2", 21 | "next-auth": "^5.0.0-beta.4", 22 | "nodemailer": "^6.9.14", 23 | "postcss": "8.4.31", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-toastify": "^10.0.5", 27 | "tailwindcss": "3.3.3", 28 | "typescript": "5.2.2", 29 | "use-debounce": "^10.0.0", 30 | "zod": "^3.22.2" 31 | }, 32 | "devDependencies": { 33 | "@types/bcrypt": "^5.0.1", 34 | "@types/jsonwebtoken": "^9.0.6", 35 | "@types/nodemailer": "^6.4.15", 36 | "@types/react": "18.2.21", 37 | "@types/react-dom": "18.2.14", 38 | "@vercel/style-guide": "^5.0.1", 39 | "dotenv": "^16.3.1", 40 | "eslint": "^8.52.0", 41 | "eslint-config-next": "^14.0.0", 42 | "eslint-config-prettier": "9.0.0", 43 | "prettier": "^3.0.3", 44 | "prettier-plugin-tailwindcss": "0.5.4" 45 | }, 46 | "engines": { 47 | "node": ">=18.17.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | const styleguide = require('@vercel/style-guide/prettier'); 2 | 3 | module.exports = { 4 | ...styleguide, 5 | plugins: [...styleguide.plugins, 'prettier-plugin-tailwindcss'], 6 | }; 7 | -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josiasbudaydeveloper/nextjs-14-dashboard-app-router-tutorial/c4718b048f18a41bca11d000f68da923bdb563b6/public/hero-desktop.png -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josiasbudaydeveloper/nextjs-14-dashboard-app-router-tutorial/c4718b048f18a41bca11d000f68da923bdb563b6/public/hero-mobile.png -------------------------------------------------------------------------------- /public/oauth-logos/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/oauth-logos/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | gridTemplateColumns: { 12 | '13': 'repeat(13, minmax(0, 1fr))', 13 | }, 14 | colors: { 15 | blue: { 16 | 400: '#2589FE', 17 | 500: '#0070F3', 18 | 600: '#2F6FEB', 19 | }, 20 | }, 21 | }, 22 | keyframes: { 23 | shimmer: { 24 | '100%': { 25 | transform: 'translateX(100%)', 26 | }, 27 | }, 28 | }, 29 | }, 30 | plugins: [require('@tailwindcss/forms')], 31 | }; 32 | export default config; 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | "app/lib/placeholder-data.js", 31 | "scripts/seed.js" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | --------------------------------------------------------------------------------