├── .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 | 
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
82 | Name
83 | |
84 |
85 | Email
86 | |
87 |
88 | Total Invoices
89 | |
90 |
91 | Total Pending
92 | |
93 |
94 | Total Paid
95 | |
96 |
97 |
98 |
99 |
103 | {customers.map((customer) => (
104 |
105 |
109 |
110 | {customer.name}
111 |
112 | |
113 |
116 | {customer.email}
117 | |
118 |
121 | {customer.total_invoices}
122 | |
123 |
126 | {customer.total_pending}
127 | |
128 |
131 | {customer.total_paid}
132 | |
133 |
134 |
135 |
136 |
137 |
138 | |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
62 | Customer
63 | |
64 |
65 | Email
66 | |
67 |
68 | Amount
69 | |
70 |
71 | Date
72 | |
73 |
74 | Status
75 | |
76 |
77 | Edit
78 | |
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 |
92 |
95 | |
96 |
97 | {invoice.email}
98 | |
99 |
100 | {formatCurrency(invoice.amount)}
101 | |
102 |
103 | {formatDateToLocal(invoice.date)}
104 | |
105 |
106 |
107 | |
108 |
109 |
110 |
111 |
112 |
113 | |
114 |
115 | ))}
116 |
117 |
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 |
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 |
207 | )
208 | }
209 |
210 | function GoogleSignInButton() {
211 | return (
212 |
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 |
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 |
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 |
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 |
54 | );
55 | }
56 |
57 | export function InvoiceSkeleton({ theme }:{ theme: themeType }) {
58 | return (
59 |
70 | );
71 | }
72 |
73 | export function LatestInvoicesSkeleton({ theme }:{ theme: themeType }) {
74 | return (
75 |
78 |
79 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
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 |
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 |
153 | |
154 |
155 | );
156 | }
157 |
158 | export function InvoicesMobileSkeleton({ theme }:{ theme: themeType }) {
159 | return (
160 |
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 |
201 | Customer
202 | |
203 |
204 | Email
205 | |
206 |
207 | Amount
208 | |
209 |
210 | Date
211 | |
212 |
213 | Status
214 | |
215 |
219 | Edit
220 | |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
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 |
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 |
--------------------------------------------------------------------------------