Added the rest of the files

This commit is contained in:
Toastie 2024-04-18 20:57:35 +12:00
parent b31f5839f5
commit f293c5a14c
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
58 changed files with 9672 additions and 0 deletions

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
# Copy from .env.local on the Vercel dashboard
# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
POSTGRES_URL=
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
# `openssl rand -base64 32`
AUTH_SECRET=
AUTH_URL=http://localhost:3000/api/auth

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
18

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p>Customers Page</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p>Invoices Page</p>
}

12
app/dashboard/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import SideNav from '@/app/ui/dashboard/sidenav';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
<div className="w-full flex-none md:w-64">
<SideNav />
</div>
<div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div>
</div>
)
}

3
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function Page() {
return <p>Dashboard Page</p>
}

14
app/layout.tsx Normal file
View file

@ -0,0 +1,14 @@
import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} antialiased`}>{children}</body>
</html>
);
}

231
app/lib/data.ts Normal file
View file

@ -0,0 +1,231 @@
import { sql } from '@vercel/postgres';
import {
CustomerField,
CustomersTableType,
InvoiceForm,
InvoicesTable,
LatestInvoiceRaw,
User,
Revenue,
} from './definitions';
import { formatCurrency } from './utils';
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
try {
// Artificially delay a response for demo purposes.
// Don't do this in production :)
// console.log('Fetching revenue data...');
// await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql<Revenue>`SELECT * FROM revenue`;
// console.log('Data fetch completed after 3 seconds.');
return data.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
export async function fetchLatestInvoices() {
try {
const data = await sql<LatestInvoiceRaw>`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 5`;
const latestInvoices = data.rows.map((invoice) => ({
...invoice,
amount: formatCurrency(invoice.amount),
}));
return latestInvoices;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch the latest invoices.');
}
}
export async function fetchCardData() {
try {
// You can probably combine these into a single SQL query
// However, we are intentionally splitting them to demonstrate
// how to initialize multiple queries in parallel with JS.
const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = sql`SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
FROM invoices`;
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
const numberOfInvoices = Number(data[0].rows[0].count ?? '0');
const numberOfCustomers = Number(data[1].rows[0].count ?? '0');
const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0');
const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0');
return {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
};
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch card data.');
}
}
const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
const invoices = await sql<InvoicesTable>`
SELECT
invoices.id,
invoices.amount,
invoices.date,
invoices.status,
customers.name,
customers.email,
customers.image_url
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
ORDER BY invoices.date DESC
LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
`;
return invoices.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoices.');
}
}
export async function fetchInvoicesPages(query: string) {
try {
const count = await sql`SELECT COUNT(*)
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
`;
const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE);
return totalPages;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch total number of invoices.');
}
}
export async function fetchInvoiceById(id: string) {
try {
const data = await sql<InvoiceForm>`
SELECT
invoices.id,
invoices.customer_id,
invoices.amount,
invoices.status
FROM invoices
WHERE invoices.id = ${id};
`;
const invoice = data.rows.map((invoice) => ({
...invoice,
// Convert amount from cents to dollars
amount: invoice.amount / 100,
}));
return invoice[0];
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoice.');
}
}
export async function fetchCustomers() {
try {
const data = await sql<CustomerField>`
SELECT
id,
name
FROM customers
ORDER BY name ASC
`;
const customers = data.rows;
return customers;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch all customers.');
}
}
export async function fetchFilteredCustomers(query: string) {
try {
const data = await sql<CustomersTableType>`
SELECT
customers.id,
customers.name,
customers.email,
customers.image_url,
COUNT(invoices.id) AS total_invoices,
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
FROM customers
LEFT JOIN invoices ON customers.id = invoices.customer_id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`}
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
`;
const customers = data.rows.map((customer) => ({
...customer,
total_pending: formatCurrency(customer.total_pending),
total_paid: formatCurrency(customer.total_paid),
}));
return customers;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch customer table.');
}
}
export async function getUser(email: string) {
try {
const user = await sql`SELECT * FROM users WHERE email=${email}`;
return user.rows[0] as User;
} catch (error) {
console.error('Failed to fetch user:', error);
throw new Error('Failed to fetch user.');
}
}

88
app/lib/definitions.ts Normal file
View file

@ -0,0 +1,88 @@
// This file contains type definitions for your data.
// It describes the shape of the data, and what data type each property should accept.
// For simplicity of teaching, we're manually defining these types.
// However, these types are generated automatically if you're using an ORM such as Prisma.
export type User = {
id: string;
name: string;
email: string;
password: string;
};
export type Customer = {
id: string;
name: string;
email: string;
image_url: string;
};
export type Invoice = {
id: string;
customer_id: string;
amount: number;
date: string;
// In TypeScript, this is called a string union type.
// It means that the "status" property can only be one of the two strings: 'pending' or 'paid'.
status: 'pending' | 'paid';
};
export type Revenue = {
month: string;
revenue: number;
};
export type LatestInvoice = {
id: string;
name: string;
image_url: string;
email: string;
amount: string;
};
// The database returns a number for amount, but we later format it to a string with the formatCurrency function
export type LatestInvoiceRaw = Omit<LatestInvoice, 'amount'> & {
amount: number;
};
export type InvoicesTable = {
id: string;
customer_id: string;
name: string;
email: string;
image_url: string;
date: string;
amount: number;
status: 'pending' | 'paid';
};
export type CustomersTableType = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: number;
total_paid: number;
};
export type FormattedCustomersTable = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: string;
total_paid: string;
};
export type CustomerField = {
id: string;
name: string;
};
export type InvoiceForm = {
id: string;
customer_id: string;
amount: number;
status: 'pending' | 'paid';
};

188
app/lib/placeholder-data.js Normal file
View file

@ -0,0 +1,188 @@
// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
// https://nextjs.org/learn/dashboard-app/fetching-data
const users = [
{
id: '410544b2-4001-4271-9855-fec4b6a6442a',
name: 'User',
email: 'user@nextmail.com',
password: '123456',
},
];
const customers = [
{
id: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
name: 'Delba de Oliveira',
email: 'delba@oliveira.com',
image_url: '/customers/delba-de-oliveira.png',
},
{
id: '3958dc9e-742f-4377-85e9-fec4b6a6442a',
name: 'Lee Robinson',
email: 'lee@robinson.com',
image_url: '/customers/lee-robinson.png',
},
{
id: '3958dc9e-737f-4377-85e9-fec4b6a6442a',
name: 'Hector Simpson',
email: 'hector@simpson.com',
image_url: '/customers/hector-simpson.png',
},
{
id: '50ca3e18-62cd-11ee-8c99-0242ac120002',
name: 'Steven Tey',
email: 'steven@tey.com',
image_url: '/customers/steven-tey.png',
},
{
id: '3958dc9e-787f-4377-85e9-fec4b6a6442a',
name: 'Steph Dietz',
email: 'steph@dietz.com',
image_url: '/customers/steph-dietz.png',
},
{
id: '76d65c26-f784-44a2-ac19-586678f7c2f2',
name: 'Michael Novotny',
email: 'michael@novotny.com',
image_url: '/customers/michael-novotny.png',
},
{
id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa',
name: 'Evil Rabbit',
email: 'evil@rabbit.com',
image_url: '/customers/evil-rabbit.png',
},
{
id: '126eed9c-c90c-4ef6-a4a8-fcf7408d3c66',
name: 'Emil Kowalski',
email: 'emil@kowalski.com',
image_url: '/customers/emil-kowalski.png',
},
{
id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9',
name: 'Amy Burns',
email: 'amy@burns.com',
image_url: '/customers/amy-burns.png',
},
{
id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB',
name: 'Balazs Orban',
email: 'balazs@orban.com',
image_url: '/customers/balazs-orban.png',
},
];
const invoices = [
{
customer_id: customers[0].id,
amount: 15795,
status: 'pending',
date: '2022-12-06',
},
{
customer_id: customers[1].id,
amount: 20348,
status: 'pending',
date: '2022-11-14',
},
{
customer_id: customers[4].id,
amount: 3040,
status: 'paid',
date: '2022-10-29',
},
{
customer_id: customers[3].id,
amount: 44800,
status: 'paid',
date: '2023-09-10',
},
{
customer_id: customers[5].id,
amount: 34577,
status: 'pending',
date: '2023-08-05',
},
{
customer_id: customers[7].id,
amount: 54246,
status: 'pending',
date: '2023-07-16',
},
{
customer_id: customers[6].id,
amount: 666,
status: 'pending',
date: '2023-06-27',
},
{
customer_id: customers[3].id,
amount: 32545,
status: 'paid',
date: '2023-06-09',
},
{
customer_id: customers[4].id,
amount: 1250,
status: 'paid',
date: '2023-06-17',
},
{
customer_id: customers[5].id,
amount: 8546,
status: 'paid',
date: '2023-06-07',
},
{
customer_id: customers[1].id,
amount: 500,
status: 'paid',
date: '2023-08-19',
},
{
customer_id: customers[5].id,
amount: 8945,
status: 'paid',
date: '2023-06-03',
},
{
customer_id: customers[2].id,
amount: 8945,
status: 'paid',
date: '2023-06-18',
},
{
customer_id: customers[0].id,
amount: 8945,
status: 'paid',
date: '2023-10-04',
},
{
customer_id: customers[2].id,
amount: 1000,
status: 'paid',
date: '2022-06-05',
},
];
const revenue = [
{ month: 'Jan', revenue: 2000 },
{ month: 'Feb', revenue: 1800 },
{ month: 'Mar', revenue: 2200 },
{ month: 'Apr', revenue: 2500 },
{ month: 'May', revenue: 2300 },
{ month: 'Jun', revenue: 3200 },
{ month: 'Jul', revenue: 3500 },
{ month: 'Aug', revenue: 3700 },
{ month: 'Sep', revenue: 2500 },
{ month: 'Oct', revenue: 2800 },
{ month: 'Nov', revenue: 3000 },
{ month: 'Dec', revenue: 4800 },
];
module.exports = {
users,
customers,
invoices,
revenue,
};

69
app/lib/utils.ts Normal file
View file

@ -0,0 +1,69 @@
import { Revenue } from './definitions';
export const formatCurrency = (amount: number) => {
return (amount / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
});
};
export const formatDateToLocal = (
dateStr: string,
locale: string = 'en-US',
) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
const formatter = new Intl.DateTimeFormat(locale, options);
return formatter.format(date);
};
export const generateYAxis = (revenue: Revenue[]) => {
// Calculate what labels we need to display on the y-axis
// based on highest record and in 1000s
const yAxisLabels = [];
const highestRecord = Math.max(...revenue.map((month) => month.revenue));
const topLabel = Math.ceil(highestRecord / 1000) * 1000;
for (let i = topLabel; i >= 0; i -= 1000) {
yAxisLabels.push(`$${i / 1000}K`);
}
return { yAxisLabels, topLabel };
};
export const generatePagination = (currentPage: number, totalPages: number) => {
// If the total number of pages is 7 or less,
// display all pages without any ellipsis.
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// If the current page is among the first 3 pages,
// show the first 3, an ellipsis, and the last 2 pages.
if (currentPage <= 3) {
return [1, 2, 3, '...', totalPages - 1, totalPages];
}
// If the current page is among the last 3 pages,
// show the first 2, an ellipsis, and the last 3 pages.
if (currentPage >= totalPages - 2) {
return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages];
}
// If the current page is somewhere in the middle,
// show the first page, an ellipsis, the current page and its neighbors,
// another ellipsis, and the last page.
return [
1,
'...',
currentPage - 1,
currentPage,
currentPage + 1,
'...',
totalPages,
];
};

51
app/page.tsx Normal file
View file

@ -0,0 +1,51 @@
import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { lusitana } from '@/app/ui/fonts';
import Image from 'next/image';
export default function Page() {
return (
<main className="flex min-h-screen flex-col p-6">
<div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
<AcmeLogo />
</div>
<div className="mt-4 flex grow flex-col gap-4 md:flex-row">
<div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
<div
className="h-0 w-0 border-b-[30px] border-l-[20px] border-r-[20px] border-b-black border-l-transparent border-r-transparent"
/>
<p className={`${lusitana.className} text-xl text-gray-800 md:text-3xl md:leading-normal`}>
<strong>Welcome to Acme.</strong> This is the example for the{' '}
<a href="https://nextjs.org/learn/" className="text-blue-500">
Next.js Learn Course
</a>
, brought to you by Vercel.
</p>
<Link
href="/login"
className="flex items-center gap-5 self-start rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base"
>
<span>Log in</span> <ArrowRightIcon className="w-5 md:w-6" />
</Link>
</div>
<div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
{/* Add Hero Images Here */}
<Image
src="/hero-desktop.png"
width={1000}
height={760}
className="hidden md:block"
alt="Screenshots of the dashboard project showing desktop version"
/>
<Image
src="/hero-mobile.png"
width={560}
height={620}
className="block md:hidden"
alt="Screenshots of the dashboard project showing mobile version"/>
</div>
</div>
</main>
);
}

13
app/ui/acme-logo.tsx Normal file
View file

@ -0,0 +1,13 @@
import { GlobeAltIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
export default function AcmeLogo() {
return (
<div
className={`${lusitana.className} flex flex-row items-center leading-none text-white`}
>
<GlobeAltIcon className="h-12 w-12 rotate-[15deg]" />
<p className="text-[44px]">Acme</p>
</div>
);
}

19
app/ui/button.tsx Normal file
View file

@ -0,0 +1,19 @@
import clsx from 'clsx';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
export function Button({ children, className, ...rest }: ButtonProps) {
return (
<button
{...rest}
className={clsx(
'flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
className,
)}
>
{children}
</button>
);
}

123
app/ui/customers/table.tsx Normal file
View file

@ -0,0 +1,123 @@
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
import Search from '@/app/ui/search';
import {
CustomersTableType,
FormattedCustomersTable,
} from '@/app/lib/definitions';
export default async function CustomersTable({
customers,
}: {
customers: FormattedCustomersTable[];
}) {
return (
<div className="w-full">
<h1 className={`${lusitana.className} mb-8 text-xl md:text-2xl`}>
Customers
</h1>
<Search placeholder="Search customers..." />
<div className="mt-6 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden rounded-md bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
{customers?.map((customer) => (
<div
key={customer.id}
className="mb-2 w-full rounded-md bg-white p-4"
>
<div className="flex items-center justify-between border-b pb-4">
<div>
<div className="mb-2 flex items-center">
<div className="flex items-center gap-3">
<Image
src={customer.image_url}
className="rounded-full"
alt={`${customer.name}'s profile picture`}
width={28}
height={28}
/>
<p>{customer.name}</p>
</div>
</div>
<p className="text-sm text-gray-500">
{customer.email}
</p>
</div>
</div>
<div className="flex w-full items-center justify-between border-b py-5">
<div className="flex w-1/2 flex-col">
<p className="text-xs">Pending</p>
<p className="font-medium">{customer.total_pending}</p>
</div>
<div className="flex w-1/2 flex-col">
<p className="text-xs">Paid</p>
<p className="font-medium">{customer.total_paid}</p>
</div>
</div>
<div className="pt-4 text-sm">
<p>{customer.total_invoices} invoices</p>
</div>
</div>
))}
</div>
<table className="hidden min-w-full rounded-md text-gray-900 md:table">
<thead className="rounded-md bg-gray-50 text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Name
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Invoices
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Pending
</th>
<th scope="col" className="px-4 py-5 font-medium">
Total Paid
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-900">
{customers.map((customer) => (
<tr key={customer.id} className="group">
<td className="whitespace-nowrap bg-white py-5 pl-4 pr-3 text-sm text-black group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6">
<div className="flex items-center gap-3">
<Image
src={customer.image_url}
className="rounded-full"
alt={`${customer.name}'s profile picture`}
width={28}
height={28}
/>
<p>{customer.name}</p>
</div>
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
{customer.email}
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
{customer.total_invoices}
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
{customer.total_pending}
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md">
{customer.total_paid}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,58 @@
import {
BanknotesIcon,
ClockIcon,
UserGroupIcon,
InboxIcon,
} from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
const iconMap = {
collected: BanknotesIcon,
customers: UserGroupIcon,
pending: ClockIcon,
invoices: InboxIcon,
};
export default async function CardWrapper() {
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
{/* <Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/> */}
</>
);
}
export function Card({
title,
value,
type,
}: {
title: string;
value: number | string;
type: 'invoices' | 'customers' | 'pending' | 'collected';
}) {
const Icon = iconMap[type];
return (
<div className="rounded-xl bg-gray-50 p-2 shadow-sm">
<div className="flex p-4">
{Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null}
<h3 className="ml-2 text-sm font-medium">{title}</h3>
</div>
<p
className={`${lusitana.className}
truncate rounded-xl bg-white px-4 py-8 text-center text-2xl`}
>
{value}
</p>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
import { LatestInvoice } from '@/app/lib/definitions';
export default async function LatestInvoices({
latestInvoices,
}: {
latestInvoices: LatestInvoice[];
}) {
return (
<div className="flex w-full flex-col md:col-span-4">
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Latest Invoices
</h2>
<div className="flex grow flex-col justify-between rounded-xl bg-gray-50 p-4">
{/* NOTE: comment in this code when you get to this point in the course */}
{/* <div className="bg-white px-6">
{latestInvoices.map((invoice, i) => {
return (
<div
key={invoice.id}
className={clsx(
'flex flex-row items-center justify-between py-4',
{
'border-t': i !== 0,
},
)}
>
<div className="flex items-center">
<Image
src={invoice.image_url}
alt={`${invoice.name}'s profile picture`}
className="mr-4 rounded-full"
width={32}
height={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold md:text-base">
{invoice.name}
</p>
<p className="hidden text-sm text-gray-500 sm:block">
{invoice.email}
</p>
</div>
</div>
<p
className={`${lusitana.className} truncate text-sm font-medium md:text-base`}
>
{invoice.amount}
</p>
</div>
);
})}
</div> */}
<div className="flex items-center pb-2 pt-6">
<ArrowPathIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
'use client';
import {
UserGroupIcon,
HomeIcon,
DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
{ name: 'Home', href: '/dashboard', icon: HomeIcon },
{
name: 'Invoices',
href: '/dashboard/invoices',
icon: DocumentDuplicateIcon,
},
{ name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
];
export default function NavLinks() {
const pathname = usePathname();
return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className={clsx(
'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
{
'bg-sky-100 text-blue-600': pathname === link.href,
}
)}
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}

View file

@ -0,0 +1,65 @@
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { Revenue } from '@/app/lib/definitions';
// This component is representational only.
// For data visualization UI, check out:
// https://www.tremor.so/
// https://www.chartjs.org/
// https://airbnb.io/visx/
export default async function RevenueChart({
revenue,
}: {
revenue: Revenue[];
}) {
const chartHeight = 350;
// NOTE: comment in this code when you get to this point in the course
// const { yAxisLabels, topLabel } = generateYAxis(revenue);
// if (!revenue || revenue.length === 0) {
// return <p className="mt-4 text-gray-400">No data available.</p>;
// }
return (
<div className="w-full md:col-span-4">
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Recent Revenue
</h2>
{/* NOTE: comment in this code when you get to this point in the course */}
{/* <div className="rounded-xl bg-gray-50 p-4">
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
<div
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
style={{ height: `${chartHeight}px` }}
>
{yAxisLabels.map((label) => (
<p key={label}>{label}</p>
))}
</div>
{revenue.map((month) => (
<div key={month.month} className="flex flex-col items-center gap-2">
<div
className="w-full rounded-md bg-blue-300"
style={{
height: `${(chartHeight / topLabel) * month.revenue}px`,
}}
></div>
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
{month.month}
</p>
</div>
))}
</div>
<div className="flex items-center pb-2 pt-6">
<CalendarIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3>
</div>
</div> */}
</div>
);
}

View file

@ -0,0 +1,29 @@
import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
export default function SideNav() {
return (
<div className="flex h-full flex-col px-3 py-4 md:px-2">
<Link
className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
href="/"
>
<div className="w-32 text-white md:w-40">
<AcmeLogo />
</div>
</Link>
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
<NavLinks />
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
<form>
<button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
<PowerIcon className="w-6" />
<div className="hidden md:block">Sign Out</div>
</button>
</form>
</div>
</div>
);
}

8
app/ui/fonts.ts Normal file
View file

@ -0,0 +1,8 @@
import { Inter, Lusitana } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });
export const lusitana = Lusitana({
weight: ['400', '700'],
subsets: ['latin'],
})

18
app/ui/global.css Normal file
View file

@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}

View file

@ -0,0 +1,36 @@
import { clsx } from 'clsx';
import Link from 'next/link';
import { lusitana } from '@/app/ui/fonts';
interface Breadcrumb {
label: string;
href: string;
active?: boolean;
}
export default function Breadcrumbs({
breadcrumbs,
}: {
breadcrumbs: Breadcrumb[];
}) {
return (
<nav aria-label="Breadcrumb" className="mb-6 block">
<ol className={clsx(lusitana.className, 'flex text-xl md:text-2xl')}>
{breadcrumbs.map((breadcrumb, index) => (
<li
key={breadcrumb.href}
aria-current={breadcrumb.active}
className={clsx(
breadcrumb.active ? 'text-gray-900' : 'text-gray-500',
)}
>
<Link href={breadcrumb.href}>{breadcrumb.label}</Link>
{index < breadcrumbs.length - 1 ? (
<span className="mx-3 inline-block">/</span>
) : null}
</li>
))}
</ol>
</nav>
);
}

View file

@ -0,0 +1,36 @@
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
export function CreateInvoice() {
return (
<Link
href="/dashboard/invoices/create"
className="flex h-10 items-center rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<span className="hidden md:block">Create Invoice</span>{' '}
<PlusIcon className="h-5 md:ml-4" />
</Link>
);
}
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href="/dashboard/invoices"
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
export function DeleteInvoice({ id }: { id: string }) {
return (
<>
<button className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-5" />
</button>
</>
);
}

View file

@ -0,0 +1,112 @@
import { CustomerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
export default function Form({ customers }: { customers: CustomerField[] }) {
return (
<form>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
</div>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
</fieldset>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Create Invoice</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,123 @@
'use client';
import { CustomerField, InvoiceForm } from '@/app/lib/definitions';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { Button } from '@/app/ui/button';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
return (
<form>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue={invoice.customer_id}
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
</div>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
defaultValue={invoice.amount}
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
defaultChecked={invoice.status === 'pending'}
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
defaultChecked={invoice.status === 'paid'}
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
</fieldset>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Edit Invoice</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,119 @@
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
export default function Pagination({ totalPages }: { totalPages: number }) {
// NOTE: comment in this code when you get to this point in the course
// const allPages = generatePagination(currentPage, totalPages);
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
{/* <div className="inline-flex">
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>
<div className="flex -space-x-px">
{allPages.map((page, index) => {
let position: 'first' | 'last' | 'single' | 'middle' | undefined;
if (index === 0) position = 'first';
if (index === allPages.length - 1) position = 'last';
if (allPages.length === 1) position = 'single';
if (page === '...') position = 'middle';
return (
<PaginationNumber
key={page}
href={createPageURL(page)}
page={page}
position={position}
isActive={currentPage === page}
/>
);
})}
</div>
<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= totalPages}
/>
</div> */}
</>
);
}
function PaginationNumber({
page,
href,
isActive,
position,
}: {
page: number | string;
href: string;
position?: 'first' | 'last' | 'middle' | 'single';
isActive: boolean;
}) {
const className = clsx(
'flex h-10 w-10 items-center justify-center text-sm border',
{
'rounded-l-md': position === 'first' || position === 'single',
'rounded-r-md': position === 'last' || position === 'single',
'z-10 bg-blue-600 border-blue-600 text-white': isActive,
'hover:bg-gray-100': !isActive && position !== 'middle',
'text-gray-300': position === 'middle',
},
);
return isActive || position === 'middle' ? (
<div className={className}>{page}</div>
) : (
<Link href={href} className={className}>
{page}
</Link>
);
}
function PaginationArrow({
href,
direction,
isDisabled,
}: {
href: string;
direction: 'left' | 'right';
isDisabled?: boolean;
}) {
const className = clsx(
'flex h-10 w-10 items-center justify-center rounded-md border',
{
'pointer-events-none text-gray-300': isDisabled,
'hover:bg-gray-100': !isDisabled,
'mr-2 md:mr-4': direction === 'left',
'ml-2 md:ml-4': direction === 'right',
},
);
const icon =
direction === 'left' ? (
<ArrowLeftIcon className="w-4" />
) : (
<ArrowRightIcon className="w-4" />
);
return isDisabled ? (
<div className={className}>{icon}</div>
) : (
<Link className={className} href={href}>
{icon}
</Link>
);
}

View file

@ -0,0 +1,29 @@
import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function InvoiceStatus({ status }: { status: string }) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-2 py-1 text-xs',
{
'bg-gray-100 text-gray-500': status === 'pending',
'bg-green-500 text-white': status === 'paid',
},
)}
>
{status === 'pending' ? (
<>
Pending
<ClockIcon className="ml-1 w-4 text-gray-500" />
</>
) : null}
{status === 'paid' ? (
<>
Paid
<CheckIcon className="ml-1 w-4 text-white" />
</>
) : null}
</span>
);
}

124
app/ui/invoices/table.tsx Normal file
View file

@ -0,0 +1,124 @@
import Image from 'next/image';
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
import InvoiceStatus from '@/app/ui/invoices/status';
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
import { fetchFilteredInvoices } from '@/app/lib/data';
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
return (
<div className="mt-6 flow-root">
<div className="inline-block min-w-full align-middle">
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
{invoices?.map((invoice) => (
<div
key={invoice.id}
className="mb-2 w-full rounded-md bg-white p-4"
>
<div className="flex items-center justify-between border-b pb-4">
<div>
<div className="mb-2 flex items-center">
<Image
src={invoice.image_url}
className="mr-2 rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`}
/>
<p>{invoice.name}</p>
</div>
<p className="text-sm text-gray-500">{invoice.email}</p>
</div>
<InvoiceStatus status={invoice.status} />
</div>
<div className="flex w-full items-center justify-between pt-4">
<div>
<p className="text-xl font-medium">
{formatCurrency(invoice.amount)}
</p>
<p>{formatDateToLocal(invoice.date)}</p>
</div>
<div className="flex justify-end gap-2">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</div>
</div>
</div>
))}
</div>
<table className="hidden min-w-full text-gray-900 md:table">
<thead className="rounded-lg text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Customer
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Amount
</th>
<th scope="col" className="px-3 py-5 font-medium">
Date
</th>
<th scope="col" className="px-3 py-5 font-medium">
Status
</th>
<th scope="col" className="relative py-3 pl-6 pr-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white">
{invoices?.map((invoice) => (
<tr
key={invoice.id}
className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"
>
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex items-center gap-3">
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`}
/>
<p>{invoice.name}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3">
{invoice.email}
</td>
<td className="whitespace-nowrap px-3 py-3">
{formatCurrency(invoice.amount)}
</td>
<td className="whitespace-nowrap px-3 py-3">
{formatDateToLocal(invoice.date)}
</td>
<td className="whitespace-nowrap px-3 py-3">
<InvoiceStatus status={invoice.status} />
</td>
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex justify-end gap-3">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

73
app/ui/login-form.tsx Normal file
View file

@ -0,0 +1,73 @@
import { lusitana } from '@/app/ui/fonts';
import {
AtSymbolIcon,
KeyIcon,
ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from './button';
export default function LoginForm() {
return (
<form className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className={`${lusitana.className} mb-3 text-2xl`}>
Please log in to continue.
</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
Email
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
<AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
Password
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
<KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
</div>
<LoginButton />
<div className="flex h-8 items-end space-x-1">
{/* Add form errors here */}
</div>
</div>
</form>
);
}
function LoginButton() {
return (
<Button className="mt-4 w-full">
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
</Button>
);
}

18
app/ui/search.tsx Normal file
View file

@ -0,0 +1,18 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
export default function Search({ placeholder }: { placeholder: string }) {
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}

218
app/ui/skeletons.tsx Normal file
View file

@ -0,0 +1,218 @@
// Loading animation
const shimmer =
'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';
export function CardSkeleton() {
return (
<div
className={`${shimmer} relative overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`}
>
<div className="flex p-4">
<div className="h-5 w-5 rounded-md bg-gray-200" />
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" />
</div>
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8">
<div className="h-7 w-20 rounded-md bg-gray-200" />
</div>
</div>
);
}
export function CardsSkeleton() {
return (
<>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</>
);
}
export function RevenueChartSkeleton() {
return (
<div className={`${shimmer} relative w-full overflow-hidden md:col-span-4`}>
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
<div className="rounded-xl bg-gray-100 p-4">
<div className="mt-0 grid h-[410px] grid-cols-12 items-end gap-2 rounded-md bg-white p-4 sm:grid-cols-13 md:gap-4" />
<div className="flex items-center pb-2 pt-6">
<div className="h-5 w-5 rounded-full bg-gray-200" />
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
</div>
</div>
</div>
);
}
export function InvoiceSkeleton() {
return (
<div className="flex flex-row items-center justify-between border-b border-gray-100 py-4">
<div className="flex items-center">
<div className="mr-2 h-8 w-8 rounded-full bg-gray-200" />
<div className="min-w-0">
<div className="h-5 w-40 rounded-md bg-gray-200" />
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
</div>
</div>
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
</div>
);
}
export function LatestInvoicesSkeleton() {
return (
<div
className={`${shimmer} relative flex w-full flex-col overflow-hidden md:col-span-4`}
>
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
<div className="flex grow flex-col justify-between rounded-xl bg-gray-100 p-4">
<div className="bg-white px-6">
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<div className="flex items-center pb-2 pt-6">
<div className="h-5 w-5 rounded-full bg-gray-200" />
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
</div>
</div>
</div>
</div>
);
}
export default function DashboardSkeleton() {
return (
<>
<div
className={`${shimmer} relative mb-4 h-8 w-36 overflow-hidden rounded-md bg-gray-100`}
/>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChartSkeleton />
<LatestInvoicesSkeleton />
</div>
</>
);
}
export function TableRowSkeleton() {
return (
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg">
{/* Customer Name and Image */}
<td className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-gray-100"></div>
<div className="h-6 w-24 rounded bg-gray-100"></div>
</div>
</td>
{/* Email */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-32 rounded bg-gray-100"></div>
</td>
{/* Amount */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Date */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Status */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Actions */}
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex justify-end gap-3">
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div>
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div>
</div>
</td>
</tr>
);
}
export function InvoicesMobileSkeleton() {
return (
<div className="mb-2 w-full rounded-md bg-white p-4">
<div className="flex items-center justify-between border-b border-gray-100 pb-8">
<div className="flex items-center">
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100"></div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
</div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
</div>
<div className="flex w-full items-center justify-between pt-4">
<div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
<div className="mt-2 h-6 w-24 rounded bg-gray-100"></div>
</div>
<div className="flex justify-end gap-2">
<div className="h-10 w-10 rounded bg-gray-100"></div>
<div className="h-10 w-10 rounded bg-gray-100"></div>
</div>
</div>
</div>
);
}
export function InvoicesTableSkeleton() {
return (
<div className="mt-6 flow-root">
<div className="inline-block min-w-full align-middle">
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
<InvoicesMobileSkeleton />
</div>
<table className="hidden min-w-full text-gray-900 md:table">
<thead className="rounded-lg text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Customer
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Amount
</th>
<th scope="col" className="px-3 py-5 font-medium">
Date
</th>
<th scope="col" className="px-3 py-5 font-medium">
Status
</th>
<th
scope="col"
className="relative pb-4 pl-3 pr-6 pt-2 sm:pr-6"
>
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white">
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
</tbody>
</table>
</div>
</div>
</div>
);
}

4
next.config.js Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;

7322
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"start": "next start"
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@tailwindcss/forms": "^0.5.7",
"@types/node": "20.5.7",
"@vercel/postgres": "^0.5.0",
"autoprefixer": "10.4.15",
"bcrypt": "^5.1.1",
"clsx": "^2.0.0",
"next": "^14.0.2",
"postcss": "8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.3",
"typescript": "5.2.2",
"zod": "^3.22.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.1",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.14",
"@vercel/style-guide": "^5.0.1",
"dotenv": "^16.3.1",
"eslint": "^8.52.0",
"eslint-config-next": "^14.0.0",
"eslint-config-prettier": "9.0.0",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "0.5.4"
},
"engines": {
"node": ">=18.17.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

6
prettier.config.js Normal file
View file

@ -0,0 +1,6 @@
const styleguide = require('@vercel/style-guide/prettier');
module.exports = {
...styleguide,
plugins: [...styleguide.plugins, 'prettier-plugin-tailwindcss'],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,019 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/hero-desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

BIN
public/hero-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
public/opengraph-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

179
scripts/seed.js Normal file
View file

@ -0,0 +1,179 @@
const { db } = require('@vercel/postgres');
const {
invoices,
customers,
revenue,
users,
} = require('../app/lib/placeholder-data.js');
const bcrypt = require('bcrypt');
async function seedUsers(client) {
try {
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "users" table if it doesn't exist
const createTable = await client.sql`
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);
`;
console.log(`Created "users" table`);
// Insert data into the "users" table
const insertedUsers = await Promise.all(
users.map(async (user) => {
const hashedPassword = await bcrypt.hash(user.password, 10);
return client.sql`
INSERT INTO users (id, name, email, password)
VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword})
ON CONFLICT (id) DO NOTHING;
`;
}),
);
console.log(`Seeded ${insertedUsers.length} users`);
return {
createTable,
users: insertedUsers,
};
} catch (error) {
console.error('Error seeding users:', error);
throw error;
}
}
async function seedInvoices(client) {
try {
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "invoices" table if it doesn't exist
const createTable = await client.sql`
CREATE TABLE IF NOT EXISTS invoices (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
customer_id UUID NOT NULL,
amount INT NOT NULL,
status VARCHAR(255) NOT NULL,
date DATE NOT NULL
);
`;
console.log(`Created "invoices" table`);
// Insert data into the "invoices" table
const insertedInvoices = await Promise.all(
invoices.map(
(invoice) => client.sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
ON CONFLICT (id) DO NOTHING;
`,
),
);
console.log(`Seeded ${insertedInvoices.length} invoices`);
return {
createTable,
invoices: insertedInvoices,
};
} catch (error) {
console.error('Error seeding invoices:', error);
throw error;
}
}
async function seedCustomers(client) {
try {
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "customers" table if it doesn't exist
const createTable = await client.sql`
CREATE TABLE IF NOT EXISTS customers (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
image_url VARCHAR(255) NOT NULL
);
`;
console.log(`Created "customers" table`);
// Insert data into the "customers" table
const insertedCustomers = await Promise.all(
customers.map(
(customer) => client.sql`
INSERT INTO customers (id, name, email, image_url)
VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url})
ON CONFLICT (id) DO NOTHING;
`,
),
);
console.log(`Seeded ${insertedCustomers.length} customers`);
return {
createTable,
customers: insertedCustomers,
};
} catch (error) {
console.error('Error seeding customers:', error);
throw error;
}
}
async function seedRevenue(client) {
try {
// Create the "revenue" table if it doesn't exist
const createTable = await client.sql`
CREATE TABLE IF NOT EXISTS revenue (
month VARCHAR(4) NOT NULL UNIQUE,
revenue INT NOT NULL
);
`;
console.log(`Created "revenue" table`);
// Insert data into the "revenue" table
const insertedRevenue = await Promise.all(
revenue.map(
(rev) => client.sql`
INSERT INTO revenue (month, revenue)
VALUES (${rev.month}, ${rev.revenue})
ON CONFLICT (month) DO NOTHING;
`,
),
);
console.log(`Seeded ${insertedRevenue.length} revenue`);
return {
createTable,
revenue: insertedRevenue,
};
} catch (error) {
console.error('Error seeding revenue:', error);
throw error;
}
}
async function main() {
const client = await db.connect();
await seedUsers(client);
await seedCustomers(client);
await seedInvoices(client);
await seedRevenue(client);
await client.end();
}
main().catch((err) => {
console.error(
'An error occurred while attempting to seed the database:',
err,
);
});

32
tailwind.config.ts Normal file
View file

@ -0,0 +1,32 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
gridTemplateColumns: {
'13': 'repeat(13, minmax(0, 1fr))',
},
colors: {
blue: {
400: '#2589FE',
500: '#0070F3',
600: '#2F6FEB',
},
},
},
keyframes: {
shimmer: {
'100%': {
transform: 'translateX(100%)',
},
},
},
},
plugins: [require('@tailwindcss/forms')],
};
export default config;

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/lib/placeholder-data.js",
"scripts/seed.js"
],
"exclude": ["node_modules"]
}