├── .env.example ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── README.md ├── app ├── dashboard │ ├── (overview) │ │ ├── loading.tsx │ │ └── page.tsx │ ├── customers │ │ └── page.tsx │ ├── invoices │ │ ├── [id] │ │ │ └── edit │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ ├── create │ │ │ └── page.tsx │ │ ├── error.tsx │ │ └── page.tsx │ └── layout.tsx ├── favicon.ico ├── layout.tsx ├── lib │ ├── actions.ts │ ├── data.ts │ ├── definitions.ts │ ├── placeholder-data.js │ └── utils.ts ├── login │ └── page.tsx ├── opengraph-image.png ├── page.tsx └── ui │ ├── acme-logo.tsx │ ├── button.tsx │ ├── customers │ └── table.tsx │ ├── dashboard │ ├── cards.tsx │ ├── latest-invoices.tsx │ ├── nav-links.tsx │ ├── revenue-chart.tsx │ └── sidenav.tsx │ ├── fonts.ts │ ├── global.css │ ├── invoices │ ├── breadcrumbs.tsx │ ├── buttons.tsx │ ├── create-form.tsx │ ├── edit-form.tsx │ ├── pagination.tsx │ ├── status.tsx │ └── table.tsx │ ├── login-form.tsx │ ├── search.tsx │ └── skeletons.tsx ├── auth.config.ts ├── auth.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── customers │ ├── amy-burns.png │ ├── balazs-orban.png │ ├── delba-de-oliveira.png │ ├── emil-kowalski.png │ ├── evil-rabbit.png │ ├── guillermo-rauch.png │ ├── hector-simpson.png │ ├── jared-palmer.png │ ├── lee-robinson.png │ ├── michael-novotny.png │ ├── steph-dietz.png │ └── steven-tey.png ├── hero-desktop.png └── hero-mobile.png ├── scripts └── seed.js ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Copy from .env.local on the Vercel dashboard 2 | POSTGRES_URL= 3 | POSTGRES_PRISMA_URL= 4 | POSTGRES_URL_NON_POOLING= 5 | POSTGRES_USER= 6 | POSTGRES_HOST= 7 | POSTGRES_PASSWORD= 8 | POSTGRES_DATABASE= 9 | 10 | # `openssl rand -base64 32` 11 | AUTH_SECRET= 12 | AUTH_URL=http://localhost:3000/api/auth -------------------------------------------------------------------------------- /.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 | ## 🚀 Next.js Admin Dashboard 2 | 3 | An admin dashboard built using Next.js framework and Tailwind CSS 4 | 5 | ## 🛠️ How to Run This Project 6 | 7 | ### 📋 Prerequisites 8 | 9 | - Ensure you have [Node.js](https://nodejs.org/) installed. 10 | - Ensure you have [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/) installed. 11 | 12 | ### 🌐 Environment Variables 13 | 14 | Create a `.env` file in the root of your project and add the following environment variables: 15 | 16 | ```js 17 | // Postgress Database 18 | POSTGRES_URL= 19 | POSTGRES_PRISMA_URL= 20 | POSTGRES_URL_NON_POOLING= 21 | POSTGRES_USER= 22 | POSTGRES_HOST= 23 | POSTGRES_PASSWORD= 24 | POSTGRES_DATABASE= 25 | 26 | // Auth 27 | AUTH_SECRET= 28 | AUTH_URL=http://localhost:3000 29 | 30 | ``` 31 | 32 | ### 🗄️ Setting up your vercel Postgres database 33 | 34 | 1. Deploy your project to vercel or visit [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for step by step guide 35 | 2. Navigate to the `Storage` tab once your project is deployed 36 | 3. Select Create Database and choose Postgres and click Continue. 37 | 4. Choose your region and storage plan, if required 38 | 5. Once connected, navigate to the `.env.local` tab, click Show secret and Copy Snippet. Make sure you reveal the secrets before copying them. 39 | 6. Navigate to your code editor and rename the `.env.example` file to `.env.` Paste in the copied contents from Vercel. 40 | 41 | ### 🌱 Seeding the database 42 | 43 | Now that your database has been created, let's seed it with some initial data. 44 | 45 | To seed your database, run the following command 46 | 47 | ```js 48 | npm run seed 49 | ``` 50 | 51 | ### 🚀 Start your local server 52 | 53 | To start your local server, run the following command: 54 | 55 | ```js 56 | npm run dev 57 | ``` 58 | 59 | ### 🔑 Default Account Credentials 60 | 61 | Once you've seeded your database with the initial data, the default login creadentials are: 62 | 63 | ```js 64 | Email: user@nextmail.com 65 | Password: 123456 66 | ``` 67 | 68 | ### 💡 Pro Tip 69 | 70 | "Great dashboards are built with great attention to detail. Keep iterating and improving!" 71 | 72 | ### 🤝 Contributing 73 | 74 | I welcome all kinds of contributions! Feel free to open issues or submit pull requests. 75 | -------------------------------------------------------------------------------- /app/dashboard/(overview)/loading.tsx: -------------------------------------------------------------------------------- 1 | import DashboardSkeleton from '@/app/ui/skeletons'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /app/dashboard/(overview)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Metadata } from 'next'; 3 | 4 | import CardWrapper from '@/app/ui/dashboard/cards'; 5 | import RevenueChart from '@/app/ui/dashboard/revenue-chart'; 6 | import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; 7 | import { lusitana } from '@/app/ui/fonts'; 8 | import { 9 | RevenueChartSkeleton, 10 | LatestInvoicesSkeleton, 11 | CardsSkeleton, 12 | } from '@/app/ui/skeletons'; 13 | 14 | export const metadata:Metadata={ 15 | title:"Home" 16 | } 17 | 18 | 19 | export default async function DashboardPage() { 20 | 21 | return ( 22 |
23 |

24 | Dashboard 25 |

26 |
27 | }> 28 | 29 | 30 |
31 |
32 | }> 33 | 34 | 35 | }> 36 | 37 | 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/dashboard/customers/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import { Suspense } from "react"; 3 | 4 | import { fetchFilteredCustomers } from "@/app/lib/data"; 5 | import CustomersTable from "@/app/ui/customers/table"; 6 | 7 | export const metadata:Metadata={ 8 | title:"Customers" 9 | } 10 | 11 | export default async function CustomersPage({searchParams}:{searchParams:{ 12 | query?:string; 13 | page?:string; 14 | }}) { 15 | const query = searchParams?.query || ""; 16 | const customers = await fetchFilteredCustomers(query); 17 | return ( 18 | 19 | 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 NotFoundPage() { 5 | return ( 6 |
7 | 8 |

404 Not Found

9 |

Could not find the requested invoice.

10 | 14 | Go Back 15 | 16 |
17 | ); 18 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { Metadata } from 'next'; 3 | 4 | import Form from '@/app/ui/invoices/edit-form'; 5 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 6 | import { fetchCustomers,fetchInvoiceById } from '@/app/lib/data'; 7 | 8 | export const metadata:Metadata={ 9 | title:"Edit Invoice" 10 | } 11 | 12 | export default async function EditInvoicePage({params}:{params:{id:string}}) { 13 | const id = params.id; 14 | const [invoice,customers] = await Promise.all([fetchInvoiceById(id),fetchCustomers()]); 15 | 16 | if(!invoice){ 17 | notFound() 18 | } 19 | 20 | return ( 21 |
22 | 32 |
33 |
34 | ); 35 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | 3 | import Form from '@/app/ui/invoices/create-form'; 4 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 5 | import { fetchCustomers } from '@/app/lib/data'; 6 | 7 | export const metadata:Metadata={ 8 | title:"New Invoice" 9 | } 10 | 11 | export default async function CreateInvoicePage() { 12 | const customers = await fetchCustomers(); 13 | 14 | return ( 15 |
16 | 26 | 27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | export default function InvooiceErrorPage({ 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 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Metadata } from 'next'; 3 | 4 | import Pagination from '@/app/ui/invoices/pagination'; 5 | import Search from '@/app/ui/search'; 6 | import Table from '@/app/ui/invoices/table'; 7 | import { CreateInvoice } from '@/app/ui/invoices/buttons'; 8 | import { lusitana } from '@/app/ui/fonts'; 9 | import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; 10 | import { fetchInvoicesPages } from '@/app/lib/data'; 11 | 12 | export const metadata:Metadata={ 13 | title:'Invoices', 14 | } 15 | 16 | export default async function InvoicesPage({searchParams}:{searchParams?:{ 17 | query?:string; 18 | page?:string; 19 | } 20 | }) { 21 | const query = searchParams?.query || ""; 22 | const currentPage= Number(searchParams?.page || 1) 23 | const totalPages = await fetchInvoicesPages(query) 24 | return ( 25 |
26 |
27 |

Invoices

28 |
29 |
30 | 31 | 32 |
33 | }> 34 | 35 | 36 |
37 | 38 |
39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | 3 | export default function DashboardLayout({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
7 | 8 |
9 |
{children}
10 |
11 | ); 12 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | 3 | import "@/app/ui/global.css" 4 | import {inter} from "@/app/ui/fonts" 5 | 6 | 7 | export const metadata:Metadata={ 8 | title:{ 9 | template:"%s | Acme Dashboard", 10 | default:"Acme Dashboard" 11 | }, 12 | description:"An admin dashboard built using Next.js framework", 13 | metadataBase:new URL("https://dashboard-v1-ashy.vercel.app/") 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/lib/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from 'next/cache'; 4 | import { redirect } from 'next/navigation'; 5 | import { z } from 'zod'; 6 | import { sql } from '@vercel/postgres'; 7 | import { signIn } from '@/auth'; 8 | 9 | const FormSchema=z.object({ 10 | id:z.string(), 11 | customerId:z.string({ 12 | invalid_type_error:"Please select a customer" 13 | }), 14 | amount:z.coerce.number().gt(0,{message:"Please enter an amount greater than $0"}), 15 | status:z.enum(['pending','paid'],{ 16 | invalid_type_error:"Please select an invoice status" 17 | }), 18 | date:z.string(), 19 | }) 20 | 21 | const CreateInvoice=FormSchema.omit({id:true,date:true}) 22 | const UpdateInvoice = FormSchema.omit({ id: true, date: true }); 23 | 24 | 25 | export type State = { 26 | errors?: { 27 | customerId?: string[]; 28 | amount?: string[]; 29 | status?: string[]; 30 | }; 31 | message?: string | null; 32 | } 33 | 34 | export async function createInvoice(prevState: State, formData: FormData){ 35 | // Validate form fields using Zod 36 | const validatedFields = CreateInvoice.safeParse({ 37 | customerId: formData.get('customerId'), 38 | amount: formData.get('amount'), 39 | status: formData.get('status'), 40 | }); 41 | 42 | 43 | if(!validatedFields.success){ 44 | return { 45 | errors:validatedFields.error.flatten().fieldErrors, 46 | message:"Missing Fields.Failed to create invoice" 47 | } 48 | } 49 | 50 | // Prepare data for insertion into the database 51 | const { customerId, amount, status } = validatedFields.data; 52 | const amountInCents=amount * 100; 53 | const date = new Date().toISOString().split('T')[0] 54 | 55 | // Insert data into the database 56 | try { 57 | await sql` 58 | INSERT INTO invoices (customer_id, amount, status, date) 59 | VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) 60 | `; 61 | } catch (error) { 62 | // If a database error occurs, return a more specific error. 63 | return { 64 | message: 'Database Error: Failed to Create Invoice.', 65 | }; 66 | } 67 | 68 | // Revalidate the cache for the invoices page and redirect the user. 69 | revalidatePath('/dashboard/invoices'); 70 | redirect('/dashboard/invoices'); 71 | 72 | } 73 | 74 | export async function updateInvoice(id: string,prevState: State, formData: FormData) { 75 | const validatedFields = UpdateInvoice.safeParse({ 76 | customerId: formData.get('customerId'), 77 | amount: formData.get('amount'), 78 | status: formData.get('status'), 79 | }); 80 | 81 | 82 | if(!validatedFields.success){ 83 | return { 84 | errors:validatedFields.error.flatten().fieldErrors, 85 | message:"Missing Fields.Failed to update invoice" 86 | } 87 | } 88 | 89 | const { customerId, amount, status } =validatedFields.data 90 | const amountInCents = amount * 100; 91 | 92 | try { 93 | await sql` 94 | UPDATE invoices 95 | SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} 96 | WHERE id = ${id} 97 | `; 98 | } catch (error) { 99 | return { message: 'Database Error: Failed to Update Invoice.' }; 100 | } 101 | 102 | revalidatePath('/dashboard/invoices'); 103 | redirect('/dashboard/invoices'); 104 | } 105 | 106 | // Delete invoice by id 107 | export async function deleteInvoice(id: string) { 108 | try { 109 | await sql`DELETE FROM invoices WHERE id = ${id}`; 110 | revalidatePath('/dashboard/invoices'); 111 | return { message: 'Deleted Invoice.' }; 112 | } catch (error) { 113 | return { message: 'Database Error: Failed to Delete Invoice.' }; 114 | } 115 | } 116 | 117 | // Authenticate user credentials 118 | export async function authenticate( 119 | prevState: string | undefined, 120 | formData: FormData, 121 | ) { 122 | try { 123 | await signIn('credentials', Object.fromEntries(formData)); 124 | } catch (error) { 125 | if ((error as Error).message.includes('CredentialsSignin')) { 126 | return 'CredentialsSignin'; 127 | } 128 | throw error; 129 | } 130 | } -------------------------------------------------------------------------------- /app/lib/data.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { unstable_noStore as noStore } from 'next/cache'; 3 | 4 | import { 5 | CustomerField, 6 | CustomersTable, 7 | InvoiceForm, 8 | InvoicesTable, 9 | LatestInvoiceRaw, 10 | User, 11 | Revenue, 12 | } from './definitions'; 13 | import { formatCurrency } from './utils'; 14 | 15 | export async function fetchRevenue() { 16 | // Add noStore() here prevent the response from being cached. 17 | // This is equivalent to in fetch(..., {cache: 'no-store'}). 18 | noStore() 19 | 20 | try { 21 | // Artificially delay a response for demo purposes. 22 | // Don't do this in production :) 23 | 24 | // console.log('Fetching revenue data...'); 25 | // await new Promise((resolve) => setTimeout(resolve, 3000)); 26 | 27 | const data = await sql`SELECT * FROM revenue`; 28 | 29 | // console.log('Data fetch completed after 3 seconds.'); 30 | 31 | return data.rows; 32 | } catch (error) { 33 | console.error('Database Error:', error); 34 | throw new Error('Failed to fetch revenue data.'); 35 | } 36 | } 37 | 38 | export async function fetchLatestInvoices() { 39 | noStore() 40 | try { 41 | const data = await sql` 42 | SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id 43 | FROM invoices 44 | JOIN customers ON invoices.customer_id = customers.id 45 | ORDER BY invoices.date DESC 46 | LIMIT 5`; 47 | 48 | const latestInvoices = data.rows.map((invoice) => ({ 49 | ...invoice, 50 | amount: formatCurrency(invoice.amount), 51 | })); 52 | return latestInvoices; 53 | } catch (error) { 54 | console.error('Database Error:', error); 55 | throw new Error('Failed to fetch the latest invoices.'); 56 | } 57 | } 58 | 59 | export async function fetchCardData() { 60 | noStore() 61 | try { 62 | // You can probably combine these into a single SQL query 63 | // However, we are intentionally splitting them to demonstrate 64 | // how to initialize multiple queries in parallel with JS. 65 | const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`; 66 | const customerCountPromise = sql`SELECT COUNT(*) FROM customers`; 67 | const invoiceStatusPromise = sql`SELECT 68 | SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid", 69 | SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending" 70 | FROM invoices`; 71 | 72 | const data = await Promise.all([ 73 | invoiceCountPromise, 74 | customerCountPromise, 75 | invoiceStatusPromise, 76 | ]); 77 | 78 | const numberOfInvoices = Number(data[0].rows[0].count ?? '0'); 79 | const numberOfCustomers = Number(data[1].rows[0].count ?? '0'); 80 | const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0'); 81 | const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0'); 82 | 83 | return { 84 | numberOfCustomers, 85 | numberOfInvoices, 86 | totalPaidInvoices, 87 | totalPendingInvoices, 88 | }; 89 | } catch (error) { 90 | console.error('Database Error:', error); 91 | throw new Error('Failed to fetch card data.'); 92 | } 93 | } 94 | 95 | const ITEMS_PER_PAGE = 6; 96 | export async function fetchFilteredInvoices( 97 | query: string, 98 | currentPage: number, 99 | ) { 100 | noStore() 101 | const offset = (currentPage - 1) * ITEMS_PER_PAGE; 102 | 103 | try { 104 | const invoices = await sql` 105 | SELECT 106 | invoices.id, 107 | invoices.amount, 108 | invoices.date, 109 | invoices.status, 110 | customers.name, 111 | customers.email, 112 | customers.image_url 113 | FROM invoices 114 | JOIN customers ON invoices.customer_id = customers.id 115 | WHERE 116 | customers.name ILIKE ${`%${query}%`} OR 117 | customers.email ILIKE ${`%${query}%`} OR 118 | invoices.amount::text ILIKE ${`%${query}%`} OR 119 | invoices.date::text ILIKE ${`%${query}%`} OR 120 | invoices.status ILIKE ${`%${query}%`} 121 | ORDER BY invoices.date DESC 122 | LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset} 123 | `; 124 | 125 | return invoices.rows; 126 | } catch (error) { 127 | console.error('Database Error:', error); 128 | throw new Error('Failed to fetch invoices.'); 129 | } 130 | } 131 | 132 | export async function fetchInvoicesPages(query: string) { 133 | noStore() 134 | try { 135 | const count = await sql`SELECT COUNT(*) 136 | FROM invoices 137 | JOIN customers ON invoices.customer_id = customers.id 138 | WHERE 139 | customers.name ILIKE ${`%${query}%`} OR 140 | customers.email ILIKE ${`%${query}%`} OR 141 | invoices.amount::text ILIKE ${`%${query}%`} OR 142 | invoices.date::text ILIKE ${`%${query}%`} OR 143 | invoices.status ILIKE ${`%${query}%`} 144 | `; 145 | 146 | const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE); 147 | return totalPages; 148 | } catch (error) { 149 | console.error('Database Error:', error); 150 | throw new Error('Failed to fetch total number of invoices.'); 151 | } 152 | } 153 | 154 | export async function fetchInvoiceById(id: string) { 155 | noStore() 156 | try { 157 | const data = await sql` 158 | SELECT 159 | invoices.id, 160 | invoices.customer_id, 161 | invoices.amount, 162 | invoices.status 163 | FROM invoices 164 | WHERE invoices.id = ${id}; 165 | `; 166 | 167 | const invoice = data.rows.map((invoice) => ({ 168 | ...invoice, 169 | // Convert amount from cents to dollars 170 | amount: invoice.amount / 100, 171 | })); 172 | 173 | return invoice[0]; 174 | } catch (error) { 175 | console.error('Database Error:', error); 176 | throw new Error('Failed to fetch invoice.'); 177 | } 178 | } 179 | 180 | export async function fetchCustomers() { 181 | noStore() 182 | try { 183 | const data = await sql` 184 | SELECT 185 | id, 186 | name 187 | FROM customers 188 | ORDER BY name ASC 189 | `; 190 | 191 | const customers = data.rows; 192 | return customers; 193 | } catch (err) { 194 | console.error('Database Error:', err); 195 | throw new Error('Failed to fetch all customers.'); 196 | } 197 | } 198 | 199 | export async function fetchFilteredCustomers(query: string) { 200 | noStore() 201 | try { 202 | const data = await sql` 203 | SELECT 204 | customers.id, 205 | customers.name, 206 | customers.email, 207 | customers.image_url, 208 | COUNT(invoices.id) AS total_invoices, 209 | SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending, 210 | SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid 211 | FROM customers 212 | LEFT JOIN invoices ON customers.id = invoices.customer_id 213 | WHERE 214 | customers.name ILIKE ${`%${query}%`} OR 215 | customers.email ILIKE ${`%${query}%`} 216 | GROUP BY customers.id, customers.name, customers.email, customers.image_url 217 | ORDER BY customers.name ASC 218 | `; 219 | 220 | const customers = data.rows.map((customer) => ({ 221 | ...customer, 222 | total_pending: formatCurrency(customer.total_pending), 223 | total_paid: formatCurrency(customer.total_paid), 224 | })); 225 | 226 | return customers; 227 | } catch (err) { 228 | console.error('Database Error:', err); 229 | throw new Error('Failed to fetch customer table.'); 230 | } 231 | } 232 | 233 | export async function getUser(email: string) { 234 | noStore() 235 | try { 236 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 237 | return user.rows[0] as User; 238 | } catch (error) { 239 | console.error('Failed to fetch user:', error); 240 | throw new Error('Failed to fetch user.'); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /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 CustomersTable = { 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/lib/placeholder-data.js: -------------------------------------------------------------------------------- 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: '3958dc9e-712f-4377-85e9-fec4b6a6442a', 15 | name: 'Delba de Oliveira', 16 | email: 'delba@oliveira.com', 17 | image_url: '/customers/delba-de-oliveira.png', 18 | }, 19 | { 20 | id: '3958dc9e-742f-4377-85e9-fec4b6a6442a', 21 | name: 'Lee Robinson', 22 | email: 'lee@robinson.com', 23 | image_url: '/customers/lee-robinson.png', 24 | }, 25 | { 26 | id: '3958dc9e-737f-4377-85e9-fec4b6a6442a', 27 | name: 'Hector Simpson', 28 | email: 'hector@simpson.com', 29 | image_url: '/customers/hector-simpson.png', 30 | }, 31 | { 32 | id: '50ca3e18-62cd-11ee-8c99-0242ac120002', 33 | name: 'Steven Tey', 34 | email: 'steven@tey.com', 35 | image_url: '/customers/steven-tey.png', 36 | }, 37 | { 38 | id: '3958dc9e-787f-4377-85e9-fec4b6a6442a', 39 | name: 'Steph Dietz', 40 | email: 'steph@dietz.com', 41 | image_url: '/customers/steph-dietz.png', 42 | }, 43 | { 44 | id: '76d65c26-f784-44a2-ac19-586678f7c2f2', 45 | name: 'Michael Novotny', 46 | email: 'michael@novotny.com', 47 | image_url: '/customers/michael-novotny.png', 48 | }, 49 | { 50 | id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa', 51 | name: 'Evil Rabbit', 52 | email: 'evil@rabbit.com', 53 | image_url: '/customers/evil-rabbit.png', 54 | }, 55 | { 56 | id: '126eed9c-c90c-4ef6-a4a8-fcf7408d3c66', 57 | name: 'Emil Kowalski', 58 | email: 'emil@kowalski.com', 59 | image_url: '/customers/emil-kowalski.png', 60 | }, 61 | { 62 | id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9', 63 | name: 'Amy Burns', 64 | email: 'amy@burns.com', 65 | image_url: '/customers/amy-burns.png', 66 | }, 67 | { 68 | id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB', 69 | name: 'Balazs Orban', 70 | email: 'balazs@orban.com', 71 | image_url: '/customers/balazs-orban.png', 72 | }, 73 | ]; 74 | 75 | const invoices = [ 76 | { 77 | customer_id: customers[0].id, 78 | amount: 15795, 79 | status: 'pending', 80 | date: '2022-12-06', 81 | }, 82 | { 83 | customer_id: customers[1].id, 84 | amount: 20348, 85 | status: 'pending', 86 | date: '2022-11-14', 87 | }, 88 | { 89 | customer_id: customers[4].id, 90 | amount: 3040, 91 | status: 'paid', 92 | date: '2022-10-29', 93 | }, 94 | { 95 | customer_id: customers[3].id, 96 | amount: 44800, 97 | status: 'paid', 98 | date: '2023-09-10', 99 | }, 100 | { 101 | customer_id: customers[5].id, 102 | amount: 34577, 103 | status: 'pending', 104 | date: '2023-08-05', 105 | }, 106 | { 107 | customer_id: customers[7].id, 108 | amount: 54246, 109 | status: 'pending', 110 | date: '2023-07-16', 111 | }, 112 | { 113 | customer_id: customers[6].id, 114 | amount: 666, 115 | status: 'pending', 116 | date: '2023-06-27', 117 | }, 118 | { 119 | customer_id: customers[3].id, 120 | amount: 32545, 121 | status: 'paid', 122 | date: '2023-06-09', 123 | }, 124 | { 125 | customer_id: customers[4].id, 126 | amount: 1250, 127 | status: 'paid', 128 | date: '2023-06-17', 129 | }, 130 | { 131 | customer_id: customers[5].id, 132 | amount: 8546, 133 | status: 'paid', 134 | date: '2023-06-07', 135 | }, 136 | { 137 | customer_id: customers[1].id, 138 | amount: 500, 139 | status: 'paid', 140 | date: '2023-08-19', 141 | }, 142 | { 143 | customer_id: customers[5].id, 144 | amount: 8945, 145 | status: 'paid', 146 | date: '2023-06-03', 147 | }, 148 | { 149 | customer_id: customers[2].id, 150 | amount: 8945, 151 | status: 'paid', 152 | date: '2023-06-18', 153 | }, 154 | { 155 | customer_id: customers[0].id, 156 | amount: 8945, 157 | status: 'paid', 158 | date: '2023-10-04', 159 | }, 160 | { 161 | customer_id: customers[2].id, 162 | amount: 1000, 163 | status: 'paid', 164 | date: '2022-06-05', 165 | }, 166 | ]; 167 | 168 | const revenue = [ 169 | { month: 'Jan', revenue: 2000 }, 170 | { month: 'Feb', revenue: 1800 }, 171 | { month: 'Mar', revenue: 2200 }, 172 | { month: 'Apr', revenue: 2500 }, 173 | { month: 'May', revenue: 2300 }, 174 | { month: 'Jun', revenue: 3200 }, 175 | { month: 'Jul', revenue: 3500 }, 176 | { month: 'Aug', revenue: 3700 }, 177 | { month: 'Sep', revenue: 2500 }, 178 | { month: 'Oct', revenue: 2800 }, 179 | { month: 'Nov', revenue: 3000 }, 180 | { month: 'Dec', revenue: 4800 }, 181 | ]; 182 | 183 | module.exports = { 184 | users, 185 | customers, 186 | invoices, 187 | revenue, 188 | }; 189 | -------------------------------------------------------------------------------- /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 { Metadata } from 'next'; 2 | 3 | import AcmeLogo from '@/app/ui/acme-logo'; 4 | import LoginForm from '@/app/ui/login-form'; 5 | 6 | export const metadata:Metadata={ 7 | title:"Login" 8 | } 9 | 10 | export default function LoginPage() { 11 | return ( 12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { ArrowRightIcon } from '@heroicons/react/24/outline'; 4 | 5 | import AcmeLogo from '@/app/ui/acme-logo'; 6 | import { lusitana } from '@/app/ui/fonts'; 7 | 8 | export default function Page() { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 |
16 | 17 |
18 | 19 |

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

26 | 30 | Log in 31 | 32 |
33 |
34 | {/* Add Hero Images Here */} 35 | 36 | 37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/ui/acme-logo.tsx: -------------------------------------------------------------------------------- 1 | import { GlobeAltIcon } from '@heroicons/react/24/outline'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | 4 | export default function AcmeLogo() { 5 | return ( 6 |
9 | 10 |

Acme

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function Button({ children, className, ...rest }: ButtonProps) { 8 | return ( 9 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/ui/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 { CustomersTable, FormattedCustomersTable } from '@/app/lib/definitions'; 5 | 6 | export default async function CustomersTable({ 7 | customers, 8 | }: { 9 | customers: FormattedCustomersTable[]; 10 | }) { 11 | return ( 12 |
13 |

14 | Customers 15 |

16 | 17 |
18 |
19 |
20 |
21 |
22 | {customers?.map((customer) => ( 23 |
27 |
28 |
29 |
30 |
31 | 38 |

{customer.name}

39 |
40 |
41 |

42 | {customer.email} 43 |

44 |
45 |
46 |
47 |
48 |

Pending

49 |

{customer.total_pending}

50 |
51 |
52 |

Paid

53 |

{customer.total_paid}

54 |
55 |
56 |
57 |

{customer.total_invoices} invoices

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

{customer.name}

96 |
97 |
99 | {customer.email} 100 | 102 | {customer.total_invoices} 103 | 105 | {customer.total_pending} 106 | 108 | {customer.total_paid} 109 |
114 |
115 | 116 | 117 | 118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /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: comment in this code when you get to this point in the course */} 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export function Card({ 37 | title, 38 | value, 39 | type, 40 | }: { 41 | title: string; 42 | value: number | string; 43 | type: 'invoices' | 'customers' | 'pending' | 'collected'; 44 | }) { 45 | const Icon = iconMap[type]; 46 | 47 | return ( 48 |
49 |
50 | {Icon ? : null} 51 |

{title}

52 |
53 |

57 | {value} 58 |

59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /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 | 8 | 9 | export default async function LatestInvoices() { 10 | const latestInvoices = await fetchLatestInvoices() 11 | return ( 12 |
13 |

14 | Latest Invoices 15 |

16 |
17 | {/* NOTE: comment in this code when you get to this point in the course */} 18 | 19 |
20 | {latestInvoices.map((invoice, i) => { 21 | return ( 22 |
31 |
32 | {`${invoice.name}'s 39 |
40 |

41 | {invoice.name} 42 |

43 |

44 | {invoice.email} 45 |

46 |
47 |
48 |

51 | {invoice.amount} 52 |

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

Updated just now

60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { usePathname } from 'next/navigation'; 4 | import Link from 'next/link'; 5 | import { 6 | UserGroupIcon, 7 | HomeIcon, 8 | DocumentDuplicateIcon, 9 | } from '@heroicons/react/24/outline'; 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 | 36 | 37 |

{link.name}

38 | 39 | ); 40 | })} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /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() { 13 | const revenue = await fetchRevenue(); 14 | const chartHeight = 350; 15 | // NOTE: comment in this code when you get to this point in the course 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: comment in this code when you get to this point in the course */} 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/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 | 7 | export default function SideNav() { 8 | return ( 9 |
10 | 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | { 23 | 'use server'; 24 | await signOut(); 25 | }} 26 | > 27 | 31 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /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({subsets:["latin"],weight:["400","700"]}) -------------------------------------------------------------------------------- /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 | 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/invoices/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; 2 | import Link from 'next/link'; 3 | import { deleteInvoice } from '@/app/lib/actions'; 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 | return ( 31 |
32 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/ui/invoices/create-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useFormState } from 'react-dom'; 4 | import Link from 'next/link'; 5 | import { 6 | CheckIcon, 7 | ClockIcon, 8 | CurrencyDollarIcon, 9 | UserCircleIcon, 10 | } from '@heroicons/react/24/outline'; 11 | 12 | import { CustomerField } from '@/app/lib/definitions'; 13 | import { Button } from '@/app/ui/button'; 14 | import { State, createInvoice } from '@/app/lib/actions'; 15 | 16 | export default function Form({ customers }: { customers: CustomerField[] }) { 17 | const initialState:State = { message: null, errors: {} }; 18 | const [state,dispatch] = useFormState(createInvoice,initialState); 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 | 72 | 73 |
74 |
75 | {state.errors?.amount && state.errors.amount.map((error: string) => ( 76 |

77 | {error} 78 |

79 | ))} 80 |
81 |
82 |
83 | 84 | {/* Invoice Status */} 85 |
86 | 87 | Set the invoice status 88 | 89 |
90 |
91 |
92 | 99 | 105 |
106 |
107 | 114 | 120 |
121 |
122 |
123 |
124 | {state.errors?.status && state.errors.status.map((error: string) => ( 125 |

126 | {error} 127 |

128 | ))} 129 |
130 | 131 |
132 |
133 | {state.message && ( 134 |

135 | {state.message} 136 |

137 | )} 138 |
139 |
140 | 141 |
142 | 146 | Cancel 147 | 148 | 149 |
150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /app/ui/invoices/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useFormState } from 'react-dom'; 4 | import Link from 'next/link'; 5 | import { 6 | CheckIcon, 7 | ClockIcon, 8 | CurrencyDollarIcon, 9 | UserCircleIcon, 10 | } from '@heroicons/react/24/outline'; 11 | import { CustomerField, InvoiceForm } from '@/app/lib/definitions'; 12 | import { Button } from '@/app/ui/button'; 13 | import { State, updateInvoice } from '@/app/lib/actions'; 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,dispatch]=useFormState(updateInvoiceWithId,initialState); 25 | return ( 26 |
27 |
28 | {/* Customer Name */} 29 |
30 | 33 |
34 | 50 | 51 |
52 |
53 | {state.errors?.customerId && state.errors.customerId.map((error: string) => ( 54 |

55 | {error} 56 |

57 | ))} 58 |
59 |
60 | 61 | {/* Invoice Amount */} 62 |
63 | 66 |
67 |
68 | 78 | 79 |
80 |
81 |
82 | {state.errors?.amount && state.errors.amount.map((error: string) => ( 83 |

84 | {error} 85 |

86 | ))} 87 |
88 |
89 | 90 | {/* Invoice Status */} 91 |
92 | 93 | Set the invoice status 94 | 95 |
96 |
97 |
98 | 106 | 112 |
113 |
114 | 122 | 128 |
129 |
130 |
131 |
132 | {state.errors?.status && state.errors.status.map((error: string) => ( 133 |

134 | {error} 135 |

136 | ))} 137 |
138 |
139 |
140 | {state.message && ( 141 |

142 | {state.message} 143 |

144 | )} 145 |
146 |
147 |
148 | 152 | Cancel 153 | 154 | 155 |
156 |
157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /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 { usePathname,useSearchParams } from 'next/navigation'; 7 | 8 | import { generatePagination } from '@/app/lib/utils'; 9 | 10 | export default function Pagination({ totalPages }: { totalPages: number }) { 11 | // NOTE: comment in this code when you get to this point in the course 12 | const pathname=usePathname(); 13 | const searchParams=useSearchParams(); 14 | const currentPage=Number(searchParams.get("page")) || 1; 15 | const allPages = generatePagination(currentPage, totalPages); 16 | 17 | const createPageURL = (pageNumber: number | string) => { 18 | const params = new URLSearchParams(searchParams); 19 | params.set('page', pageNumber.toString()); 20 | return `${pathname}?${params.toString()}`; 21 | }; 22 | 23 | return ( 24 | <> 25 | {/* NOTE: comment in this code when you get to this point in the course */} 26 | 27 |
28 | 33 | 34 |
35 | {allPages.map((page, index) => { 36 | let position: 'first' | 'last' | 'single' | 'middle' | undefined; 37 | 38 | if (index === 0) position = 'first'; 39 | if (index === allPages.length - 1) position = 'last'; 40 | if (allPages.length === 1) position = 'single'; 41 | if (page === '...') position = 'middle'; 42 | 43 | return ( 44 | 51 | ); 52 | })} 53 |
54 | 55 | = totalPages} 59 | /> 60 |
61 | 62 | ); 63 | } 64 | 65 | function PaginationNumber({ 66 | page, 67 | href, 68 | isActive, 69 | position, 70 | }: { 71 | page: number | string; 72 | href: string; 73 | position?: 'first' | 'last' | 'middle' | 'single'; 74 | isActive: boolean; 75 | }) { 76 | const className = clsx( 77 | 'flex h-10 w-10 items-center justify-center text-sm border', 78 | { 79 | 'rounded-l-md': position === 'first' || position === 'single', 80 | 'rounded-r-md': position === 'last' || position === 'single', 81 | 'z-10 bg-blue-600 border-blue-600 text-white': isActive, 82 | 'hover:bg-gray-100': !isActive && position !== 'middle', 83 | 'text-gray-300': position === 'middle', 84 | }, 85 | ); 86 | 87 | return isActive || position === 'middle' ? ( 88 |
{page}
89 | ) : ( 90 | 91 | {page} 92 | 93 | ); 94 | } 95 | 96 | function PaginationArrow({ 97 | href, 98 | direction, 99 | isDisabled, 100 | }: { 101 | href: string; 102 | direction: 'left' | 'right'; 103 | isDisabled?: boolean; 104 | }) { 105 | const className = clsx( 106 | 'flex h-10 w-10 items-center justify-center rounded-md border', 107 | { 108 | 'pointer-events-none text-gray-300': isDisabled, 109 | 'hover:bg-gray-100': !isDisabled, 110 | 'mr-2 md:mr-4': direction === 'left', 111 | 'ml-2 md:ml-4': direction === 'right', 112 | }, 113 | ); 114 | 115 | const icon = 116 | direction === 'left' ? ( 117 | 118 | ) : ( 119 | 120 | ); 121 | 122 | return isDisabled ? ( 123 |
{icon}
124 | ) : ( 125 | 126 | {icon} 127 | 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /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/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 | {`${invoice.name}'s 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 | 99 | 102 | 105 | 108 | 111 | 117 | 118 | ))} 119 | 120 |
61 | Customer 62 | 64 | Email 65 | 67 | Amount 68 | 70 | Date 71 | 73 | Status 74 | 76 | Edit 77 |
87 |
88 | {`${invoice.name}'s 96 |

{invoice.name}

97 |
98 |
100 | {invoice.email} 101 | 103 | {formatCurrency(invoice.amount)} 104 | 106 | {formatDateToLocal(invoice.date)} 107 | 109 | 110 | 112 |
113 | 114 | 115 |
116 |
121 |
122 |
123 |
124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /app/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState, useFormStatus } from 'react-dom'; 4 | import { 5 | AtSymbolIcon, 6 | KeyIcon, 7 | ExclamationCircleIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import { ArrowRightIcon } from '@heroicons/react/20/solid'; 10 | 11 | import { Button } from './button'; 12 | import { lusitana } from '@/app/ui/fonts'; 13 | import { authenticate } from '@/app/lib/actions'; 14 | 15 | export default function LoginForm() { 16 | const [state, dispatch] = useFormState(authenticate, undefined); 17 | return ( 18 |
19 |
20 |

21 | Please log in to continue. 22 |

23 |
24 |
25 | 31 |
32 | 41 | 42 |
43 |
44 |
45 | 51 |
52 | 62 | 63 |
64 |
65 |
66 | 67 |
68 | {/* Add form errors here */} 69 |
74 | {state === 'CredentialsSignin' && ( 75 | <> 76 | 77 |

Invalid credentials

78 | 79 | )} 80 |
81 |
82 |
83 |
84 | ); 85 | } 86 | 87 | function LoginButton() { 88 | const { pending } = useFormStatus(); 89 | 90 | return ( 91 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /app/ui/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSearchParams,useRouter,usePathname } from 'next/navigation'; 4 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 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:string)=>{ 13 | const params= new URLSearchParams(searchParams) 14 | params.set("page","1") 15 | if(term){ 16 | params.set('query',term) 17 | }else{ 18 | params.delete('query') 19 | } 20 | replace(`${pathname}?${params.toString()}`) 21 | },500); 22 | 23 | return ( 24 |
25 | 28 | handleSearch(e.target.value)} 32 | defaultValue={searchParams.get('query')?.toString()} 33 | /> 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /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 | 32 | if (!user) return null; 33 | 34 | const passwordsMatch = await bcrypt.compare(password, user.password); 35 | 36 | if (passwordsMatch) return user; 37 | } 38 | 39 | console.log('Invalid credentials'); 40 | return null; 41 | }, 42 | }), 43 | ], 44 | }); -------------------------------------------------------------------------------- /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 | "seed": "node -r dotenv/config ./scripts/seed.js", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@heroicons/react": "^2.0.18", 12 | "@tailwindcss/forms": "^0.5.7", 13 | "@types/node": "20.5.7", 14 | "@vercel/postgres": "^0.5.1", 15 | "autoprefixer": "10.4.15", 16 | "bcrypt": "^5.1.1", 17 | "clsx": "^2.0.0", 18 | "next": "^14.0.2", 19 | "next-auth": "^5.0.0-beta.3", 20 | "postcss": "8.4.31", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0", 23 | "tailwindcss": "3.3.3", 24 | "typescript": "5.2.2", 25 | "use-debounce": "^10.0.0", 26 | "zod": "^3.22.2" 27 | }, 28 | "devDependencies": { 29 | "@types/bcrypt": "^5.0.1", 30 | "@types/react": "18.2.21", 31 | "@types/react-dom": "18.2.14", 32 | "@vercel/style-guide": "^5.0.1", 33 | "dotenv": "^16.3.1", 34 | "eslint": "^8.52.0", 35 | "eslint-config-next": "^14.0.0", 36 | "eslint-config-prettier": "9.0.0", 37 | "prettier": "^3.0.3", 38 | "prettier-plugin-tailwindcss": "0.5.4" 39 | }, 40 | "engines": { 41 | "node": ">=18.17.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /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/customers/amy-burns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/amy-burns.png -------------------------------------------------------------------------------- /public/customers/balazs-orban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/balazs-orban.png -------------------------------------------------------------------------------- /public/customers/delba-de-oliveira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/delba-de-oliveira.png -------------------------------------------------------------------------------- /public/customers/emil-kowalski.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/emil-kowalski.png -------------------------------------------------------------------------------- /public/customers/evil-rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/evil-rabbit.png -------------------------------------------------------------------------------- /public/customers/guillermo-rauch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/guillermo-rauch.png -------------------------------------------------------------------------------- /public/customers/hector-simpson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/hector-simpson.png -------------------------------------------------------------------------------- /public/customers/jared-palmer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/jared-palmer.png -------------------------------------------------------------------------------- /public/customers/lee-robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/lee-robinson.png -------------------------------------------------------------------------------- /public/customers/michael-novotny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/michael-novotny.png -------------------------------------------------------------------------------- /public/customers/steph-dietz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/steph-dietz.png -------------------------------------------------------------------------------- /public/customers/steven-tey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/customers/steven-tey.png -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/hero-desktop.png -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnMwendwa/nextjs-dashboard/6c4ff3bcc19e49c370857d172f4aed7f2dd5e075/public/hero-mobile.png -------------------------------------------------------------------------------- /scripts/seed.js: -------------------------------------------------------------------------------- 1 | const { db } = require('@vercel/postgres'); 2 | const { 3 | invoices, 4 | customers, 5 | revenue, 6 | users, 7 | } = require('../app/lib/placeholder-data.js'); 8 | const bcrypt = require('bcrypt'); 9 | 10 | async function seedUsers(client) { 11 | try { 12 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 13 | // Create the "users" table if it doesn't exist 14 | const createTable = await client.sql` 15 | CREATE TABLE IF NOT EXISTS users ( 16 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 17 | name VARCHAR(255) NOT NULL, 18 | email TEXT NOT NULL UNIQUE, 19 | password TEXT NOT NULL 20 | ); 21 | `; 22 | 23 | console.log(`Created "users" table`); 24 | 25 | // Insert data into the "users" table 26 | const insertedUsers = await Promise.all( 27 | users.map(async (user) => { 28 | const hashedPassword = await bcrypt.hash(user.password, 10); 29 | return client.sql` 30 | INSERT INTO users (id, name, email, password) 31 | VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) 32 | ON CONFLICT (id) DO NOTHING; 33 | `; 34 | }), 35 | ); 36 | 37 | console.log(`Seeded ${insertedUsers.length} users`); 38 | 39 | return { 40 | createTable, 41 | users: insertedUsers, 42 | }; 43 | } catch (error) { 44 | console.error('Error seeding users:', error); 45 | throw error; 46 | } 47 | } 48 | 49 | async function seedInvoices(client) { 50 | try { 51 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 52 | 53 | // Create the "invoices" table if it doesn't exist 54 | const createTable = await client.sql` 55 | CREATE TABLE IF NOT EXISTS invoices ( 56 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 57 | customer_id UUID NOT NULL, 58 | amount INT NOT NULL, 59 | status VARCHAR(255) NOT NULL, 60 | date DATE NOT NULL 61 | ); 62 | `; 63 | 64 | console.log(`Created "invoices" table`); 65 | 66 | // Insert data into the "invoices" table 67 | const insertedInvoices = await Promise.all( 68 | invoices.map( 69 | (invoice) => client.sql` 70 | INSERT INTO invoices (customer_id, amount, status, date) 71 | VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) 72 | ON CONFLICT (id) DO NOTHING; 73 | `, 74 | ), 75 | ); 76 | 77 | console.log(`Seeded ${insertedInvoices.length} invoices`); 78 | 79 | return { 80 | createTable, 81 | invoices: insertedInvoices, 82 | }; 83 | } catch (error) { 84 | console.error('Error seeding invoices:', error); 85 | throw error; 86 | } 87 | } 88 | 89 | async function seedCustomers(client) { 90 | try { 91 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 92 | 93 | // Create the "customers" table if it doesn't exist 94 | const createTable = await client.sql` 95 | CREATE TABLE IF NOT EXISTS customers ( 96 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 97 | name VARCHAR(255) NOT NULL, 98 | email VARCHAR(255) NOT NULL, 99 | image_url VARCHAR(255) NOT NULL 100 | ); 101 | `; 102 | 103 | console.log(`Created "customers" table`); 104 | 105 | // Insert data into the "customers" table 106 | const insertedCustomers = await Promise.all( 107 | customers.map( 108 | (customer) => client.sql` 109 | INSERT INTO customers (id, name, email, image_url) 110 | VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) 111 | ON CONFLICT (id) DO NOTHING; 112 | `, 113 | ), 114 | ); 115 | 116 | console.log(`Seeded ${insertedCustomers.length} customers`); 117 | 118 | return { 119 | createTable, 120 | customers: insertedCustomers, 121 | }; 122 | } catch (error) { 123 | console.error('Error seeding customers:', error); 124 | throw error; 125 | } 126 | } 127 | 128 | async function seedRevenue(client) { 129 | try { 130 | // Create the "revenue" table if it doesn't exist 131 | const createTable = await client.sql` 132 | CREATE TABLE IF NOT EXISTS revenue ( 133 | month VARCHAR(4) NOT NULL UNIQUE, 134 | revenue INT NOT NULL 135 | ); 136 | `; 137 | 138 | console.log(`Created "revenue" table`); 139 | 140 | // Insert data into the "revenue" table 141 | const insertedRevenue = await Promise.all( 142 | revenue.map( 143 | (rev) => client.sql` 144 | INSERT INTO revenue (month, revenue) 145 | VALUES (${rev.month}, ${rev.revenue}) 146 | ON CONFLICT (month) DO NOTHING; 147 | `, 148 | ), 149 | ); 150 | 151 | console.log(`Seeded ${insertedRevenue.length} revenue`); 152 | 153 | return { 154 | createTable, 155 | revenue: insertedRevenue, 156 | }; 157 | } catch (error) { 158 | console.error('Error seeding revenue:', error); 159 | throw error; 160 | } 161 | } 162 | 163 | async function main() { 164 | const client = await db.connect(); 165 | 166 | await seedUsers(client); 167 | await seedCustomers(client); 168 | await seedInvoices(client); 169 | await seedRevenue(client); 170 | 171 | await client.end(); 172 | } 173 | 174 | main().catch((err) => { 175 | console.error( 176 | 'An error occurred while attempting to seed the database:', 177 | err, 178 | ); 179 | }); 180 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------