├── public ├── favicon.ico ├── hero-desktop.png ├── hero-mobile.png ├── opengraph-image.png └── customers │ ├── amy-burns.png │ ├── balazs-orban.png │ ├── evil-rabbit.png │ ├── lee-robinson.png │ ├── michael-novotny.png │ └── delba-de-oliveira.png ├── app ├── dashboard │ ├── customers │ │ └── page.tsx │ ├── (overview) │ │ ├── loading.tsx │ │ └── page.tsx │ ├── layout.tsx │ └── invoices │ │ ├── create │ │ └── page.tsx │ │ ├── [id] │ │ └── edit │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ │ └── page.tsx ├── ui │ ├── home.module.css │ ├── fonts.ts │ ├── global.css │ ├── acme-logo.tsx │ ├── button.tsx │ ├── invoices │ │ ├── status.tsx │ │ ├── edit-form.tsx │ │ ├── error.tsx │ │ ├── breadcrumbs.tsx │ │ ├── buttons.tsx │ │ ├── pagination.tsx │ │ ├── create-form.tsx │ │ └── table.tsx │ ├── dashboard │ │ ├── sidenav.tsx │ │ ├── nav-links.tsx │ │ ├── cards.tsx │ │ ├── revenue-chart.tsx │ │ └── latest-invoices.tsx │ ├── search.tsx │ ├── login-form.tsx │ ├── customers │ │ └── table.tsx │ └── skeletons.tsx ├── login │ └── page.tsx ├── layout.tsx ├── auth.ts ├── page.tsx ├── lib │ ├── utils.ts │ ├── definitions.ts │ ├── placeholder-data.ts │ ├── actions.ts │ └── data.ts └── seed │ └── route.ts ├── postcss.config.js ├── .eslintrc.json ├── next.config.mjs ├── README.md ├── middleware.ts ├── .gitignore ├── auth.config.ts ├── tailwind.config.ts ├── tsconfig.json └── package.json /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/favicon.ico -------------------------------------------------------------------------------- /app/dashboard/customers/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Customers Page

; 3 | } 4 | -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/hero-desktop.png -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/hero-mobile.png -------------------------------------------------------------------------------- /public/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/opengraph-image.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/customers/amy-burns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/customers/amy-burns.png -------------------------------------------------------------------------------- /public/customers/balazs-orban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/customers/balazs-orban.png -------------------------------------------------------------------------------- /public/customers/evil-rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/customers/evil-rabbit.png -------------------------------------------------------------------------------- /public/customers/lee-robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/customers/lee-robinson.png -------------------------------------------------------------------------------- /public/customers/michael-novotny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/customers/michael-novotny.png -------------------------------------------------------------------------------- /public/customers/delba-de-oliveira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/nextjs-dashboard/main/public/customers/delba-de-oliveira.png -------------------------------------------------------------------------------- /app/dashboard/(overview)/loading.tsx: -------------------------------------------------------------------------------- 1 | import DashboardSkeleton from '@/app/ui/skeletons'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "next/typescript" 5 | ], 6 | "rules": { 7 | "@typescript-eslint/no-unused-vars": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/ui/home.module.css: -------------------------------------------------------------------------------- 1 | .shape { 2 | height: 0; 3 | width: 0; 4 | border-bottom: 30px solid black; 5 | border-left: 20px solid transparent; 6 | border-right: 20px solid transparent; 7 | } 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = { 4 | experimental: { 5 | ppr: 'incremental', 6 | }, 7 | }; 8 | 9 | export default nextConfig; 10 | -------------------------------------------------------------------------------- /app/ui/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google'; 2 | import { Lusitana } from 'next/font/google'; 3 | 4 | export const inter = Inter({ subsets: ['latin'] }); 5 | export const lusitana = Lusitana({ subsets: ['latin'], weight: '400' }); 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Next.js App Router Course - Starter 2 | 3 | This is the starter template for the Next.js App Router Course. It contains the starting code for the dashboard application. 4 | 5 | For more information, see the [course curriculum](https://nextjs.org/learn) on the Next.js Website. 6 | -------------------------------------------------------------------------------- /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 | }; 10 | -------------------------------------------------------------------------------- /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/acme-logo.tsx: -------------------------------------------------------------------------------- 1 | import { GlobeAltIcon } from '@heroicons/react/24/outline'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | 4 | export default function AcmeLogo() { 5 | return ( 6 |
9 | 10 |

Acme

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | 3 | export const experimental_ppr = true; 4 | 5 | export default function Layout({ children }: { children: React.ReactNode }) { 6 | return ( 7 |
8 |
9 | 10 |
11 |
{children}
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import LoginForm from '@/app/ui/login-form'; 3 | 4 | export default function LoginPage() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/ui/global.css'; 2 | import { inter } from '@/app/ui/fonts'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: { 7 | template: '%s | Acme Dashboard', 8 | default: 'Acme Dashboard', 9 | }, 10 | description: 'The official Next.js Course Dashboard, built with App Router.', 11 | metadataBase: new URL('https://next-learn-dashboard.vercel.sh'), 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /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 } from '@/app/lib/data'; 4 | 5 | export default async function Page() { 6 | const customers = await fetchCustomers(); 7 | 8 | return ( 9 |
10 | 20 |
21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FaceFrownIcon } from '@heroicons/react/24/outline'; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | 8 |

404 Not Found

9 |

Could not find the requested invoice.

10 | 14 | Go Back 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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('/dashboard', nextUrl)); 16 | } 17 | return true; 18 | }, 19 | }, 20 | providers: [], // Add providers with an empty array for now 21 | } satisfies NextAuthConfig; 22 | -------------------------------------------------------------------------------- /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": "ES2017", 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 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "app/lib/placeholder-data.ts", 32 | "scripts/seed.js" 33 | ], 34 | "exclude": ["node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /app/ui/invoices/status.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline'; 2 | import clsx from 'clsx'; 3 | 4 | export default function InvoiceStatus({ status }: { status: string }) { 5 | return ( 6 | 15 | {status === 'pending' ? ( 16 | <> 17 | Pending 18 | 19 | 20 | ) : null} 21 | {status === 'paid' ? ( 22 | <> 23 | Paid 24 | 25 | 26 | ) : null} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/ui/invoices/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { updateInvoice, State } from '@/app/lib/actions'; 4 | import { useActionState } from 'react'; 5 | import { CustomerField, InvoiceForm } from '@/app/lib/definitions'; 6 | import { 7 | CheckIcon, 8 | ClockIcon, 9 | CurrencyDollarIcon, 10 | UserCircleIcon, 11 | } from '@heroicons/react/24/outline'; 12 | import Link from 'next/link'; 13 | import { Button } from '@/app/ui/button'; 14 | 15 | export default function EditInvoiceForm({ 16 | invoice, 17 | customers, 18 | }: { 19 | invoice: InvoiceForm; 20 | customers: CustomerField[]; 21 | }) { 22 | const initialState: State = { message: null, errors: {} }; 23 | const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); 24 | const [state, formAction] = useActionState(updateInvoiceWithId, initialState); 25 | 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /app/ui/invoices/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string }; 10 | reset: () => void; 11 | }) { 12 | useEffect(() => { 13 | // Optionally log the error to an error reporting service 14 | console.error(error); 15 | }, [error]); 16 | 17 | return ( 18 |
19 |

Something went wrong!

20 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /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 } from '@/app/lib/data'; 4 | import { notFound } from 'next/navigation'; 5 | 6 | export default async function Page({ params }: { params: { id: string } }) { 7 | const id = params.id; 8 | const [invoice, customers] = await Promise.all([ 9 | fetchInvoiceById(id), 10 | fetchCustomers(), 11 | ]); 12 | 13 | if (!invoice) { 14 | notFound(); 15 | } 16 | 17 | return ( 18 |
19 | 29 |
30 |
31 | ); 32 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "next build", 5 | "dev": "next dev", 6 | "start": "next start", 7 | "lint": "next lint" 8 | }, 9 | "dependencies": { 10 | "@heroicons/react": "^2.1.4", 11 | "@tailwindcss/forms": "^0.5.7", 12 | "@vercel/postgres": "^0.8.0", 13 | "autoprefixer": "10.4.19", 14 | "bcrypt": "^5.1.1", 15 | "clsx": "^2.1.1", 16 | "next": "15.0.0-canary.56", 17 | "next-auth": "5.0.0-beta.22", 18 | "postcss": "8.4.38", 19 | "react": "19.0.0-rc-f38c22b244-20240704", 20 | "react-dom": "19.0.0-rc-f38c22b244-20240704", 21 | "tailwindcss": "3.4.4", 22 | "typescript": "5.5.2", 23 | "use-debounce": "^10.0.1", 24 | "zod": "^3.23.8" 25 | }, 26 | "devDependencies": { 27 | "@types/bcrypt": "^5.0.2", 28 | "@types/node": "20.14.8", 29 | "@types/react": "18.3.3", 30 | "@types/react-dom": "18.3.0", 31 | "eslint": "^8", 32 | "eslint-config-next": "14.2.15" 33 | }, 34 | "engines": { 35 | "node": ">=20.12.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/ui/invoices/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import Link from 'next/link'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | 5 | interface Breadcrumb { 6 | label: string; 7 | href: string; 8 | active?: boolean; 9 | } 10 | 11 | export default function Breadcrumbs({ 12 | breadcrumbs, 13 | }: { 14 | breadcrumbs: Breadcrumb[]; 15 | }) { 16 | return ( 17 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /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 '@/app/auth'; 6 | 7 | export default function SideNav() { 8 | return ( 9 |
10 | 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | { 22 | 'use server'; 23 | await signOut(); 24 | }} 25 | > 26 | 30 | 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/ui/invoices/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { deleteInvoice } from '@/app/lib/actions'; 2 | import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; 3 | import Link from 'next/link'; 4 | 5 | export function CreateInvoice() { 6 | return ( 7 | 11 | Create Invoice{' '} 12 | 13 | 14 | ); 15 | } 16 | 17 | export function UpdateInvoice({ id }: { id: string }) { 18 | return ( 19 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export function DeleteInvoice({ id }: { id: string }) { 29 | const deleteInvoiceWithId = deleteInvoice.bind(null, id); 30 | 31 | return ( 32 | <> 33 |
34 | 38 |
39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import Credentials from 'next-auth/providers/credentials'; 3 | import { authConfig } from 'auth.config'; 4 | import { z } from 'zod'; 5 | import { sql } from '@vercel/postgres'; 6 | import type { User } from '@/app/lib/definitions'; 7 | import bcrypt from 'bcrypt'; 8 | 9 | async function getUser(email: string): Promise { 10 | try { 11 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 12 | return user.rows[0]; 13 | } catch (error) { 14 | console.error('Failed to fetch user:', error); 15 | throw new Error('Failed to fetch user.'); 16 | } 17 | } 18 | 19 | export const { auth, signIn, signOut } = NextAuth({ 20 | ...authConfig, 21 | providers: [ 22 | Credentials({ 23 | async authorize(credentials) { 24 | const parsedCredentials = z 25 | .object({ email: z.string().email(), password: z.string().min(6) }) 26 | .safeParse(credentials); 27 | 28 | if (parsedCredentials.success) { 29 | const { email, password } = parsedCredentials.data; 30 | const user = await getUser(email); 31 | if (!user) return null; 32 | const passwordsMatch = await bcrypt.compare(password, user.password); 33 | 34 | if (passwordsMatch) return user; 35 | } 36 | 37 | console.log('Invalid credentials'); 38 | return null; 39 | }, 40 | }), 41 | ], 42 | }); -------------------------------------------------------------------------------- /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 { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // remove fetchRevenue 6 | import { Suspense } from 'react'; 7 | import { 8 | RevenueChartSkeleton, 9 | LatestInvoicesSkeleton, 10 | CardsSkeleton, 11 | } from '@/app/ui/skeletons'; 12 | 13 | export default async function Page() { 14 | const latestInvoices = await fetchLatestInvoices(); 15 | const { 16 | numberOfInvoices, 17 | numberOfCustomers, 18 | totalPaidInvoices, 19 | totalPendingInvoices, 20 | } = await fetchCardData(); 21 | 22 | return ( 23 |
24 |

25 | Dashboard 26 |

27 |
28 | }> 29 | 30 | 31 |
32 |
33 | }> 34 | 35 | 36 | }> 37 | 38 | 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /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 | 7 | export default function Search({ placeholder }: { placeholder: string }) { 8 | const searchParams = useSearchParams(); 9 | const pathname = usePathname(); 10 | const { replace } = useRouter(); 11 | 12 | const handleSearch = useDebouncedCallback((term) => { 13 | console.log(`Searching... ${term}`); 14 | const params = new URLSearchParams(searchParams); 15 | params.set('page', '1'); 16 | if (term) { 17 | params.set('query', term); 18 | } else { 19 | params.delete('query'); 20 | } 21 | replace(`${pathname}?${params.toString()}`); 22 | }, 300); 23 | return ( 24 |
25 | 28 | { 32 | handleSearch(e.target.value); 33 | }} 34 | defaultValue={searchParams.get('query')?.toString()} 35 | /> 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | UserGroupIcon, 5 | HomeIcon, 6 | DocumentDuplicateIcon, 7 | } from '@heroicons/react/24/outline'; 8 | import Link from 'next/link'; 9 | import { usePathname } from 'next/navigation'; 10 | import clsx from 'clsx'; 11 | 12 | // Map of links to display in the side navigation. 13 | // Depending on the size of the application, this would be stored in a database. 14 | const links = [ 15 | { name: 'Home', href: '/dashboard', icon: HomeIcon }, 16 | { 17 | name: 'Invoices', 18 | href: '/dashboard/invoices', 19 | icon: DocumentDuplicateIcon, 20 | }, 21 | { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon }, 22 | ]; 23 | 24 | export default function NavLinks() { 25 | const pathname = usePathname(); 26 | return ( 27 | <> 28 | {links.map((link) => { 29 | const LinkIcon = link.icon; 30 | return ( 31 | 41 | 42 |

{link.name}

43 | 44 | ); 45 | })} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /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 } from '@/app/lib/data'; 9 | import { Metadata } from 'next'; 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 totalPages = await fetchInvoicesPages(query); 27 | 28 | return ( 29 |
30 |
31 |

Invoices

32 |
33 |
34 | 35 | 36 |
37 | }> 38 | 39 | 40 |
41 | 42 |
43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /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 | 10 | const iconMap = { 11 | collected: BanknotesIcon, 12 | customers: UserGroupIcon, 13 | pending: ClockIcon, 14 | invoices: InboxIcon, 15 | }; 16 | 17 | export default async function CardWrapper() { 18 | const { 19 | numberOfInvoices, 20 | numberOfCustomers, 21 | totalPaidInvoices, 22 | totalPendingInvoices, 23 | } = await fetchCardData(); 24 | return ( 25 | <> 26 | {/* NOTE: Uncomment this code in Chapter 9 */} 27 | 28 | 29 | 30 | 31 | 36 | 37 | ); 38 | } 39 | 40 | export function Card({ 41 | title, 42 | value, 43 | type, 44 | }: { 45 | title: string; 46 | value: number | string; 47 | type: 'invoices' | 'customers' | 'pending' | 'collected'; 48 | }) { 49 | const Icon = iconMap[type]; 50 | 51 | return ( 52 |
53 |
54 | {Icon ? : null} 55 |

{title}

56 |
57 |

61 | {value} 62 |

63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /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 styles from '@/app/ui/home.module.css'; 5 | import Image from 'next/image'; 6 | 7 | export default function Page() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 |
15 |
18 |

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

25 | 29 | Log in 30 | 31 |
32 |
33 | 40 | 47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /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/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 | }; 11 | 12 | export type Customer = { 13 | id: string; 14 | name: string; 15 | email: string; 16 | image_url: string; 17 | }; 18 | 19 | export type Invoice = { 20 | id: string; 21 | customer_id: string; 22 | amount: number; 23 | date: string; 24 | // In TypeScript, this is called a string union type. 25 | // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'. 26 | status: 'pending' | 'paid'; 27 | }; 28 | 29 | export type Revenue = { 30 | month: string; 31 | revenue: number; 32 | }; 33 | 34 | export type LatestInvoice = { 35 | id: string; 36 | name: string; 37 | image_url: 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 | image_url: string; 53 | date: string; 54 | amount: number; 55 | status: 'pending' | 'paid'; 56 | }; 57 | 58 | export type CustomersTableType = { 59 | id: string; 60 | name: string; 61 | email: string; 62 | image_url: string; 63 | total_invoices: number; 64 | total_pending: number; 65 | total_paid: number; 66 | }; 67 | 68 | export type FormattedCustomersTable = { 69 | id: string; 70 | name: string; 71 | email: string; 72 | image_url: string; 73 | total_invoices: number; 74 | total_pending: string; 75 | total_paid: string; 76 | }; 77 | 78 | export type CustomerField = { 79 | id: string; 80 | name: string; 81 | }; 82 | 83 | export type InvoiceForm = { 84 | id: string; 85 | customer_id: string; 86 | amount: number; 87 | status: 'pending' | 'paid'; 88 | }; 89 | -------------------------------------------------------------------------------- /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 { fetchRevenue } from '@/app/lib/data'; 5 | 6 | // This component is representational only. 7 | // For data visualization UI, check out: 8 | // https://www.tremor.so/ 9 | // https://www.chartjs.org/ 10 | // https://airbnb.io/visx/ 11 | 12 | export default async function RevenueChart() { // Make component async, remove the props 13 | const revenue = await fetchRevenue(); // Fetch data inside the component 14 | const chartHeight = 350; 15 | // NOTE: Uncomment this code in Chapter 7 16 | 17 | const { yAxisLabels, topLabel } = generateYAxis(revenue); 18 | 19 | if (!revenue || revenue.length === 0) { 20 | return

No data available.

; 21 | } 22 | 23 | return ( 24 |
25 |

26 | Recent Revenue 27 |

28 | {/* NOTE: Uncomment this code in Chapter 7 */} 29 | 30 |
31 |
32 |
36 | {yAxisLabels.map((label) => ( 37 |

{label}

38 | ))} 39 |
40 | 41 | {revenue.map((month) => ( 42 |
43 |
49 |

50 | {month.month} 51 |

52 |
53 | ))} 54 |
55 |
56 | 57 |

Last 12 months

58 |
59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /app/ui/dashboard/latest-invoices.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowPathIcon } from '@heroicons/react/24/outline'; 2 | import clsx from 'clsx'; 3 | import Image from 'next/image'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { fetchLatestInvoices } from '@/app/lib/data'; 6 | 7 | // import { LatestInvoice } from '@/app/lib/definitions'; 8 | 9 | export default async function LatestInvoices() { 10 | const latestInvoices = await fetchLatestInvoices(); 11 | 12 | return ( 13 |
14 |

15 | Latest Invoices 16 |

17 |
18 | {/* NOTE: Uncomment this code in Chapter 7 */} 19 | 20 |
21 | {latestInvoices.map((invoice, i) => { 22 | return ( 23 |
32 |
33 | 40 |
41 |

42 | {invoice.name} 43 |

44 |

45 | {invoice.email} 46 |

47 |
48 |
49 |

52 | {invoice.amount} 53 |

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

Updated just now

61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /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 './button'; 11 | import { useActionState } from 'react'; 12 | import { authenticate } from '@/app/lib/actions'; 13 | 14 | export default function LoginForm() { 15 | const [errorMessage, formAction, isPending] = useActionState( 16 | authenticate, 17 | undefined, 18 | ); 19 | return ( 20 |
21 |
22 |

23 | Please log in to continue. 24 |

25 |
26 |
27 | 33 |
34 | 42 | 43 |
44 |
45 |
46 | 52 |
53 | 62 | 63 |
64 |
65 |
66 | 69 |
74 | {errorMessage && ( 75 | <> 76 | 77 |

{errorMessage}

78 | 79 | )} 80 |
81 |
82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /app/seed/route.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import { db } from '@vercel/postgres'; 3 | import { invoices, customers, revenue, users } from '../lib/placeholder-data'; 4 | 5 | const client = await db.connect(); 6 | 7 | async function seedUsers() { 8 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 9 | await client.sql` 10 | CREATE TABLE IF NOT EXISTS users ( 11 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 12 | name VARCHAR(255) NOT NULL, 13 | email TEXT NOT NULL UNIQUE, 14 | password TEXT NOT NULL 15 | ); 16 | `; 17 | 18 | const insertedUsers = await Promise.all( 19 | users.map(async (user) => { 20 | const hashedPassword = await bcrypt.hash(user.password, 10); 21 | return client.sql` 22 | INSERT INTO users (id, name, email, password) 23 | VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) 24 | ON CONFLICT (id) DO NOTHING; 25 | `; 26 | }), 27 | ); 28 | 29 | return insertedUsers; 30 | } 31 | 32 | async function seedInvoices() { 33 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 34 | 35 | await client.sql` 36 | CREATE TABLE IF NOT EXISTS invoices ( 37 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 38 | customer_id UUID NOT NULL, 39 | amount INT NOT NULL, 40 | status VARCHAR(255) NOT NULL, 41 | date DATE NOT NULL 42 | ); 43 | `; 44 | 45 | const insertedInvoices = await Promise.all( 46 | invoices.map( 47 | (invoice) => client.sql` 48 | INSERT INTO invoices (customer_id, amount, status, date) 49 | VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) 50 | ON CONFLICT (id) DO NOTHING; 51 | `, 52 | ), 53 | ); 54 | 55 | return insertedInvoices; 56 | } 57 | 58 | async function seedCustomers() { 59 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 60 | 61 | await client.sql` 62 | CREATE TABLE IF NOT EXISTS customers ( 63 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 64 | name VARCHAR(255) NOT NULL, 65 | email VARCHAR(255) NOT NULL, 66 | image_url VARCHAR(255) NOT NULL 67 | ); 68 | `; 69 | 70 | const insertedCustomers = await Promise.all( 71 | customers.map( 72 | (customer) => client.sql` 73 | INSERT INTO customers (id, name, email, image_url) 74 | VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) 75 | ON CONFLICT (id) DO NOTHING; 76 | `, 77 | ), 78 | ); 79 | 80 | return insertedCustomers; 81 | } 82 | 83 | async function seedRevenue() { 84 | await client.sql` 85 | CREATE TABLE IF NOT EXISTS revenue ( 86 | month VARCHAR(4) NOT NULL UNIQUE, 87 | revenue INT NOT NULL 88 | ); 89 | `; 90 | 91 | const insertedRevenue = await Promise.all( 92 | revenue.map( 93 | (rev) => client.sql` 94 | INSERT INTO revenue (month, revenue) 95 | VALUES (${rev.month}, ${rev.revenue}) 96 | ON CONFLICT (month) DO NOTHING; 97 | `, 98 | ), 99 | ); 100 | 101 | return insertedRevenue; 102 | } 103 | 104 | export async function GET() { 105 | try { 106 | await client.sql`BEGIN`; 107 | await seedUsers(); 108 | await seedCustomers(); 109 | await seedInvoices(); 110 | await seedRevenue(); 111 | await client.sql`COMMIT`; 112 | 113 | return Response.json({ message: 'Database seeded successfully' }); 114 | } catch (error) { 115 | await client.sql`ROLLBACK`; 116 | return Response.json({ error }, { status: 500 }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/lib/placeholder-data.ts: -------------------------------------------------------------------------------- 1 | // This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter: 2 | // https://nextjs.org/learn/dashboard-app/fetching-data 3 | const users = [ 4 | { 5 | id: '410544b2-4001-4271-9855-fec4b6a6442a', 6 | name: 'User', 7 | email: 'user@nextmail.com', 8 | password: '123456', 9 | }, 10 | ]; 11 | 12 | const customers = [ 13 | { 14 | id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa', 15 | name: 'Evil Rabbit', 16 | email: 'evil@rabbit.com', 17 | image_url: '/customers/evil-rabbit.png', 18 | }, 19 | { 20 | id: '3958dc9e-712f-4377-85e9-fec4b6a6442a', 21 | name: 'Delba de Oliveira', 22 | email: 'delba@oliveira.com', 23 | image_url: '/customers/delba-de-oliveira.png', 24 | }, 25 | { 26 | id: '3958dc9e-742f-4377-85e9-fec4b6a6442a', 27 | name: 'Lee Robinson', 28 | email: 'lee@robinson.com', 29 | image_url: '/customers/lee-robinson.png', 30 | }, 31 | { 32 | id: '76d65c26-f784-44a2-ac19-586678f7c2f2', 33 | name: 'Michael Novotny', 34 | email: 'michael@novotny.com', 35 | image_url: '/customers/michael-novotny.png', 36 | }, 37 | { 38 | id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9', 39 | name: 'Amy Burns', 40 | email: 'amy@burns.com', 41 | image_url: '/customers/amy-burns.png', 42 | }, 43 | { 44 | id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB', 45 | name: 'Balazs Orban', 46 | email: 'balazs@orban.com', 47 | image_url: '/customers/balazs-orban.png', 48 | }, 49 | ]; 50 | 51 | const invoices = [ 52 | { 53 | customer_id: customers[0].id, 54 | amount: 15795, 55 | status: 'pending', 56 | date: '2022-12-06', 57 | }, 58 | { 59 | customer_id: customers[1].id, 60 | amount: 20348, 61 | status: 'pending', 62 | date: '2022-11-14', 63 | }, 64 | { 65 | customer_id: customers[4].id, 66 | amount: 3040, 67 | status: 'paid', 68 | date: '2022-10-29', 69 | }, 70 | { 71 | customer_id: customers[3].id, 72 | amount: 44800, 73 | status: 'paid', 74 | date: '2023-09-10', 75 | }, 76 | { 77 | customer_id: customers[5].id, 78 | amount: 34577, 79 | status: 'pending', 80 | date: '2023-08-05', 81 | }, 82 | { 83 | customer_id: customers[2].id, 84 | amount: 54246, 85 | status: 'pending', 86 | date: '2023-07-16', 87 | }, 88 | { 89 | customer_id: customers[0].id, 90 | amount: 666, 91 | status: 'pending', 92 | date: '2023-06-27', 93 | }, 94 | { 95 | customer_id: customers[3].id, 96 | amount: 32545, 97 | status: 'paid', 98 | date: '2023-06-09', 99 | }, 100 | { 101 | customer_id: customers[4].id, 102 | amount: 1250, 103 | status: 'paid', 104 | date: '2023-06-17', 105 | }, 106 | { 107 | customer_id: customers[5].id, 108 | amount: 8546, 109 | status: 'paid', 110 | date: '2023-06-07', 111 | }, 112 | { 113 | customer_id: customers[1].id, 114 | amount: 500, 115 | status: 'paid', 116 | date: '2023-08-19', 117 | }, 118 | { 119 | customer_id: customers[5].id, 120 | amount: 8945, 121 | status: 'paid', 122 | date: '2023-06-03', 123 | }, 124 | { 125 | customer_id: customers[2].id, 126 | amount: 1000, 127 | status: 'paid', 128 | date: '2022-06-05', 129 | }, 130 | ]; 131 | 132 | const revenue = [ 133 | { month: 'Jan', revenue: 2000 }, 134 | { month: 'Feb', revenue: 1800 }, 135 | { month: 'Mar', revenue: 2200 }, 136 | { month: 'Apr', revenue: 2500 }, 137 | { month: 'May', revenue: 2300 }, 138 | { month: 'Jun', revenue: 3200 }, 139 | { month: 'Jul', revenue: 3500 }, 140 | { month: 'Aug', revenue: 3700 }, 141 | { month: 'Sep', revenue: 2500 }, 142 | { month: 'Oct', revenue: 2800 }, 143 | { month: 'Nov', revenue: 3000 }, 144 | { month: 'Dec', revenue: 4800 }, 145 | ]; 146 | 147 | export { users, customers, invoices, revenue }; 148 | -------------------------------------------------------------------------------- /app/lib/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { z } from 'zod'; 4 | import { sql } from '@vercel/postgres'; 5 | import { revalidatePath } from 'next/cache'; 6 | import { redirect } from 'next/navigation'; 7 | import { signIn } from '../auth'; 8 | import { AuthError } from 'next-auth'; 9 | 10 | export async function authenticate( 11 | prevState: string | undefined, 12 | formData: FormData, 13 | ) { 14 | try { 15 | await signIn('credentials', formData); 16 | } catch (error) { 17 | if (error instanceof AuthError) { 18 | switch (error.type) { 19 | case 'CredentialsSignin': 20 | return 'Invalid credentials.'; 21 | default: 22 | return 'Something went wrong.'; 23 | } 24 | } 25 | throw error; 26 | } 27 | } 28 | 29 | const FormSchema = z.object({ 30 | id: z.string(), 31 | customerId: z.string({ 32 | invalid_type_error: 'Please select a customer.', 33 | }), 34 | amount: z.coerce.number() 35 | .gt(0, { message: 'Please enter an amount greater than $0.' }), 36 | status: z.enum(['pending', 'paid'], { 37 | invalid_type_error: 'Please select an invoice status.', 38 | }), 39 | date: z.string(), 40 | }); 41 | 42 | const CreateInvoice = FormSchema.omit({ id: true, date: true }); 43 | const UpdateInvoice = FormSchema.omit({ id: true, date: true }); 44 | 45 | export async function deleteInvoice(id: string) { 46 | try { 47 | await sql`DELETE FROM invoices WHERE id = ${id}`; 48 | revalidatePath('/dashboard/invoices'); 49 | } catch(error) { 50 | return { message: 'Database Error: Failed to Delete Invoice.' }; 51 | } 52 | } 53 | 54 | export async function updateInvoice( 55 | id: string, 56 | prevState: State, 57 | formData: FormData, 58 | ) { 59 | const validatedFields = UpdateInvoice.safeParse({ 60 | customerId: formData.get('customerId'), 61 | amount: formData.get('amount'), 62 | status: formData.get('status'), 63 | }); 64 | 65 | if (!validatedFields.success) { 66 | return { 67 | errors: validatedFields.error.flatten().fieldErrors, 68 | message: 'Missing Fields. Failed to Update Invoice.', 69 | }; 70 | } 71 | 72 | const { customerId, amount, status } = validatedFields.data; 73 | const amountInCents = amount * 100; 74 | 75 | try { 76 | await sql` 77 | UPDATE invoices 78 | SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} 79 | WHERE id = ${id} 80 | `; 81 | } catch (error) { 82 | return { message: 'Database Error: Failed to Update Invoice.' }; 83 | } 84 | 85 | revalidatePath('/dashboard/invoices'); 86 | redirect('/dashboard/invoices'); 87 | } 88 | 89 | export type State = { 90 | errors?: { 91 | customerId?: string[]; 92 | amount?: string[]; 93 | status?: string[]; 94 | }; 95 | message?: string | null; 96 | }; 97 | 98 | export async function createInvoice(prevState: State, formData: FormData) { 99 | const validatedFields = CreateInvoice.safeParse({ 100 | customerId: formData.get('customerId'), 101 | amount: formData.get('amount'), 102 | status: formData.get('status'), 103 | }); 104 | 105 | if (!validatedFields.success) { 106 | return { 107 | errors: validatedFields.error.flatten().fieldErrors, 108 | message: 'Missing Fields. Failed to Create Invoice.', 109 | }; 110 | } 111 | 112 | const { customerId, amount, status } = validatedFields.data; 113 | const amountInCents = amount * 100; 114 | const date = new Date().toISOString().split('T')[0]; 115 | 116 | try { 117 | await sql` 118 | INSERT INTO invoices (customer_id, amount, status, date) 119 | VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) 120 | `; 121 | } catch(error) { 122 | return { 123 | message: 'Database Error: Failed to Create Invoice.', 124 | }; 125 | } 126 | 127 | revalidatePath('/dashboard/invoices'); 128 | redirect('/dashboard/invoices'); 129 | } 130 | -------------------------------------------------------------------------------- /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 | 9 | export default function Pagination({ totalPages }: { totalPages: number }) { 10 | const pathname = usePathname(); 11 | const searchParams = useSearchParams(); 12 | const currentPage = Number(searchParams.get('page')) || 1; 13 | 14 | const createPageURL = (pageNumber: number | string) => { 15 | const params = new URLSearchParams(searchParams); 16 | params.set('page', pageNumber.toString()); 17 | return `${pathname}?${params.toString()}`; 18 | }; 19 | 20 | const allPages = generatePagination(currentPage, totalPages); 21 | 22 | return ( 23 | <> 24 | {/* NOTE: Uncomment this code in Chapter 11 */} 25 | 26 |
27 | 32 | 33 |
34 | {allPages.map((page, index) => { 35 | let position: 'first' | 'last' | 'single' | 'middle' | undefined; 36 | 37 | if (index === 0) position = 'first'; 38 | if (index === allPages.length - 1) position = 'last'; 39 | if (allPages.length === 1) position = 'single'; 40 | if (page === '...') position = 'middle'; 41 | 42 | return ( 43 | 50 | ); 51 | })} 52 |
53 | 54 | = totalPages} 58 | /> 59 |
60 | 61 | ); 62 | } 63 | 64 | function PaginationNumber({ 65 | page, 66 | href, 67 | isActive, 68 | position, 69 | }: { 70 | page: number | string; 71 | href: string; 72 | position?: 'first' | 'last' | 'middle' | 'single'; 73 | isActive: boolean; 74 | }) { 75 | const className = clsx( 76 | 'flex h-10 w-10 items-center justify-center text-sm border', 77 | { 78 | 'rounded-l-md': position === 'first' || position === 'single', 79 | 'rounded-r-md': position === 'last' || position === 'single', 80 | 'z-10 bg-blue-600 border-blue-600 text-white': isActive, 81 | 'hover:bg-gray-100': !isActive && position !== 'middle', 82 | 'text-gray-300': position === 'middle', 83 | }, 84 | ); 85 | 86 | return isActive || position === 'middle' ? ( 87 |
{page}
88 | ) : ( 89 | 90 | {page} 91 | 92 | ); 93 | } 94 | 95 | function PaginationArrow({ 96 | href, 97 | direction, 98 | isDisabled, 99 | }: { 100 | href: string; 101 | direction: 'left' | 'right'; 102 | isDisabled?: boolean; 103 | }) { 104 | const className = clsx( 105 | 'flex h-10 w-10 items-center justify-center rounded-md border', 106 | { 107 | 'pointer-events-none text-gray-300': isDisabled, 108 | 'hover:bg-gray-100': !isDisabled, 109 | 'mr-2 md:mr-4': direction === 'left', 110 | 'ml-2 md:ml-4': direction === 'right', 111 | }, 112 | ); 113 | 114 | const icon = 115 | direction === 'left' ? ( 116 | 117 | ) : ( 118 | 119 | ); 120 | 121 | return isDisabled ? ( 122 |
{icon}
123 | ) : ( 124 | 125 | {icon} 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /app/ui/invoices/create-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useActionState } from 'react'; 4 | import { CustomerField } from '@/app/lib/definitions'; 5 | import Link from 'next/link'; 6 | import { 7 | CheckIcon, 8 | ClockIcon, 9 | CurrencyDollarIcon, 10 | UserCircleIcon, 11 | } from '@heroicons/react/24/outline'; 12 | import { Button } from '@/app/ui/button'; 13 | import { createInvoice, State } from '@/app/lib/actions'; 14 | 15 | export default function Form({ customers }: { customers: CustomerField[] }) { 16 | const initialState: State = { message: null, errors: {} }; 17 | const [state, formAction] = useActionState(createInvoice, initialState); 18 | 19 | return ( 20 |
21 |
22 | {/* Customer Name */} 23 |
24 | 27 |
28 | 44 | 45 |
46 |
47 | {state.errors?.customerId && 48 | state.errors.customerId.map((error: string) => ( 49 |

50 | {error} 51 |

52 | ))} 53 |
54 |
55 | 56 | {/* Invoice Amount */} 57 |
58 | 61 |
62 |
63 | 71 | 72 |
73 |
74 |
75 | 76 | {/* Invoice Status */} 77 |
78 | 79 | Set the invoice status 80 | 81 |
82 |
83 |
84 | 91 | 97 |
98 |
99 | 106 | 112 |
113 |
114 |
115 |
116 |
117 |
118 | 122 | Cancel 123 | 124 | 125 |
126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /app/ui/invoices/table.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons'; 3 | import InvoiceStatus from '@/app/ui/invoices/status'; 4 | import { formatDateToLocal, formatCurrency } from '@/app/lib/utils'; 5 | import { fetchFilteredInvoices } from '@/app/lib/data'; 6 | 7 | export default async function InvoicesTable({ 8 | query, 9 | currentPage, 10 | }: { 11 | query: string; 12 | currentPage: number; 13 | }) { 14 | const invoices = await fetchFilteredInvoices(query, currentPage); 15 | 16 | return ( 17 |
18 |
19 |
20 |
21 | {invoices?.map((invoice) => ( 22 |
26 |
27 |
28 |
29 | 36 |

{invoice.name}

37 |
38 |

{invoice.email}

39 |
40 | 41 |
42 |
43 |
44 |

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

47 |

{formatDateToLocal(invoice.date)}

48 |
49 |
50 | 51 | 52 |
53 |
54 |
55 | ))} 56 |
57 |
58 | 59 | 60 | 63 | 66 | 69 | 72 | 75 | 78 | 79 | 80 | 81 | {invoices?.map((invoice) => ( 82 | 86 | 98 | 101 | 104 | 107 | 110 | 116 | 117 | ))} 118 | 119 |
61 | Customer 62 | 64 | Email 65 | 67 | Amount 68 | 70 | Date 71 | 73 | Status 74 | 76 | Edit 77 |
87 |
88 | {`${invoice.name}'s 95 |

{invoice.name}

96 |
97 |
99 | {invoice.email} 100 | 102 | {formatCurrency(invoice.amount)} 103 | 105 | {formatDateToLocal(invoice.date)} 106 | 108 | 109 | 111 |
112 | 113 | 114 |
115 |
120 |
121 | 122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /app/ui/customers/table.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | import Search from '@/app/ui/search'; 4 | import { 5 | CustomersTableType, 6 | FormattedCustomersTable, 7 | } from '@/app/lib/definitions'; 8 | 9 | export default async function CustomersTable({ 10 | customers, 11 | }: { 12 | customers: FormattedCustomersTable[]; 13 | }) { 14 | return ( 15 |
16 |

17 | Customers 18 |

19 | 20 |
21 |
22 |
23 |
24 |
25 | {customers?.map((customer) => ( 26 |
30 |
31 |
32 |
33 |
34 | {`${customer.name}'s 41 |

{customer.name}

42 |
43 |
44 |

45 | {customer.email} 46 |

47 |
48 |
49 |
50 |
51 |

Pending

52 |

{customer.total_pending}

53 |
54 |
55 |

Paid

56 |

{customer.total_paid}

57 |
58 |
59 |
60 |

{customer.total_invoices} invoices

61 |
62 |
63 | ))} 64 |
65 | 66 | 67 | 68 | 71 | 74 | 77 | 80 | 83 | 84 | 85 | 86 | 87 | {customers.map((customer) => ( 88 | 89 | 101 | 104 | 107 | 110 | 113 | 114 | ))} 115 | 116 |
69 | Name 70 | 72 | Email 73 | 75 | Total Invoices 76 | 78 | Total Pending 79 | 81 | Total Paid 82 |
90 |
91 | {`${customer.name}'s 98 |

{customer.name}

99 |
100 |
102 | {customer.email} 103 | 105 | {customer.total_invoices} 106 | 108 | {customer.total_pending} 109 | 111 | {customer.total_paid} 112 |
117 |
118 |
119 |
120 |
121 |
122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /app/lib/data.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { 3 | CustomerField, 4 | CustomersTableType, 5 | InvoiceForm, 6 | InvoicesTable, 7 | LatestInvoiceRaw, 8 | Revenue, 9 | } from './definitions'; 10 | import { formatCurrency } from './utils'; 11 | import { notFound } from 'next/navigation'; 12 | 13 | export async function fetchRevenue() { 14 | try { 15 | // Artificially delay a response for demo purposes. 16 | // Don't do this in production :) 17 | 18 | console.log('Fetching revenue data...'); 19 | await new Promise((resolve) => setTimeout(resolve, 3000)); 20 | 21 | const data = await sql`SELECT * FROM revenue`; 22 | 23 | console.log('Data fetch completed after 3 seconds.'); 24 | 25 | return data.rows; 26 | } catch (error) { 27 | console.error('Database Error:', error); 28 | throw new Error('Failed to fetch revenue data.'); 29 | } 30 | } 31 | 32 | export async function fetchLatestInvoices() { 33 | try { 34 | const data = await sql` 35 | SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id 36 | FROM invoices 37 | JOIN customers ON invoices.customer_id = customers.id 38 | ORDER BY invoices.date DESC 39 | LIMIT 5`; 40 | 41 | const latestInvoices = data.rows.map((invoice) => ({ 42 | ...invoice, 43 | amount: formatCurrency(invoice.amount), 44 | })); 45 | return latestInvoices; 46 | } catch (error) { 47 | console.error('Database Error:', error); 48 | throw new Error('Failed to fetch the latest invoices.'); 49 | } 50 | } 51 | 52 | export async function fetchCardData() { 53 | try { 54 | // You can probably combine these into a single SQL query 55 | // However, we are intentionally splitting them to demonstrate 56 | // how to initialize multiple queries in parallel with JS. 57 | const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`; 58 | const customerCountPromise = sql`SELECT COUNT(*) FROM customers`; 59 | const invoiceStatusPromise = sql`SELECT 60 | SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid", 61 | SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending" 62 | FROM invoices`; 63 | 64 | const data = await Promise.all([ 65 | invoiceCountPromise, 66 | customerCountPromise, 67 | invoiceStatusPromise, 68 | ]); 69 | 70 | const numberOfInvoices = Number(data[0].rows[0].count ?? '0'); 71 | const numberOfCustomers = Number(data[1].rows[0].count ?? '0'); 72 | const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0'); 73 | const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0'); 74 | 75 | return { 76 | numberOfCustomers, 77 | numberOfInvoices, 78 | totalPaidInvoices, 79 | totalPendingInvoices, 80 | }; 81 | } catch (error) { 82 | console.error('Database Error:', error); 83 | throw new Error('Failed to fetch card data.'); 84 | } 85 | } 86 | 87 | const ITEMS_PER_PAGE = 6; 88 | export async function fetchFilteredInvoices( 89 | query: string, 90 | currentPage: number, 91 | ) { 92 | const offset = (currentPage - 1) * ITEMS_PER_PAGE; 93 | 94 | try { 95 | const invoices = await sql` 96 | SELECT 97 | invoices.id, 98 | invoices.amount, 99 | invoices.date, 100 | invoices.status, 101 | customers.name, 102 | customers.email, 103 | customers.image_url 104 | FROM invoices 105 | JOIN customers ON invoices.customer_id = customers.id 106 | WHERE 107 | customers.name ILIKE ${`%${query}%`} OR 108 | customers.email ILIKE ${`%${query}%`} OR 109 | invoices.amount::text ILIKE ${`%${query}%`} OR 110 | invoices.date::text ILIKE ${`%${query}%`} OR 111 | invoices.status ILIKE ${`%${query}%`} 112 | ORDER BY invoices.date DESC 113 | LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset} 114 | `; 115 | 116 | return invoices.rows; 117 | } catch (error) { 118 | console.error('Database Error:', error); 119 | throw new Error('Failed to fetch invoices.'); 120 | } 121 | } 122 | 123 | export async function fetchInvoicesPages(query: string) { 124 | try { 125 | const count = await sql`SELECT COUNT(*) 126 | FROM invoices 127 | JOIN customers ON invoices.customer_id = customers.id 128 | WHERE 129 | customers.name ILIKE ${`%${query}%`} OR 130 | customers.email ILIKE ${`%${query}%`} OR 131 | invoices.amount::text ILIKE ${`%${query}%`} OR 132 | invoices.date::text ILIKE ${`%${query}%`} OR 133 | invoices.status ILIKE ${`%${query}%`} 134 | `; 135 | 136 | const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE); 137 | return totalPages; 138 | } catch (error) { 139 | console.error('Database Error:', error); 140 | throw new Error('Failed to fetch total number of invoices.'); 141 | } 142 | } 143 | 144 | export async function fetchInvoiceById(id: string) { 145 | try { 146 | const data = await sql` 147 | SELECT 148 | invoices.id, 149 | invoices.customer_id, 150 | invoices.amount, 151 | invoices.status 152 | FROM invoices 153 | WHERE invoices.id = ${id}; 154 | `; 155 | 156 | const invoice = data.rows.map((invoice) => ({ 157 | ...invoice, 158 | // Convert amount from cents to dollars 159 | amount: invoice.amount / 100, 160 | })); 161 | 162 | console.log(invoice); // Invoice is an empty array [] 163 | return invoice[0]; 164 | } catch (error) { 165 | console.error('Database Error:', error); 166 | throw new Error('Failed to fetch invoice.'); 167 | } 168 | } 169 | 170 | export async function fetchCustomers() { 171 | try { 172 | const data = await sql` 173 | SELECT 174 | id, 175 | name 176 | FROM customers 177 | ORDER BY name ASC 178 | `; 179 | 180 | const customers = data.rows; 181 | return customers; 182 | } catch (err) { 183 | console.error('Database Error:', err); 184 | throw new Error('Failed to fetch all customers.'); 185 | } 186 | } 187 | 188 | export async function fetchFilteredCustomers(query: string) { 189 | try { 190 | const data = await sql` 191 | SELECT 192 | customers.id, 193 | customers.name, 194 | customers.email, 195 | customers.image_url, 196 | COUNT(invoices.id) AS total_invoices, 197 | SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending, 198 | SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid 199 | FROM customers 200 | LEFT JOIN invoices ON customers.id = invoices.customer_id 201 | WHERE 202 | customers.name ILIKE ${`%${query}%`} OR 203 | customers.email ILIKE ${`%${query}%`} 204 | GROUP BY customers.id, customers.name, customers.email, customers.image_url 205 | ORDER BY customers.name ASC 206 | `; 207 | 208 | const customers = data.rows.map((customer) => ({ 209 | ...customer, 210 | total_pending: formatCurrency(customer.total_pending), 211 | total_paid: formatCurrency(customer.total_paid), 212 | })); 213 | 214 | return customers; 215 | } catch (err) { 216 | console.error('Database Error:', err); 217 | throw new Error('Failed to fetch customer table.'); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /app/ui/skeletons.tsx: -------------------------------------------------------------------------------- 1 | // Loading animation 2 | const shimmer = 3 | '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'; 4 | 5 | export function CardSkeleton() { 6 | return ( 7 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ); 19 | } 20 | 21 | export function CardsSkeleton() { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export function RevenueChartSkeleton() { 33 | return ( 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | export function InvoiceSkeleton() { 48 | return ( 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | export function LatestInvoicesSkeleton() { 63 | return ( 64 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | ); 83 | } 84 | 85 | export default function DashboardSkeleton() { 86 | return ( 87 | <> 88 |
91 |
92 | 93 | 94 | 95 | 96 |
97 |
98 | 99 | 100 |
101 | 102 | ); 103 | } 104 | 105 | export function TableRowSkeleton() { 106 | return ( 107 | 108 | {/* Customer Name and Image */} 109 | 110 |
111 |
112 |
113 |
114 | 115 | {/* Email */} 116 | 117 |
118 | 119 | {/* Amount */} 120 | 121 |
122 | 123 | {/* Date */} 124 | 125 |
126 | 127 | {/* Status */} 128 | 129 |
130 | 131 | {/* Actions */} 132 | 133 |
134 |
135 |
136 |
137 | 138 | 139 | ); 140 | } 141 | 142 | export function InvoicesMobileSkeleton() { 143 | return ( 144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | ); 164 | } 165 | 166 | export function InvoicesTableSkeleton() { 167 | return ( 168 |
169 |
170 |
171 |
172 | 173 | 174 | 175 | 176 | 177 | 178 |
179 | 180 | 181 | 182 | 185 | 188 | 191 | 194 | 197 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 |
183 | Customer 184 | 186 | Email 187 | 189 | Amount 190 | 192 | Date 193 | 195 | Status 196 | 201 | Edit 202 |
214 |
215 |
216 |
217 | ); 218 | } 219 | --------------------------------------------------------------------------------