├── .nvmrc ├── .eslintrc.json ├── app ├── favicon.ico ├── opengraph-image.png ├── dashboard │ ├── (overview) │ │ ├── loading.tsx │ │ └── page.tsx │ ├── customers │ │ └── page.tsx │ ├── layout.tsx │ └── invoices │ │ ├── [id] │ │ └── edit │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ │ ├── create │ │ └── page.tsx │ │ ├── error.tsx │ │ └── page.tsx ├── ui │ ├── fonts.ts │ ├── global.css │ ├── acme-logo.tsx │ ├── button.tsx │ ├── invoices │ │ ├── status.tsx │ │ ├── breadcrumbs.tsx │ │ ├── buttons.tsx │ │ ├── pagination.tsx │ │ ├── edit-form.tsx │ │ ├── table.tsx │ │ └── create-form.tsx │ ├── dashboard │ │ ├── sidenav.tsx │ │ ├── nav-links.tsx │ │ ├── cards.tsx │ │ ├── revenue-chart.tsx │ │ └── latest-invoices.tsx │ ├── search.tsx │ ├── login-form.tsx │ ├── customers │ │ └── table.tsx │ └── skeletons.tsx ├── layout.tsx ├── login │ └── page.tsx ├── lib │ ├── utils.ts │ ├── definitions.ts │ ├── actions.ts │ ├── placeholder-data.js │ └── data.ts └── page.tsx ├── public ├── hero-mobile.png ├── hero-desktop.png └── customers │ ├── amy-burns.png │ ├── evil-rabbit.png │ ├── steph-dietz.png │ ├── steven-tey.png │ ├── balazs-orban.png │ ├── emil-kowalski.png │ ├── hector-simpson.png │ ├── jared-palmer.png │ ├── lee-robinson.png │ ├── guillermo-rauch.png │ ├── michael-novotny.png │ └── delba-de-oliveira.png ├── next.config.js ├── postcss.config.js ├── prettier.config.js ├── README.md ├── middleware.ts ├── .env.example ├── .gitignore ├── auth.config.ts ├── tailwind.config.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── auth.ts └── scripts └── seed.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/app/favicon.ico -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/hero-mobile.png -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/app/opengraph-image.png -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/hero-desktop.png -------------------------------------------------------------------------------- /public/customers/amy-burns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/amy-burns.png -------------------------------------------------------------------------------- /public/customers/evil-rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/evil-rabbit.png -------------------------------------------------------------------------------- /public/customers/steph-dietz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/steph-dietz.png -------------------------------------------------------------------------------- /public/customers/steven-tey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/steven-tey.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/customers/balazs-orban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/balazs-orban.png -------------------------------------------------------------------------------- /public/customers/emil-kowalski.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/emil-kowalski.png -------------------------------------------------------------------------------- /public/customers/hector-simpson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/hector-simpson.png -------------------------------------------------------------------------------- /public/customers/jared-palmer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/jared-palmer.png -------------------------------------------------------------------------------- /public/customers/lee-robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/lee-robinson.png -------------------------------------------------------------------------------- /public/customers/guillermo-rauch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/guillermo-rauch.png -------------------------------------------------------------------------------- /public/customers/michael-novotny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/michael-novotny.png -------------------------------------------------------------------------------- /public/customers/delba-de-oliveira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steam/nextjs-dashboard/main/public/customers/delba-de-oliveira.png -------------------------------------------------------------------------------- /app/dashboard/(overview)/loading.tsx: -------------------------------------------------------------------------------- 1 | import DashboardSkeleton from '@/app/ui/skeletons'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/ui/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter, Lusitana } from 'next/font/google'; 2 | 3 | export const inter = Inter({ subsets: ['latin'] }); 4 | export const lusitana = Lusitana({ 5 | subsets: ['latin'], 6 | weight: ['400', '700'], 7 | }); 8 | -------------------------------------------------------------------------------- /app/dashboard/customers/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | 3 | export const metadata: Metadata = { 4 | title: 'Customers', 5 | }; 6 | 7 | export default function Page() { 8 | return

Customers Page

; 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Next.js App Router Course - Starter 2 | 3 | This is the starter template for the Next.js App Router Course. It contains the starting code for the dashboard application. 4 | 5 | For more information, see the [course curriculum](https://nextjs.org/learn) on the Next.js Website. 6 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authConfig } from './auth.config'; 3 | 4 | export default NextAuth(authConfig).auth; 5 | 6 | export const config = { 7 | // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher 8 | matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], 9 | }; 10 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /app/ui/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input[type='number'] { 6 | -moz-appearance: textfield; 7 | appearance: textfield; 8 | } 9 | 10 | input[type='number']::-webkit-inner-spin-button { 11 | -webkit-appearance: none; 12 | margin: 0; 13 | } 14 | 15 | input[type='number']::-webkit-outer-spin-button { 16 | -webkit-appearance: none; 17 | margin: 0; 18 | } 19 | -------------------------------------------------------------------------------- /app/ui/acme-logo.tsx: -------------------------------------------------------------------------------- 1 | import { GlobeAltIcon } from '@heroicons/react/24/outline'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | 4 | export default function AcmeLogo() { 5 | return ( 6 |
9 | 10 |

Acme

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | 3 | export default function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
7 | 8 |
9 |
{children}
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/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 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FaceFrownIcon } from '@heroicons/react/24/outline'; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | 8 |

404 Not Found

9 |

Could not find the requested invoice.

10 | 14 | Go Back 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function Button({ children, className, ...rest }: ButtonProps) { 8 | return ( 9 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from 'next-auth'; 2 | 3 | export const authConfig = { 4 | pages: { 5 | signIn: '/login', 6 | }, 7 | callbacks: { 8 | authorized({ auth, request: { nextUrl } }) { 9 | const isLoggedIn = !!auth?.user; 10 | const isOnDashboard = nextUrl.pathname.startsWith('/dashboard'); 11 | if (isOnDashboard) { 12 | if (isLoggedIn) return true; 13 | return false; // Redirect unauthenticated users to login page 14 | } else if (isLoggedIn) { 15 | return Response.redirect(new URL('/dashboard', nextUrl)); 16 | } 17 | return true; 18 | }, 19 | }, 20 | providers: [], // Add providers with an empty array for now 21 | } satisfies NextAuthConfig; 22 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import LoginForm from '@/app/ui/login-form'; 3 | import { Metadata } from 'next'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Login', 7 | }; 8 | 9 | export default function LoginPage() { 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | import { Metadata } from 'next'; 5 | 6 | export const metadata: Metadata = { 7 | title: 'Create Invoice', 8 | }; 9 | 10 | export default async function Page() { 11 | const customers = await fetchCustomers(); 12 | 13 | return ( 14 |
15 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | } 32 | -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/edit-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data'; 4 | import { notFound } from 'next/navigation'; 5 | import { Metadata } from 'next'; 6 | 7 | export const metadata: Metadata = { 8 | title: 'Edit Invoice', 9 | }; 10 | 11 | export default async function Page({ params }: { params: { id: string } }) { 12 | const id = params.id; 13 | const [invoice, customers] = await Promise.all([ 14 | fetchInvoiceById(id), 15 | fetchCustomers(), 16 | ]); 17 | 18 | if (!invoice) { 19 | notFound(); 20 | } 21 | 22 | return ( 23 |
24 | 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/ui/invoices/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import Link from 'next/link'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | 5 | interface Breadcrumb { 6 | label: string; 7 | href: string; 8 | active?: boolean; 9 | } 10 | 11 | export default function Breadcrumbs({ 12 | breadcrumbs, 13 | }: { 14 | breadcrumbs: Breadcrumb[]; 15 | }) { 16 | return ( 17 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sean Dougherty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /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 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@heroicons/react": "^2.0.18", 14 | "@tailwindcss/forms": "^0.5.7", 15 | "@types/node": "20.5.7", 16 | "@vercel/postgres": "^0.5.1", 17 | "autoprefixer": "10.4.15", 18 | "bcrypt": "^5.1.1", 19 | "clsx": "^2.0.0", 20 | "next": "^14.0.2", 21 | "next-auth": "^5.0.0-beta.5", 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 | -------------------------------------------------------------------------------- /app/dashboard/(overview)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/app/ui/dashboard/cards'; 2 | import RevenueChart from '@/app/ui/dashboard/revenue-chart'; 3 | import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { fetchCardData } from '@/app/lib/data'; 6 | import { Suspense } from 'react'; 7 | import { 8 | CardSkeleton, 9 | LatestInvoicesSkeleton, 10 | RevenueChartSkeleton, 11 | } from '@/app/ui/skeletons'; 12 | import CardWrapper from '@/app/ui/dashboard/cards'; 13 | import { Metadata } from 'next'; 14 | 15 | export const metadata: Metadata = { 16 | title: 'Dashboard', 17 | }; 18 | 19 | export default async function Page() { 20 | return ( 21 |
22 |

23 | Dashboard 24 |

25 |
26 | }> 27 | 28 | 29 |
30 |
31 | }> 32 | 33 | 34 | }> 35 | 36 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/ui/invoices/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; 2 | import { deleteInvoice } from '@/app/lib/actions'; 3 | import Link from 'next/link'; 4 | 5 | export function CreateInvoice() { 6 | return ( 7 | 11 | Create Invoice{' '} 12 | 13 | 14 | ); 15 | } 16 | 17 | export function UpdateInvoice({ id }: { id: string }) { 18 | return ( 19 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export function DeleteInvoice({ id }: { id: string }) { 29 | const deleteInvoiceWithId = deleteInvoice.bind(null, id); 30 | return ( 31 | 32 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import Credentials from 'next-auth/providers/credentials'; 3 | import { authConfig } from './auth.config'; 4 | import { z } from 'zod'; 5 | import { sql } from '@vercel/postgres'; 6 | import type { User } from '@/app/lib/definitions'; 7 | import bcrypt from 'bcrypt'; 8 | 9 | async function getUser(email: string): Promise { 10 | try { 11 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 12 | return user.rows[0]; 13 | } catch (error) { 14 | console.error('Failed to fetch user:', error); 15 | throw new Error('Failed to fetch user.'); 16 | } 17 | } 18 | 19 | export const { auth, signIn, signOut } = NextAuth({ 20 | ...authConfig, 21 | providers: [ 22 | Credentials({ 23 | async authorize(credentials) { 24 | const parsedCredentials = z 25 | .object({ email: z.string().email(), password: z.string().min(6) }) 26 | .safeParse(credentials); 27 | 28 | if (parsedCredentials.success) { 29 | const { email, password } = parsedCredentials.data; 30 | const user = await getUser(email); 31 | if (!user) return null; 32 | const passwordsMatch = await bcrypt.compare(password, user.password); 33 | 34 | if (passwordsMatch) return user; 35 | } 36 | 37 | console.log('Invalid credentials'); 38 | return null; 39 | }, 40 | }), 41 | ], 42 | }); 43 | -------------------------------------------------------------------------------- /app/ui/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 4 | import { useSearchParams, usePathname, useRouter } from 'next/navigation'; 5 | import { useDebouncedCallback } from 'use-debounce'; 6 | 7 | export default function Search({ placeholder }: { placeholder: string }) { 8 | const searchParams = useSearchParams(); 9 | const pathname = usePathname(); 10 | const { replace } = useRouter(); 11 | 12 | const handleSearch = useDebouncedCallback((term) => { 13 | console.log(`Searching... ${term}`); 14 | const params = new URLSearchParams(searchParams); 15 | params.set('page', '1'); 16 | if (term) { 17 | params.set('query', term); 18 | } else { 19 | params.delete('query'); 20 | } 21 | replace(`${pathname}?${params.toString()}`); 22 | }, 300); 23 | 24 | return ( 25 |
26 | 29 | { 33 | handleSearch(e.target.value); 34 | }} 35 | defaultValue={searchParams.get('query')?.toString()} 36 | /> 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | UserGroupIcon, 5 | HomeIcon, 6 | DocumentDuplicateIcon, 7 | } from '@heroicons/react/24/outline'; 8 | import Link from 'next/link'; 9 | import { usePathname } from 'next/navigation'; 10 | import clsx from 'clsx'; 11 | 12 | // Map of links to display in the side navigation. 13 | // Depending on the size of the application, this would be stored in a database. 14 | const links = [ 15 | { name: 'Home', href: '/dashboard', icon: HomeIcon }, 16 | { 17 | name: 'Invoices', 18 | href: '/dashboard/invoices', 19 | icon: DocumentDuplicateIcon, 20 | }, 21 | { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon }, 22 | ]; 23 | 24 | export default function NavLinks() { 25 | const pathname = usePathname(); 26 | return ( 27 | <> 28 | {links.map((link) => { 29 | const LinkIcon = link.icon; 30 | return ( 31 | 41 | 42 |

{link.name}

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

Invoices

31 |
32 |
33 | 34 | 35 |
36 | }> 37 | 38 | 39 |
40 | 41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /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/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Revenue } from './definitions'; 2 | 3 | export const formatCurrency = (amount: number) => { 4 | return (amount / 100).toLocaleString('en-US', { 5 | style: 'currency', 6 | currency: 'USD', 7 | }); 8 | }; 9 | 10 | export const formatDateToLocal = ( 11 | dateStr: string, 12 | locale: string = 'en-US', 13 | ) => { 14 | const date = new Date(dateStr); 15 | const options: Intl.DateTimeFormatOptions = { 16 | day: 'numeric', 17 | month: 'short', 18 | year: 'numeric', 19 | }; 20 | const formatter = new Intl.DateTimeFormat(locale, options); 21 | return formatter.format(date); 22 | }; 23 | 24 | export const generateYAxis = (revenue: Revenue[]) => { 25 | // Calculate what labels we need to display on the y-axis 26 | // based on highest record and in 1000s 27 | const yAxisLabels = []; 28 | const highestRecord = Math.max(...revenue.map((month) => month.revenue)); 29 | const topLabel = Math.ceil(highestRecord / 1000) * 1000; 30 | 31 | for (let i = topLabel; i >= 0; i -= 1000) { 32 | yAxisLabels.push(`$${i / 1000}K`); 33 | } 34 | 35 | return { yAxisLabels, topLabel }; 36 | }; 37 | 38 | export const generatePagination = (currentPage: number, totalPages: number) => { 39 | // If the total number of pages is 7 or less, 40 | // display all pages without any ellipsis. 41 | if (totalPages <= 7) { 42 | return Array.from({ length: totalPages }, (_, i) => i + 1); 43 | } 44 | 45 | // If the current page is among the first 3 pages, 46 | // show the first 3, an ellipsis, and the last 2 pages. 47 | if (currentPage <= 3) { 48 | return [1, 2, 3, '...', totalPages - 1, totalPages]; 49 | } 50 | 51 | // If the current page is among the last 3 pages, 52 | // show the first 2, an ellipsis, and the last 3 pages. 53 | if (currentPage >= totalPages - 2) { 54 | return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages]; 55 | } 56 | 57 | // If the current page is somewhere in the middle, 58 | // show the first page, an ellipsis, the current page and its neighbors, 59 | // another ellipsis, and the last page. 60 | return [ 61 | 1, 62 | '...', 63 | currentPage - 1, 64 | currentPage, 65 | currentPage + 1, 66 | '...', 67 | totalPages, 68 | ]; 69 | }; 70 | -------------------------------------------------------------------------------- /app/lib/definitions.ts: -------------------------------------------------------------------------------- 1 | // This file contains type definitions for your data. 2 | // It describes the shape of the data, and what data type each property should accept. 3 | // For simplicity of teaching, we're manually defining these types. 4 | // However, these types are generated automatically if you're using an ORM such as Prisma. 5 | export type User = { 6 | id: string; 7 | name: string; 8 | email: string; 9 | password: string; 10 | }; 11 | 12 | export type Customer = { 13 | id: string; 14 | name: string; 15 | email: string; 16 | image_url: string; 17 | }; 18 | 19 | export type Invoice = { 20 | id: string; 21 | customer_id: string; 22 | amount: number; 23 | date: string; 24 | // In TypeScript, this is called a string union type. 25 | // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'. 26 | status: 'pending' | 'paid'; 27 | }; 28 | 29 | export type Revenue = { 30 | month: string; 31 | revenue: number; 32 | }; 33 | 34 | export type LatestInvoice = { 35 | id: string; 36 | name: string; 37 | image_url: string; 38 | email: string; 39 | amount: string; 40 | }; 41 | 42 | // The database returns a number for amount, but we later format it to a string with the formatCurrency function 43 | export type LatestInvoiceRaw = Omit & { 44 | amount: number; 45 | }; 46 | 47 | export type InvoicesTable = { 48 | id: string; 49 | customer_id: string; 50 | name: string; 51 | email: string; 52 | image_url: string; 53 | date: string; 54 | amount: number; 55 | status: 'pending' | 'paid'; 56 | }; 57 | 58 | export type CustomersTableType = { 59 | id: string; 60 | name: string; 61 | email: string; 62 | image_url: string; 63 | total_invoices: number; 64 | total_pending: number; 65 | total_paid: number; 66 | }; 67 | 68 | export type FormattedCustomersTable = { 69 | id: string; 70 | name: string; 71 | email: string; 72 | image_url: string; 73 | total_invoices: number; 74 | total_pending: string; 75 | total_paid: string; 76 | }; 77 | 78 | export type CustomerField = { 79 | id: string; 80 | name: string; 81 | }; 82 | 83 | export type InvoiceForm = { 84 | id: string; 85 | customer_id: string; 86 | amount: number; 87 | status: 'pending' | 'paid'; 88 | }; 89 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import { ArrowRightIcon } from '@heroicons/react/24/outline'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import Link from 'next/link'; 5 | import Image from 'next/image'; 6 | 7 | export default function Page() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 |

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

25 | 29 | Log in 30 | 31 |
32 |
33 | 40 | 47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/ui/dashboard/revenue-chart.tsx: -------------------------------------------------------------------------------- 1 | import { generateYAxis } from '@/app/lib/utils'; 2 | import { CalendarIcon } from '@heroicons/react/24/outline'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { Revenue } from '@/app/lib/definitions'; 5 | import { fetchRevenue } from '@/app/lib/data'; 6 | 7 | // This component is representational only. 8 | // For data visualization UI, check out: 9 | // https://www.tremor.so/ 10 | // https://www.chartjs.org/ 11 | // https://airbnb.io/visx/ 12 | 13 | export default async function RevenueChart() { 14 | // Make component async, remove the props 15 | const revenue = await fetchRevenue(); // Fetch data inside the component 16 | 17 | const chartHeight = 350; 18 | // NOTE: comment in this code when you get to this point in the course 19 | 20 | const { yAxisLabels, topLabel } = generateYAxis(revenue); 21 | 22 | if (!revenue || revenue.length === 0) { 23 | return

No data available.

; 24 | } 25 | 26 | return ( 27 |
28 |

29 | Recent Revenue 30 |

31 | {/* NOTE: comment in this code when you get to this point in the course */} 32 | 33 |
34 |
35 |
39 | {yAxisLabels.map((label) => ( 40 |

{label}

41 | ))} 42 |
43 | 44 | {revenue.map((month) => ( 45 |
46 |
52 |

53 | {month.month} 54 |

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

Last 12 months

61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /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 { LatestInvoice } from '@/app/lib/definitions'; 6 | import { fetchLatestInvoices } from '@/app/lib/data'; 7 | 8 | export default async function LatestInvoices() { 9 | // Make component async, remove the props 10 | const latestInvoices = await fetchLatestInvoices(); 11 | 12 | return ( 13 |
14 |

15 | Latest Invoices 16 |

17 |
18 | {/* NOTE: comment in this code when you get to this point in the course */} 19 | 20 |
21 | {latestInvoices.map((invoice, i) => { 22 | return ( 23 |
32 |
33 | 40 |
41 |

42 | {invoice.name} 43 |

44 |

45 | {invoice.email} 46 |

47 |
48 |
49 |

52 | {invoice.amount} 53 |

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

Updated just now

61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { 5 | AtSymbolIcon, 6 | KeyIcon, 7 | ExclamationCircleIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import { ArrowRightIcon } from '@heroicons/react/20/solid'; 10 | import { Button } from './button'; 11 | import { 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 | return ( 85 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /app/lib/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { z } from 'zod'; 4 | import { sql } from '@vercel/postgres'; 5 | import { revalidatePath } from 'next/cache'; 6 | import { redirect } from 'next/navigation'; 7 | import { signIn } from '@/auth'; 8 | import { AuthError } from 'next-auth'; 9 | 10 | 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 | export type State = { 25 | errors?: { 26 | customerId?: string[]; 27 | amount?: string[]; 28 | status?: string[]; 29 | }; 30 | message?: string | null; 31 | }; 32 | 33 | const CreateInvoice = FormSchema.omit({ id: true, date: true }); 34 | const UpdateInvoice = FormSchema.omit({ id: true, date: true }); 35 | 36 | export async function createInvoice(prevState: State, formData: FormData) { 37 | const validatedFields = CreateInvoice.safeParse({ 38 | customerId: formData.get('customerId'), 39 | amount: formData.get('amount'), 40 | status: formData.get('status'), 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 | const { customerId, amount, status } = validatedFields.data; 51 | 52 | const amountInCents = amount * 100; 53 | const date = new Date().toISOString().split('T')[0]; 54 | 55 | try { 56 | await sql` 57 | INSERT INTO invoices (customer_id, amount, status, date) 58 | VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) 59 | `; 60 | } catch (error) { 61 | return { 62 | message: 'Database Error: Failed to Create Invoice.', 63 | }; 64 | } 65 | 66 | revalidatePath('/dashboard/invoices'); 67 | redirect('/dashboard/invoices'); 68 | } 69 | 70 | export async function updateInvoice(id: string, formData: FormData) { 71 | const { customerId, amount, status } = UpdateInvoice.parse({ 72 | customerId: formData.get('customerId'), 73 | amount: formData.get('amount'), 74 | status: formData.get('status'), 75 | }); 76 | 77 | const amountInCents = amount * 100; 78 | 79 | try { 80 | await sql` 81 | UPDATE invoices 82 | SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} 83 | WHERE id = ${id} 84 | `; 85 | } catch (error) { 86 | return { message: 'Database Error: Failed to Update Invoice.' }; 87 | } 88 | 89 | revalidatePath('/dashboard/invoices'); 90 | redirect('/dashboard/invoices'); 91 | } 92 | 93 | export async function deleteInvoice(id: string) { 94 | try { 95 | await sql`DELETE FROM invoices WHERE id = ${id}`; 96 | revalidatePath('/dashboard/invoices'); 97 | return { message: 'Deleted Invoice.' }; 98 | } catch (error) { 99 | return { message: 'Database Error: Failed to Delete Invoice.' }; 100 | } 101 | } 102 | 103 | export async function authenticate( 104 | prevState: string | undefined, 105 | formData: FormData, 106 | ) { 107 | try { 108 | await signIn('credentials', formData); 109 | } catch (error) { 110 | if (error instanceof AuthError) { 111 | switch (error.type) { 112 | case 'CredentialsSignin': 113 | return 'Invalid credentials.'; 114 | default: 115 | return 'Something went wrong.'; 116 | } 117 | } 118 | throw error; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /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 | 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/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/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 | 14 | export default function EditInvoiceForm({ 15 | invoice, 16 | customers, 17 | }: { 18 | invoice: InvoiceForm; 19 | customers: CustomerField[]; 20 | }) { 21 | const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); 22 | 23 | return ( 24 |
25 |
26 | {/* Customer Name */} 27 |
28 | 31 |
32 | 47 | 48 |
49 |
50 | 51 | {/* Invoice Amount */} 52 |
53 | 56 |
57 |
58 | 67 | 68 |
69 |
70 |
71 | 72 | {/* Invoice Status */} 73 |
74 | 75 | Set the invoice status 76 | 77 |
78 |
79 |
80 | 88 | 94 |
95 |
96 | 104 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | 120 | Cancel 121 | 122 | 123 |
124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/ui/invoices/table.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons'; 3 | import InvoiceStatus from '@/app/ui/invoices/status'; 4 | import { formatDateToLocal, formatCurrency } from '@/app/lib/utils'; 5 | import { fetchFilteredInvoices } from '@/app/lib/data'; 6 | 7 | export default async function InvoicesTable({ 8 | query, 9 | currentPage, 10 | }: { 11 | query: string; 12 | currentPage: number; 13 | }) { 14 | const invoices = await fetchFilteredInvoices(query, currentPage); 15 | 16 | return ( 17 |
18 |
19 |
20 |
21 | {invoices?.map((invoice) => ( 22 |
26 |
27 |
28 |
29 | 36 |

{invoice.name}

37 |
38 |

{invoice.email}

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

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

47 |

{formatDateToLocal(invoice.date)}

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

{invoice.name}

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

17 | Customers 18 |

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

{customer.name}

42 |
43 |
44 |

45 | {customer.email} 46 |

47 |
48 |
49 |
50 |
51 |

Pending

52 |

{customer.total_pending}

53 |
54 |
55 |

Paid

56 |

{customer.total_paid}

57 |
58 |
59 |
60 |

{customer.total_invoices} invoices

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

{customer.name}

99 |
100 |
102 | {customer.email} 103 | 105 | {customer.total_invoices} 106 | 108 | {customer.total_pending} 109 | 111 | {customer.total_paid} 112 |
117 |
118 |
119 |
120 |
121 |
122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /app/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 | {state.errors?.customerId && 48 | state.errors.customerId.map((error: string) => ( 49 |

50 | {error} 51 |

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

78 | {error} 79 |

80 | ))} 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/lib/data.ts: -------------------------------------------------------------------------------- 1 | import { sql } from '@vercel/postgres'; 2 | import { 3 | CustomerField, 4 | CustomersTableType, 5 | InvoiceForm, 6 | InvoicesTable, 7 | LatestInvoiceRaw, 8 | User, 9 | Revenue, 10 | } from './definitions'; 11 | import { formatCurrency } from './utils'; 12 | import { unstable_noStore as noStore } from 'next/cache'; 13 | 14 | export async function fetchRevenue() { 15 | // Add noStore() here to 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 | 26 | const data = await sql`SELECT * FROM revenue`; 27 | 28 | console.log('Data fetch completed 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 | try { 61 | // You can probably combine these into a single SQL query 62 | // However, we are intentionally splitting them to demonstrate 63 | // how to initialize multiple queries in parallel with JS. 64 | const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`; 65 | const customerCountPromise = sql`SELECT COUNT(*) FROM customers`; 66 | const invoiceStatusPromise = sql`SELECT 67 | SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid", 68 | SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending" 69 | FROM invoices`; 70 | 71 | const data = await Promise.all([ 72 | invoiceCountPromise, 73 | customerCountPromise, 74 | invoiceStatusPromise, 75 | ]); 76 | 77 | const numberOfInvoices = Number(data[0].rows[0].count ?? '0'); 78 | const numberOfCustomers = Number(data[1].rows[0].count ?? '0'); 79 | const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0'); 80 | const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0'); 81 | 82 | return { 83 | numberOfCustomers, 84 | numberOfInvoices, 85 | totalPaidInvoices, 86 | totalPendingInvoices, 87 | }; 88 | } catch (error) { 89 | console.error('Database Error:', error); 90 | throw new Error('Failed to fetch card data.'); 91 | } 92 | } 93 | 94 | const ITEMS_PER_PAGE = 6; 95 | export async function fetchFilteredInvoices( 96 | query: string, 97 | currentPage: number, 98 | ) { 99 | noStore(); 100 | const offset = (currentPage - 1) * ITEMS_PER_PAGE; 101 | 102 | try { 103 | const invoices = await sql` 104 | SELECT 105 | invoices.id, 106 | invoices.amount, 107 | invoices.date, 108 | invoices.status, 109 | customers.name, 110 | customers.email, 111 | customers.image_url 112 | FROM invoices 113 | JOIN customers ON invoices.customer_id = customers.id 114 | WHERE 115 | customers.name ILIKE ${`%${query}%`} OR 116 | customers.email ILIKE ${`%${query}%`} OR 117 | invoices.amount::text ILIKE ${`%${query}%`} OR 118 | invoices.date::text ILIKE ${`%${query}%`} OR 119 | invoices.status ILIKE ${`%${query}%`} 120 | ORDER BY invoices.date DESC 121 | LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset} 122 | `; 123 | 124 | return invoices.rows; 125 | } catch (error) { 126 | console.error('Database Error:', error); 127 | throw new Error('Failed to fetch invoices.'); 128 | } 129 | } 130 | 131 | export async function fetchInvoicesPages(query: string) { 132 | noStore(); 133 | try { 134 | const count = await sql`SELECT COUNT(*) 135 | FROM invoices 136 | JOIN customers ON invoices.customer_id = customers.id 137 | WHERE 138 | customers.name ILIKE ${`%${query}%`} OR 139 | customers.email ILIKE ${`%${query}%`} OR 140 | invoices.amount::text ILIKE ${`%${query}%`} OR 141 | invoices.date::text ILIKE ${`%${query}%`} OR 142 | invoices.status ILIKE ${`%${query}%`} 143 | `; 144 | 145 | const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE); 146 | return totalPages; 147 | } catch (error) { 148 | console.error('Database Error:', error); 149 | throw new Error('Failed to fetch total number of invoices.'); 150 | } 151 | } 152 | 153 | export async function fetchInvoiceById(id: string) { 154 | noStore(); 155 | try { 156 | const data = await sql` 157 | SELECT 158 | invoices.id, 159 | invoices.customer_id, 160 | invoices.amount, 161 | invoices.status 162 | FROM invoices 163 | WHERE invoices.id = ${id}; 164 | `; 165 | 166 | const invoice = data.rows.map((invoice) => ({ 167 | ...invoice, 168 | // Convert amount from cents to dollars 169 | amount: invoice.amount / 100, 170 | })); 171 | 172 | return invoice[0]; 173 | } catch (error) { 174 | console.error('Database Error:', error); 175 | throw new Error('Failed to fetch invoice.'); 176 | } 177 | } 178 | 179 | export async function fetchCustomers() { 180 | noStore(); 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 | noStore(); 234 | try { 235 | const user = await sql`SELECT * FROM users WHERE email=${email}`; 236 | return user.rows[0] as User; 237 | } catch (error) { 238 | console.error('Failed to fetch user:', error); 239 | throw new Error('Failed to fetch user.'); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------