├── .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 ├── layout.tsx ├── lib │ ├── actions.ts │ ├── data.ts │ ├── definitions.ts │ ├── pg-local.ts │ ├── placeholder-data.js │ ├── sql-hack.ts │ └── utils.ts ├── login │ └── page.tsx ├── 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 │ ├── home.module.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 ├── favicon.ico ├── hero-desktop.png ├── hero-mobile.png └── opengraph-image.png ├── scripts ├── pg-local.js └── seed.js ├── tailwind.config.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Copy from .env.local on the Vercel dashboard 2 | # https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database 3 | POSTGRES_URL= 4 | POSTGRES_PRISMA_URL= 5 | POSTGRES_URL_NON_POOLING= 6 | POSTGRES_USER= 7 | POSTGRES_HOST= 8 | POSTGRES_PASSWORD= 9 | POSTGRES_DATABASE= 10 | 11 | # `openssl rand -base64 32` 12 | AUTH_SECRET= 13 | AUTH_URL=http://localhost:3000/api/auth 14 | 15 | LOCAL_VERCEL_POSTGRES=true -------------------------------------------------------------------------------- /.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 App Router Course - Starter 2 | 3 | 这是 Next.js 入门课程的示例代码。要了解更多信息,请参阅中文学习教程 [https://qufei1993.github.io/nextjs-learn-cn/](https://qufei1993.github.io/nextjs-learn-cn/)。 4 | -------------------------------------------------------------------------------- /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 RevenueChart from '@/app/ui/dashboard/revenue-chart'; 2 | import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; 3 | import CardWrapper from '@/app/ui/dashboard/cards'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { Suspense } from 'react'; 6 | import { RevenueChartSkeleton, LatestInvoicesSkeleton, CardsSkeleton } from '@/app/ui/skeletons'; 7 | 8 | export default async function Page() { 9 | return ( 10 |
11 |

12 | 仪表板 13 |

14 |
15 | }> 16 | 17 | 18 |
19 |
20 | }> 21 | 22 | 23 | }> 24 | 25 | 26 |
27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /app/dashboard/customers/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Customers Page

; 3 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FaceFrownIcon } from '@heroicons/react/24/outline'; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | 8 |

404 Not Found

9 |

Could not find the requested invoice.

10 | 14 | Go Back 15 | 16 |
17 | ); 18 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/edit-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data'; 4 | import { notFound } from 'next/navigation'; 5 | 6 | export default async function Page({ params }: { params: { id: string } }) { 7 | const id = params.id; 8 | const [invoice, customers] = await Promise.all([ 9 | fetchInvoiceById(id), 10 | fetchCustomers(), 11 | ]); 12 | 13 | if (!invoice) { 14 | notFound(); 15 | } 16 | 17 | return ( 18 |
19 | 29 |
30 |
31 | ); 32 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/create/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/create-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchCustomers } from '@/app/lib/data'; 4 | 5 | export default async function Page() { 6 | const customers = await fetchCustomers(); 7 | 8 | return ( 9 |
10 | 20 | 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string }; 10 | reset: () => void; 11 | }) { 12 | useEffect(() => { 13 | // Optionally log the error to an error reporting service 14 | console.error(error); 15 | }, [error]); 16 | 17 | return ( 18 |
19 |

Something went wrong!

20 | 29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /app/dashboard/invoices/page.tsx: -------------------------------------------------------------------------------- 1 | import Pagination from '@/app/ui/invoices/pagination'; 2 | import Search from '@/app/ui/search'; 3 | import Table from '@/app/ui/invoices/table'; 4 | import { CreateInvoice } from '@/app/ui/invoices/buttons'; 5 | import { lusitana } from '@/app/ui/fonts'; 6 | import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; 7 | import { Suspense } from 'react'; 8 | import { fetchInvoicesPages } from '@/app/lib/data'; 9 | import { Metadata } from 'next'; 10 | 11 | export const metadata: Metadata = { 12 | title: 'Invoices', 13 | }; 14 | export default async function Page({ 15 | searchParams, 16 | }: { 17 | searchParams?: { 18 | query?: string; 19 | page?: string; 20 | }; 21 | }) { 22 | const query = searchParams?.query || ''; 23 | const currentPage = Number(searchParams?.page) || 1; 24 | const totalPages = await fetchInvoicesPages(query); 25 | 26 | return ( 27 |
28 |
29 |

Invoices

30 |
31 |
32 | 33 | 34 |
35 | }> 36 | 37 | 38 |
39 | 40 |
41 | 42 | ); 43 | } -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | 3 | export default function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
7 | 8 |
9 |
{children}
10 |
11 | ); 12 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/ui/global.css'; 2 | import { inter } from '@/app/ui/fonts'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: { 7 | template: '%s | Acme Dashboard', 8 | default: 'Acme Dashboard', 9 | }, 10 | description: 'The official Next.js Learn Dashboard built with App Router.', 11 | metadataBase: new URL('https://next-learn-dashboard.vercel.sh'), 12 | }; 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/lib/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { z } from 'zod'; 4 | import { sql } from './sql-hack'; 5 | import { revalidatePath } from 'next/cache'; 6 | import { redirect } from 'next/navigation'; 7 | import { signIn } from '@/auth'; 8 | import { AuthError } from 'next-auth'; 9 | 10 | const FormSchema = z.object({ 11 | id: z.string(), 12 | customerId: z.string({ 13 | invalid_type_error: 'Please select a customer.', 14 | }), 15 | amount: z.coerce 16 | .number() 17 | .gt(0, { message: 'Please enter an amount greater than $0.' }), 18 | status: z.enum(['pending', 'paid'], { 19 | invalid_type_error: 'Please select an invoice status.', 20 | }), 21 | date: z.string(), 22 | }); 23 | 24 | const CreateInvoice = FormSchema.omit({ id: true, date: true }); 25 | export type State = { 26 | errors?: { 27 | customerId?: string[]; 28 | amount?: string[]; 29 | status?: string[]; 30 | }; 31 | message?: string | null; 32 | }; 33 | export async function createInvoice(prevState: State, formData: FormData) { 34 | const validatedFields = CreateInvoice.safeParse({ 35 | customerId: formData.get('customerId'), 36 | amount: formData.get('amount'), 37 | status: formData.get('status'), 38 | }); 39 | 40 | // If form validation fails, return errors early. Otherwise, continue. 41 | if (!validatedFields.success) { 42 | return { 43 | errors: validatedFields.error.flatten().fieldErrors, 44 | message: 'Missing Fields. Failed to Create Invoice.', 45 | }; 46 | } 47 | 48 | // Prepare data for insertion into the database 49 | const { customerId, amount, status } = validatedFields.data; 50 | const amountInCents = amount * 100; 51 | const date = new Date().toISOString().split('T')[0]; 52 | 53 | try { 54 | await sql` 55 | INSERT INTO invoices (customer_id, amount, status, date) 56 | VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) 57 | `; 58 | } catch (error) { 59 | return { 60 | message: 'Database Error: Failed to Create Invoice.', 61 | }; 62 | } 63 | 64 | revalidatePath('/dashboard/invoices'); 65 | redirect('/dashboard/invoices'); 66 | } 67 | 68 | const UpdateInvoice = FormSchema.omit({ id: true, date: true }); 69 | export async function updateInvoice(id: string, prevState: State, formData: FormData) { 70 | 71 | const validatedFields = UpdateInvoice.safeParse({ 72 | customerId: formData.get('customerId'), 73 | amount: formData.get('amount'), 74 | status: formData.get('status'), 75 | }); 76 | if (!validatedFields.success) { 77 | return { 78 | errors: validatedFields.error.flatten().fieldErrors, 79 | message: 'Missing Fields. Failed to Update Invoice.', 80 | }; 81 | } 82 | 83 | const { customerId, amount, status } = validatedFields.data; 84 | 85 | const amountInCents = amount * 100; 86 | 87 | try { 88 | await sql` 89 | UPDATE invoices 90 | SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} 91 | WHERE id = ${id} 92 | `; 93 | } catch (error) { 94 | return { message: 'Database Error: Failed to Update Invoice.' }; 95 | } 96 | 97 | revalidatePath('/dashboard/invoices'); 98 | redirect('/dashboard/invoices'); 99 | } 100 | 101 | export async function deleteInvoice(id: string) { 102 | try { 103 | await sql`DELETE FROM invoices WHERE id = ${id}`; 104 | revalidatePath('/dashboard/invoices'); 105 | return { message: 'Deleted Invoice.' }; 106 | } catch (error) { 107 | return { message: 'Database Error: Failed to Delete Invoice.' }; 108 | } 109 | } 110 | 111 | export async function authenticate( 112 | prevState: string | undefined, 113 | formData: FormData, 114 | ) { 115 | try { 116 | await signIn('credentials', formData); 117 | } catch (error) { 118 | if (error instanceof AuthError) { 119 | switch (error.type) { 120 | case 'CredentialsSignin': 121 | return 'Invalid credentials.'; 122 | default: 123 | return 'Something went wrong.'; 124 | } 125 | } 126 | throw error; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /app/lib/data.ts: -------------------------------------------------------------------------------- 1 | import { unstable_noStore as noStore } from 'next/cache'; 2 | import { sql } from './sql-hack'; 3 | import { 4 | CustomerField, 5 | CustomersTable, 6 | InvoiceForm, 7 | InvoicesTable, 8 | LatestInvoiceRaw, 9 | User, 10 | Revenue, 11 | } from './definitions'; 12 | import { formatCurrency } from './utils'; 13 | 14 | export async function fetchRevenue() { 15 | // Add noStore() here prevent the response from being cached. 16 | // This is equivalent to in fetch(..., {cache: 'no-store'}). 17 | noStore(); 18 | 19 | try { 20 | // Artificially delay a response for demo purposes. 21 | // Don't do this in production :) 22 | 23 | console.log('Fetching revenue data...'); 24 | await new Promise((resolve) => setTimeout(resolve, 3000)); 25 | console.log(`SELECT * FROM revenue`); 26 | const data = await sql`SELECT * FROM revenue`; 27 | 28 | console.log('Data fetch complete after 3 seconds.'); 29 | 30 | return data.rows; 31 | } catch (error) { 32 | console.error('Database Error:', error); 33 | throw new Error('Failed to fetch revenue data.'); 34 | } 35 | } 36 | 37 | export async function fetchLatestInvoices() { 38 | noStore(); 39 | try { 40 | const data = await sql` 41 | SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id 42 | FROM invoices 43 | JOIN customers ON invoices.customer_id = customers.id 44 | ORDER BY invoices.date DESC 45 | LIMIT 5`; 46 | 47 | const latestInvoices = data.rows.map((invoice) => ({ 48 | ...invoice, 49 | amount: formatCurrency(invoice.amount), 50 | })); 51 | return latestInvoices; 52 | } catch (error) { 53 | console.error('Database Error:', error); 54 | throw new Error('Failed to fetch the latest invoices.'); 55 | } 56 | } 57 | 58 | export async function fetchCardData() { 59 | noStore(); 60 | 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 | try { 182 | const data = await sql` 183 | SELECT 184 | id, 185 | name 186 | FROM customers 187 | ORDER BY name ASC 188 | `; 189 | 190 | const customers = data.rows; 191 | return customers; 192 | } catch (err) { 193 | console.error('Database Error:', err); 194 | throw new Error('Failed to fetch all customers.'); 195 | } 196 | } 197 | 198 | export async function fetchFilteredCustomers(query: string) { 199 | noStore(); 200 | try { 201 | const data = await sql` 202 | SELECT 203 | customers.id, 204 | customers.name, 205 | customers.email, 206 | customers.image_url, 207 | COUNT(invoices.id) AS total_invoices, 208 | SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending, 209 | SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid 210 | FROM customers 211 | LEFT JOIN invoices ON customers.id = invoices.customer_id 212 | WHERE 213 | customers.name ILIKE ${`%${query}%`} OR 214 | customers.email ILIKE ${`%${query}%`} 215 | GROUP BY customers.id, customers.name, customers.email, customers.image_url 216 | ORDER BY customers.name ASC 217 | `; 218 | 219 | const customers = data.rows.map((customer) => ({ 220 | ...customer, 221 | total_pending: formatCurrency(customer.total_pending), 222 | total_paid: formatCurrency(customer.total_paid), 223 | })); 224 | 225 | return customers; 226 | } catch (err) { 227 | console.error('Database Error:', err); 228 | throw new Error('Failed to fetch customer table.'); 229 | } 230 | } 231 | 232 | export async function getUser(email: string) { 233 | try { 234 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 235 | return user.rows[0] as User; 236 | } catch (error) { 237 | console.error('Failed to fetch user:', error); 238 | throw new Error('Failed to fetch user.'); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /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/pg-local.ts: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg'; 2 | import type { 3 | QueryResult, 4 | QueryResultRow, 5 | } from '@neondatabase/serverless'; 6 | 7 | const connectionString = process.env.POSTGRES_URL; 8 | 9 | const pool = new Pool({ 10 | connectionString, 11 | }) 12 | 13 | export async function sql( 14 | strings: TemplateStringsArray, 15 | ...values: Primitive[] 16 | ): Promise> { 17 | const [query, params] = sqlTemplate(strings, ...values); 18 | 19 | // @ts-ignore 20 | const res = await pool.query(query, params); 21 | 22 | // @ts-ignore 23 | return res as unknown as Promise>; 24 | } 25 | 26 | export type Primitive = string | number | boolean | undefined | null; 27 | 28 | export function sqlTemplate( 29 | strings: TemplateStringsArray, 30 | ...values: Primitive[] 31 | ): [string, Primitive[]] { 32 | if (!isTemplateStringsArray(strings) || !Array.isArray(values)) { 33 | throw new Error("It looks like you tried to call `sql` as a function. Make sure to use it as a tagged template.\n\tExample: sql`SELECT * FROM users`, not sql('SELECT * FROM users')"); 34 | } 35 | 36 | let result = strings[0] ?? ''; 37 | 38 | for (let i = 1; i < strings.length; i++) { 39 | result += `$${i}${strings[i] ?? ''}`; 40 | } 41 | 42 | return [result, values]; 43 | } 44 | 45 | function isTemplateStringsArray( 46 | strings: unknown, 47 | ): strings is TemplateStringsArray { 48 | return ( 49 | Array.isArray(strings) && 'raw' in strings && Array.isArray(strings.raw) 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /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/sql-hack.ts: -------------------------------------------------------------------------------- 1 | import { sql as vercelSql } from '@vercel/postgres'; 2 | import { sql as pgLocalSql } from './pg-local'; 3 | 4 | export const sql = process.env.LOCAL_VERCEL_POSTGRES ? pgLocalSql : vercelSql -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | type Revenue = { 2 | month: string; 3 | revenue: number; 4 | }; 5 | 6 | export const formatCurrency = (amount: number) => { 7 | return (amount / 100).toLocaleString('en-US', { 8 | style: 'currency', 9 | currency: 'USD', 10 | }); 11 | }; 12 | 13 | export const formatDateToLocal = ( 14 | dateStr: string, 15 | locale: string = 'en-US', 16 | ) => { 17 | const date = new Date(dateStr); 18 | const options: Intl.DateTimeFormatOptions = { 19 | day: 'numeric', 20 | month: 'short', 21 | year: 'numeric', 22 | }; 23 | const formatter = new Intl.DateTimeFormat(locale, options); 24 | return formatter.format(date); 25 | }; 26 | 27 | export const generateYAxis = (revenue: Revenue[]) => { 28 | // Calculate what labels we need to display on the y-axis 29 | // based on highest record and in 1000s 30 | const yAxisLabels = []; 31 | const highestRecord = Math.max(...revenue.map((month) => month.revenue)); 32 | const topLabel = Math.ceil(highestRecord / 1000) * 1000; 33 | 34 | for (let i = topLabel; i >= 0; i -= 1000) { 35 | yAxisLabels.push(`$${i / 1000}K`); 36 | } 37 | 38 | return { yAxisLabels, topLabel }; 39 | }; 40 | 41 | export const generatePagination = (currentPage: number, totalPages: number) => { 42 | // If the total number of pages is 7 or less, 43 | // display all pages without any ellipsis. 44 | if (totalPages <= 7) { 45 | return Array.from({ length: totalPages }, (_, i) => i + 1); 46 | } 47 | 48 | // If the current page is among the first 3 pages, 49 | // show the first 3, an ellipsis, and the last 2 pages. 50 | if (currentPage <= 3) { 51 | return [1, 2, 3, '...', totalPages - 1, totalPages]; 52 | } 53 | 54 | // If the current page is among the last 3 pages, 55 | // show the first 2, an ellipsis, and the last 3 pages. 56 | if (currentPage >= totalPages - 2) { 57 | return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages]; 58 | } 59 | 60 | // If the current page is somewhere in the middle, 61 | // show the first page, an ellipsis, the current page and its neighbors, 62 | // another ellipsis, and the last page. 63 | return [ 64 | 1, 65 | '...', 66 | currentPage - 1, 67 | currentPage, 68 | currentPage + 1, 69 | '...', 70 | totalPages, 71 | ]; 72 | }; 73 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import LoginForm from '@/app/ui/login-form'; 3 | 4 | export default function LoginPage() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | ); 17 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import styles from '@/app/ui/home.module.css'; 2 | import AcmeLogo from '@/app/ui/acme-logo'; 3 | import { ArrowRightIcon } from '@heroicons/react/24/outline'; 4 | import Link from 'next/link'; 5 | import { lusitana } from '@/app/ui/fonts'; 6 | import Image from 'next/image'; 7 | 8 | export default function Page() { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 |
20 |

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

27 | 31 | Log in 32 | 33 |
34 |
35 | {/* Add Hero Images Here */} 36 | 43 | 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /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 | 25 | return ( 26 | <> 27 | {/* NOTE: comment in this code when you get to this point in the course */} 28 | 29 | 30 | 31 | 32 | 37 | 38 | ); 39 | } 40 | 41 | export function Card({ 42 | title, 43 | value, 44 | type, 45 | }: { 46 | title: string; 47 | value: number | string; 48 | type: 'invoices' | 'customers' | 'pending' | 'collected'; 49 | }) { 50 | const Icon = iconMap[type]; 51 | 52 | return ( 53 |
54 |
55 | {Icon ? : null} 56 |

{title}

57 |
58 |

62 | {value} 63 |

64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /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 | export default async function LatestInvoices() { 8 | const latestInvoices = await fetchLatestInvoices(); 9 | 10 | return ( 11 |
12 |

13 | Latest Invoices 14 |

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

40 | {invoice.name} 41 |

42 |

43 | {invoice.email} 44 |

45 |
46 |
47 |

50 | {invoice.amount} 51 |

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

Updated just now

59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | UserGroupIcon, 5 | HomeIcon, 6 | DocumentDuplicateIcon, 7 | } from '@heroicons/react/24/outline'; 8 | import Link from 'next/link'; 9 | import { usePathname } from 'next/navigation'; 10 | import clsx from 'clsx'; 11 | 12 | // Map of links to display in the side navigation. 13 | // Depending on the size of the application, this would be stored in a database. 14 | const links = [ 15 | { name: 'Home', href: '/dashboard', icon: HomeIcon }, 16 | { 17 | name: 'Invoices', 18 | href: '/dashboard/invoices', 19 | icon: DocumentDuplicateIcon, 20 | }, 21 | { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon }, 22 | ]; 23 | 24 | export default function NavLinks() { 25 | const pathname = usePathname(); 26 | 27 | return ( 28 | <> 29 | {links.map((link) => { 30 | const LinkIcon = link.icon; 31 | return ( 32 | 42 | 43 |

{link.name}

44 | 45 | ); 46 | })} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /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({ 6 | weight: ['400', '700'], 7 | subsets: ['latin'], 8 | }); -------------------------------------------------------------------------------- /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/home.module.css: -------------------------------------------------------------------------------- 1 | .shape { 2 | height: 0; 3 | width: 0; 4 | border-bottom: 30px solid black; 5 | border-left: 20px solid transparent; 6 | border-right: 20px solid transparent; 7 | } -------------------------------------------------------------------------------- /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 | 31 | return ( 32 |
33 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/invoices/create-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField } from '@/app/lib/definitions'; 4 | import Link from 'next/link'; 5 | import { 6 | CheckIcon, 7 | ClockIcon, 8 | CurrencyDollarIcon, 9 | UserCircleIcon, 10 | } from '@heroicons/react/24/outline'; 11 | import { Button } from '@/app/ui/button'; 12 | import { createInvoice } from '@/app/lib/actions'; 13 | import { useFormState } from 'react-dom'; 14 | 15 | export default function Form({ customers }: { customers: CustomerField[] }) { 16 | const initialState = { message: null, errors: {} }; 17 | const [state, dispatch] = useFormState(createInvoice, initialState); 18 | 19 | return ( 20 |
21 |
22 | {/* Customer Name */} 23 |
24 | 27 |
28 | 44 | 45 |
46 |
47 |
48 | {state.errors?.customerId && 49 | state.errors.customerId.map((error: string) => ( 50 |

51 | {error} 52 |

53 | ))} 54 |
55 | 56 | {/* Invoice Amount */} 57 |
58 | 61 |
62 |
63 | 72 | 73 |
74 |
75 |
76 |
77 | {state.errors?.amount && 78 | state.errors.amount.map((error: string) => ( 79 |

80 | {error} 81 |

82 | ))} 83 |
84 | 85 | {/* Invoice Status */} 86 |
87 | 88 | Set the invoice status 89 | 90 |
91 |
92 |
93 | 100 | 106 |
107 |
108 | 115 | 121 |
122 |
123 |
124 |
125 |
126 |
127 | 131 | Cancel 132 | 133 | 134 |
135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /app/ui/invoices/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField, InvoiceForm } from '@/app/lib/definitions'; 4 | import { 5 | CheckIcon, 6 | ClockIcon, 7 | CurrencyDollarIcon, 8 | UserCircleIcon, 9 | } from '@heroicons/react/24/outline'; 10 | import Link from 'next/link'; 11 | import { Button } from '@/app/ui/button'; 12 | import { updateInvoice } from '@/app/lib/actions'; 13 | import { useFormState } from 'react-dom'; 14 | 15 | export default function EditInvoiceForm({ 16 | invoice, 17 | customers, 18 | }: { 19 | invoice: InvoiceForm; 20 | customers: CustomerField[]; 21 | }) { 22 | const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); 23 | const initialState = { message: null, errors: {} }; 24 | const [state, dispatch] = useFormState(updateInvoiceWithId, initialState); 25 | 26 | return ( 27 |
28 |
29 | {/* Customer Name */} 30 |
31 | 34 |
35 | 50 | 51 |
52 |
53 | 54 | {/* Invoice Amount */} 55 |
56 | 59 |
60 |
61 | 70 | 71 |
72 |
73 |
74 | 75 | {/* Invoice Status */} 76 |
77 | 78 | Set the invoice status 79 | 80 |
81 |
82 |
83 | 91 | 97 |
98 |
99 | 107 | 113 |
114 |
115 |
116 |
117 |
118 |
119 | 123 | Cancel 124 | 125 | 126 |
127 |
128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /app/ui/invoices/pagination.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; 4 | import clsx from 'clsx'; 5 | import Link from 'next/link'; 6 | import { generatePagination } from '@/app/lib/utils'; 7 | import { usePathname, useSearchParams } from 'next/navigation'; 8 | 9 | export default function Pagination({ totalPages }: { totalPages: number }) { 10 | // NOTE: comment in this code when you get to this point in the course 11 | const pathname = usePathname(); 12 | const searchParams = useSearchParams(); 13 | const currentPage = Number(searchParams.get('page')) || 1; 14 | 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 | {''} 34 |

{invoice.name}

35 |
36 |

{invoice.email}

37 |
38 | 39 |
40 |
41 |
42 |

43 | {formatCurrency(invoice.amount)} 44 |

45 |

{formatDateToLocal(invoice.date)}

46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 | ))} 54 |
55 | 56 | 57 | 58 | 61 | 64 | 67 | 70 | 73 | 76 | 77 | 78 | 79 | {invoices?.map((invoice) => ( 80 | 84 | 94 | 97 | 100 | 103 | 106 | 112 | 113 | ))} 114 | 115 |
59 | Customer 60 | 62 | Email 63 | 65 | Amount 66 | 68 | Date 69 | 71 | Status 72 | 74 | Edit 75 |
85 |
86 | {''} 91 |

{invoice.name}

92 |
93 |
95 | {invoice.email} 96 | 98 | {formatCurrency(invoice.amount)} 99 | 101 | {formatDateToLocal(invoice.date)} 102 | 104 | 105 | 107 |
108 | 109 | 110 |
111 |
116 |
117 |
118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { 5 | AtSymbolIcon, 6 | KeyIcon, 7 | ExclamationCircleIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import { ArrowRightIcon } from '@heroicons/react/20/solid'; 10 | import { Button } from './button'; 11 | import { useFormState, useFormStatus } from 'react-dom'; 12 | import { authenticate } from '@/app/lib/actions'; 13 | 14 | export default function LoginForm() { 15 | const [errorMessage, dispatch] = useFormState(authenticate, undefined); 16 | 17 | return ( 18 |
19 |
20 |

21 | Please log in to continue. 22 |

23 |
24 |
25 | 31 |
32 | 40 | 41 |
42 |
43 |
44 | 50 |
51 | 60 | 61 |
62 |
63 |
64 | 65 |
70 | {errorMessage && ( 71 | <> 72 | 73 |

{errorMessage}

74 | 75 | )} 76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | function LoginButton() { 83 | const { pending } = useFormStatus(); 84 | 85 | return ( 86 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /app/ui/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 4 | import { useSearchParams, usePathname, useRouter } from 'next/navigation'; 5 | import { useDebouncedCallback } from 'use-debounce'; 6 | 7 | export default function Search({ placeholder }: { placeholder: string }) { 8 | const searchParams = useSearchParams(); 9 | const pathname = usePathname(); 10 | const { replace } = useRouter(); 11 | 12 | const handleSearch = useDebouncedCallback((term: string) => { 13 | // console.log(`Searching... ${term}`); 14 | 15 | const params = new URLSearchParams(searchParams); 16 | params.set('page', '1'); 17 | if (term) { 18 | params.set('query', term); 19 | } else { 20 | params.delete('query'); 21 | } 22 | replace(`${pathname}?${params.toString()}`); 23 | }, 300); 24 | 25 | return ( 26 |
27 | 30 | { 34 | handleSearch(e.target.value); 35 | }} 36 | defaultValue={searchParams.get('query')?.toString()} 37 | /> 38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /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 | trustHost: true, 8 | callbacks: { 9 | authorized({ auth, request: { nextUrl } }) { 10 | const isLoggedIn = !!auth?.user; 11 | const isOnDashboard = nextUrl.pathname.startsWith('/dashboard'); 12 | if (isOnDashboard) { 13 | if (isLoggedIn) return true; 14 | return false; // Redirect unauthenticated users to login page 15 | } else if (isLoggedIn) { 16 | return Response.redirect(new URL('/dashboard', nextUrl)); 17 | } 18 | return true; 19 | }, 20 | }, 21 | providers: [], // Add providers with an empty array for now 22 | } 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 '@/app/lib/sql-hack'; 6 | import type { User } from '@/app/lib/definitions'; 7 | import bcrypt from 'bcrypt'; 8 | 9 | async function getUser(email: string): Promise { 10 | try { 11 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 12 | return user.rows[0]; 13 | } catch (error) { 14 | console.error('Failed to fetch user:', error); 15 | throw new Error('Failed to fetch user.'); 16 | } 17 | } 18 | 19 | export const { auth, signIn, signOut } = NextAuth({ 20 | ...authConfig, 21 | providers: [ 22 | Credentials({ 23 | async authorize(credentials) { 24 | const parsedCredentials = z 25 | .object({ email: z.string().email(), password: z.string().min(6) }) 26 | .safeParse(credentials); 27 | 28 | if (parsedCredentials.success) { 29 | const { email, password } = parsedCredentials.data; 30 | const user = await getUser(email); 31 | if (!user) return null; 32 | 33 | const passwordsMatch = await bcrypt.compare(password, user.password); 34 | if (passwordsMatch) return user; 35 | } 36 | 37 | return null; 38 | }, 39 | }), 40 | ], 41 | }); -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authConfig } from './auth.config'; 3 | 4 | export default NextAuth(authConfig).auth; 5 | 6 | export const config = { 7 | // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher 8 | matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], 9 | }; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "next build", 5 | "dev": "next dev", 6 | "prettier": "prettier --write --ignore-unknown .", 7 | "prettier:check": "prettier --check --ignore-unknown .", 8 | "start": "next start", 9 | "seed": "node -r dotenv/config ./scripts/seed.js" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.0.18", 13 | "@tailwindcss/forms": "^0.5.7", 14 | "@types/node": "20.5.7", 15 | "@vercel/postgres": "^0.5.1", 16 | "autoprefixer": "10.4.15", 17 | "bcrypt": "^5.1.1", 18 | "clsx": "^2.0.0", 19 | "next": "^14.0.2", 20 | "next-auth": "^5.0.0-beta.5", 21 | "pg": "^8.11.3", 22 | "postcss": "8.4.31", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "tailwindcss": "3.3.3", 26 | "typescript": "5.2.2", 27 | "use-debounce": "^10.0.0", 28 | "zod": "^3.22.2" 29 | }, 30 | "devDependencies": { 31 | "@types/bcrypt": "^5.0.1", 32 | "@types/react": "18.2.21", 33 | "@types/react-dom": "18.2.14", 34 | "@vercel/style-guide": "^5.0.1", 35 | "dotenv": "^16.3.1", 36 | "eslint": "^8.52.0", 37 | "eslint-config-next": "^14.0.0", 38 | "eslint-config-prettier": "9.0.0", 39 | "prettier": "^3.0.3", 40 | "prettier-plugin-tailwindcss": "0.5.4" 41 | }, 42 | "engines": { 43 | "node": ">=18.17.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/amy-burns.png -------------------------------------------------------------------------------- /public/customers/balazs-orban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/balazs-orban.png -------------------------------------------------------------------------------- /public/customers/delba-de-oliveira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/delba-de-oliveira.png -------------------------------------------------------------------------------- /public/customers/emil-kowalski.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/emil-kowalski.png -------------------------------------------------------------------------------- /public/customers/evil-rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/evil-rabbit.png -------------------------------------------------------------------------------- /public/customers/guillermo-rauch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/guillermo-rauch.png -------------------------------------------------------------------------------- /public/customers/hector-simpson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/hector-simpson.png -------------------------------------------------------------------------------- /public/customers/jared-palmer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/jared-palmer.png -------------------------------------------------------------------------------- /public/customers/lee-robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/lee-robinson.png -------------------------------------------------------------------------------- /public/customers/michael-novotny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/michael-novotny.png -------------------------------------------------------------------------------- /public/customers/steph-dietz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/steph-dietz.png -------------------------------------------------------------------------------- /public/customers/steven-tey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/customers/steven-tey.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/favicon.ico -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/hero-desktop.png -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/hero-mobile.png -------------------------------------------------------------------------------- /public/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qufei1993/nextjs-learn-example/ccf31f3458690980bcc4c1ae6d153cf45d540e27/public/opengraph-image.png -------------------------------------------------------------------------------- /scripts/pg-local.js: -------------------------------------------------------------------------------- 1 | const { Client } = require('pg'); 2 | 3 | const client = new Client(process.env.POSTGRES_URL); 4 | 5 | exports.getClient = async () => { 6 | if (!client._connected) { 7 | await client.connect(); 8 | } 9 | 10 | // 适配这样的语句查询数据:client.sql`SHOW TIME ZONE;` 11 | client.sql = async (strings, ...values) => { 12 | if (!strings) { 13 | throw new ('sql is required') 14 | } 15 | const [query, params] = sqlTemplate(strings, ...values) 16 | const res = await client.query(query, params); 17 | return res; 18 | } 19 | 20 | return client; 21 | } 22 | 23 | function sqlTemplate(strings, ...values) { 24 | if (!isTemplateStringsArray(strings) || !Array.isArray(values)) { 25 | throw new Error( 26 | 'incorrect_tagged_template_call', 27 | "It looks like you tried to call `sql` as a function. Make sure to use it as a tagged template.\n\tExample: sql`SELECT * FROM users`, not sql('SELECT * FROM users')", 28 | ); 29 | } 30 | 31 | let result = strings[0] ?? ''; 32 | 33 | for (let i = 1; i < strings.length; i++) { 34 | result += `$${i}${strings[i] ?? ''}`; 35 | } 36 | 37 | return [result, values]; 38 | } 39 | 40 | function isTemplateStringsArray(strings) { 41 | return ( 42 | Array.isArray(strings) && 'raw' in strings && Array.isArray(strings.raw) 43 | ); 44 | } 45 | 46 | // (async () => { 47 | // // Test 48 | // try { 49 | // const clientInstance = await exports.getClient(); 50 | // const res = await clientInstance.sql`SHOW TIME ZONE;` 51 | // console.log(res.rows[0].TimeZone) // 'Etc/UTC' 52 | // } catch (err) { 53 | // console.error(err); 54 | // } finally { 55 | // await client.end() 56 | // } 57 | // })(); -------------------------------------------------------------------------------- /scripts/seed.js: -------------------------------------------------------------------------------- 1 | const { db } = require('@vercel/postgres'); 2 | const { getClient } = require('./pg-local'); 3 | const { 4 | invoices, 5 | customers, 6 | revenue, 7 | users, 8 | } = require('../app/lib/placeholder-data.js'); 9 | const bcrypt = require('bcrypt'); 10 | 11 | async function seedUsers(client) { 12 | try { 13 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 14 | // Create the "users" table if it doesn't exist 15 | const createTable = await client.sql` 16 | CREATE TABLE IF NOT EXISTS users ( 17 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 18 | name VARCHAR(255) NOT NULL, 19 | email TEXT NOT NULL UNIQUE, 20 | password TEXT NOT NULL 21 | ); 22 | `; 23 | 24 | console.log(`Created "users" table`); 25 | 26 | // Insert data into the "users" table 27 | const insertedUsers = await Promise.all( 28 | users.map(async (user) => { 29 | const hashedPassword = await bcrypt.hash(user.password, 10); 30 | return client.sql`INSERT INTO users (id, name, email, password) VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) ON CONFLICT (id) DO NOTHING;`; 31 | }), 32 | ); 33 | 34 | console.log(`Seeded ${insertedUsers.length} users`); 35 | 36 | return { 37 | createTable, 38 | users: insertedUsers, 39 | }; 40 | } catch (error) { 41 | console.error('Error seeding users:', error); 42 | throw error; 43 | } 44 | } 45 | 46 | async function seedInvoices(client) { 47 | try { 48 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 49 | 50 | // Create the "invoices" table if it doesn't exist 51 | const createTable = await client.sql` 52 | CREATE TABLE IF NOT EXISTS invoices ( 53 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 54 | customer_id UUID NOT NULL, 55 | amount INT NOT NULL, 56 | status VARCHAR(255) NOT NULL, 57 | date DATE NOT NULL 58 | ); 59 | `; 60 | 61 | console.log(`Created "invoices" table`); 62 | 63 | // Insert data into the "invoices" table 64 | const insertedInvoices = await Promise.all( 65 | invoices.map( 66 | (invoice) => client.sql` 67 | INSERT INTO invoices (customer_id, amount, status, date) 68 | VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) 69 | ON CONFLICT (id) DO NOTHING; 70 | `, 71 | ), 72 | ); 73 | 74 | console.log(`Seeded ${insertedInvoices.length} invoices`); 75 | 76 | return { 77 | createTable, 78 | invoices: insertedInvoices, 79 | }; 80 | } catch (error) { 81 | console.error('Error seeding invoices:', error); 82 | throw error; 83 | } 84 | } 85 | 86 | async function seedCustomers(client) { 87 | try { 88 | await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; 89 | 90 | // Create the "customers" table if it doesn't exist 91 | const createTable = await client.sql` 92 | CREATE TABLE IF NOT EXISTS customers ( 93 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 94 | name VARCHAR(255) NOT NULL, 95 | email VARCHAR(255) NOT NULL, 96 | image_url VARCHAR(255) NOT NULL 97 | ); 98 | `; 99 | 100 | console.log(`Created "customers" table`); 101 | 102 | // Insert data into the "customers" table 103 | const insertedCustomers = await Promise.all( 104 | customers.map( 105 | (customer) => client.sql` 106 | INSERT INTO customers (id, name, email, image_url) 107 | VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) 108 | ON CONFLICT (id) DO NOTHING; 109 | `, 110 | ), 111 | ); 112 | 113 | console.log(`Seeded ${insertedCustomers.length} customers`); 114 | 115 | return { 116 | createTable, 117 | customers: insertedCustomers, 118 | }; 119 | } catch (error) { 120 | console.error('Error seeding customers:', error); 121 | throw error; 122 | } 123 | } 124 | 125 | async function seedRevenue(client) { 126 | try { 127 | // Create the "revenue" table if it doesn't exist 128 | const createTable = await client.sql` 129 | CREATE TABLE IF NOT EXISTS revenue ( 130 | month VARCHAR(4) NOT NULL UNIQUE, 131 | revenue INT NOT NULL 132 | ); 133 | `; 134 | 135 | console.log(`Created "revenue" table`); 136 | 137 | // Insert data into the "revenue" table 138 | const insertedRevenue = await Promise.all( 139 | revenue.map( 140 | (rev) => client.sql` 141 | INSERT INTO revenue (month, revenue) 142 | VALUES (${rev.month}, ${rev.revenue}) 143 | ON CONFLICT (month) DO NOTHING; 144 | `, 145 | ), 146 | ); 147 | 148 | console.log(`Seeded ${insertedRevenue.length} revenue`); 149 | 150 | return { 151 | createTable, 152 | revenue: insertedRevenue, 153 | }; 154 | } catch (error) { 155 | console.error('Error seeding revenue:', error); 156 | throw error; 157 | } 158 | } 159 | 160 | async function main() { 161 | const client = process.env.LOCAL_VERCEL_POSTGRES ? await getClient() : await db.connect(); 162 | 163 | await seedUsers(client); 164 | await seedCustomers(client); 165 | await seedInvoices(client); 166 | await seedRevenue(client); 167 | 168 | await client.end(); 169 | } 170 | 171 | main().catch((err) => { 172 | console.error( 173 | 'An error occurred while attempting to seed the database:', 174 | err, 175 | ); 176 | }); 177 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------