@ -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 |
||||
@ -0,0 +1,5 @@ |
|||||
|
## Next.js App Router Course - Starter |
||||
|
|
||||
|
This is the starter template for the Next.js App Router Course. It contains the starting code for the dashboard application. |
||||
|
|
||||
|
For more information, see the [course curriculum](https://nextjs.org/learn) on the Next.js Website. |
||||
@ -0,0 +1,11 @@ |
|||||
|
export default function RootLayout({ |
||||
|
children, |
||||
|
}: { |
||||
|
children: React.ReactNode; |
||||
|
}) { |
||||
|
return ( |
||||
|
<html lang="en"> |
||||
|
<body>{children}</body> |
||||
|
</html> |
||||
|
); |
||||
|
} |
||||
@ -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.'); |
||||
|
} |
||||
|
} |
||||
@ -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'; |
||||
|
}; |
||||
@ -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, |
||||
|
}; |
||||
@ -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, |
||||
|
]; |
||||
|
}; |
||||
@ -0,0 +1,33 @@ |
|||||
|
import AcmeLogo from '@/app/ui/acme-logo'; |
||||
|
import { ArrowRightIcon } from '@heroicons/react/24/outline'; |
||||
|
import Link from 'next/link'; |
||||
|
|
||||
|
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"> |
||||
|
<p 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 */} |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
import { |
||||
|
UserGroupIcon, |
||||
|
HomeIcon, |
||||
|
DocumentDuplicateIcon, |
||||
|
} from '@heroicons/react/24/outline'; |
||||
|
|
||||
|
// 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() { |
||||
|
return ( |
||||
|
<> |
||||
|
{links.map((link) => { |
||||
|
const LinkIcon = link.icon; |
||||
|
return ( |
||||
|
<a |
||||
|
key={link.name} |
||||
|
href={link.href} |
||||
|
className="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" |
||||
|
> |
||||
|
<LinkIcon className="w-6" /> |
||||
|
<p className="hidden md:block">{link.name}</p> |
||||
|
</a> |
||||
|
); |
||||
|
})} |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
/** @type {import('next').NextConfig} */ |
||||
|
const nextConfig = {}; |
||||
|
|
||||
|
module.exports = nextConfig; |
||||
@ -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" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
module.exports = { |
||||
|
plugins: { |
||||
|
tailwindcss: {}, |
||||
|
autoprefixer: {}, |
||||
|
}, |
||||
|
}; |
||||
@ -0,0 +1,6 @@ |
|||||
|
const styleguide = require('@vercel/style-guide/prettier'); |
||||
|
|
||||
|
module.exports = { |
||||
|
...styleguide, |
||||
|
plugins: [...styleguide.plugins, 'prettier-plugin-tailwindcss'], |
||||
|
}; |
||||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1019 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 238 KiB |
@ -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, |
||||
|
); |
||||
|
}); |
||||
@ -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; |
||||
@ -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"] |
||||
|
} |
||||