├── .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 |
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 |
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 |
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 |
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 |
66 | Name
67 | |
68 |
69 | Email
70 | |
71 |
72 | Total Invoices
73 | |
74 |
75 | Total Pending
76 | |
77 |
78 | Total Paid
79 | |
80 |
81 |
82 |
83 |
84 | {customers.map((customer) => (
85 |
86 |
87 |
88 |
95 | {customer.name}
96 |
97 | |
98 |
99 | {customer.email}
100 | |
101 |
102 | {customer.total_invoices}
103 | |
104 |
105 | {customer.total_pending}
106 | |
107 |
108 | {customer.total_paid}
109 | |
110 |
111 | ))}
112 |
113 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
61 | Customer
62 | |
63 |
64 | Email
65 | |
66 |
67 | Amount
68 | |
69 |
70 | Date
71 | |
72 |
73 | Status
74 | |
75 |
76 | Edit
77 | |
78 |
79 |
80 |
81 | {invoices?.map((invoice) => (
82 |
86 |
87 |
88 |
96 | {invoice.name}
97 |
98 | |
99 |
100 | {invoice.email}
101 | |
102 |
103 | {formatCurrency(invoice.amount)}
104 | |
105 |
106 | {formatDateToLocal(invoice.date)}
107 | |
108 |
109 |
110 | |
111 |
112 |
113 |
114 |
115 |
116 | |
117 |
118 | ))}
119 |
120 |
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 |
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 |
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 |
44 | );
45 | }
46 |
47 | export function InvoiceSkeleton() {
48 | return (
49 |
59 | );
60 | }
61 |
62 | export function LatestInvoicesSkeleton() {
63 | return (
64 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
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 |
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 |
137 | |
138 |
139 | );
140 | }
141 |
142 | export function InvoicesMobileSkeleton() {
143 | return (
144 |
163 | );
164 | }
165 |
166 | export function InvoicesTableSkeleton() {
167 | return (
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 | Customer
184 | |
185 |
186 | Email
187 | |
188 |
189 | Amount
190 | |
191 |
192 | Date
193 | |
194 |
195 | Status
196 | |
197 |
201 | Edit
202 | |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
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 |
--------------------------------------------------------------------------------