Code for Chapters 9-10 (#172)

This commit is contained in:
Delba de Oliveira
2023-10-02 15:26:13 +01:00
committed by GitHub
parent 68be8ee164
commit b0d832e2cf
25 changed files with 839 additions and 2231 deletions

View File

@@ -26,6 +26,7 @@ yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel

View File

@@ -1,20 +1,102 @@
import InvoiceForm from '@/app/ui/invoices/form';
import { notFound } from 'next/navigation';
import { Invoice } from '@/app/lib/definitions';
import { fetchInvoiceById } from '@/app/lib/data';
import { fetchInvoiceById, fetchAllCustomers } from '@/app/lib/data';
import { updateInvoice } from '@/app/lib/actions';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id ? parseInt(params.id) : null;
const invoiceData = await fetchInvoiceById(id);
const invoice = invoiceData.rows[0] as Invoice;
const id = params.id;
const invoice = await fetchInvoiceById(id);
const customers = await fetchAllCustomers();
if (!invoice) {
notFound();
}
return (
<div>
<InvoiceForm type="edit" invoice={invoice} />
</div>
<main role="main">
<div className="mx-auto max-w-sm rounded-lg border px-6 py-8 shadow-sm">
<h2
className="mb-6 text-xl font-semibold text-gray-900"
role="heading"
aria-level={2}
>
Edit Invoice
</h2>
<form action={updateInvoice}>
<input type="hidden" name="id" value={id} />
{/* Customer selection */}
<div className="mb-4" role="group">
<label
htmlFor="customer"
className="mb-2 block text-sm font-semibold"
>
Customer
</label>
<select
id="customer"
name="customerId"
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200 focus:ring-blue-200"
defaultValue={invoice.name}
aria-label="Select Customer"
>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</div>
{/* Invoice amount */}
<div className="mb-4" role="group">
<label className="mb-2 block text-sm font-semibold">Amount</label>
<div className="relative mt-2 rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-600 sm:text-sm" aria-hidden="true">
$
</span>
</div>
<input
name="amount"
type="number"
step="0.01"
defaultValue={invoice.amount}
placeholder="00.00"
className="block w-full rounded-md border-0 py-1.5 pl-7 text-sm leading-6 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400 focus:ring-blue-200"
aria-label="Enter Amount"
/>
</div>
</div>
{/* Invoice status */}
<div className="mb-4" role="group">
<label
className="mb-2 block text-sm font-semibold"
htmlFor="status"
>
Status
</label>
<select
id="status"
name="status"
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200 focus:ring-blue-200"
defaultValue={invoice.status}
aria-label="Select Status"
>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
</select>
</div>
{/* Submit button */}
<button
type="submit"
className="rounded-md bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Update Invoice"
>
Update Invoice
</button>
</form>
</div>
</main>
);
}

View File

@@ -1,5 +1,101 @@
import InvoiceForm from '@/app/ui/invoices/form';
import { createInvoice } from '@/app/lib/actions';
import { fetchAllCustomers } from '@/app/lib/data';
export default function Page() {
return <InvoiceForm type="new" />;
export default async function Page() {
const customers = await fetchAllCustomers();
return (
<div className="mx-auto max-w-sm rounded-lg border px-6 py-8 shadow-sm">
<h2 className="mb-6 text-xl font-semibold text-gray-900">
Create Invoice
</h2>
<form action={createInvoice}>
{/* Customer */}
<div className="mb-4">
<label
htmlFor="customer"
className="mb-2 block text-sm font-semibold"
aria-label="Select Customer"
>
Customer
</label>
<select
id="customer"
name="customerId"
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
defaultValue=""
aria-label="Select Customer"
aria-required="true"
required
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="mb-4">
<label
className="mb-2 block text-sm font-semibold"
htmlFor="amount"
id="amount-label"
>
Amount
</label>
<div className="relative mt-2 rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-600 sm:text-sm" aria-hidden="true">
$
</span>
</div>
<input
id="amount"
name="amount"
type="number"
step="0.01"
placeholder="00.00"
className="block w-full rounded-md border-0 py-1.5 pl-7 text-sm leading-6 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400"
aria-describedby="amount-label"
/>
</div>
</div>
{/* Invoice Status */}
<div className="mb-4">
<label
className="mb-2 block text-sm font-semibold"
htmlFor="status"
id="status-label"
>
Status
</label>
<select
id="status"
name="status"
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
defaultValue="pending"
aria-describedby="status-label"
>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
</select>
</div>
{/* Submit Button */}
<button
type="submit"
className="rounded-md bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-blue-600 focus:border-blue-700 focus:outline-none focus:ring focus:ring-blue-200"
>
Create
</button>
</form>
</div>
);
}

View File

@@ -1,16 +1,38 @@
import InvoicesTable from '@/app/ui/invoices/table';
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/invoices/search';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import Table from '@/app/ui/invoices/table';
import { fetchFilteredInvoices } from '@/app/lib/data';
export default function Page({
export default async function Page({
searchParams,
}: {
searchParams: {
query: string;
page: string;
};
searchParams:
| {
query: string | undefined;
page: string | undefined;
}
| undefined;
}) {
const query = searchParams?.query || '';
const currentPage = query ? 1 : Number(searchParams?.page || '1');
const { invoices, totalPages } = await fetchFilteredInvoices(
query,
currentPage,
);
return (
<main>
<InvoicesTable searchParams={searchParams} />
</main>
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className="text-base font-semibold">Invoices</h1>
<CreateInvoice />
</div>
<div className="mt-8 flex items-center justify-between gap-2">
<Search />
<Pagination totalPages={totalPages} currentPage={currentPage} />
</div>
<Table invoices={invoices} />
</div>
);
}

View File

@@ -1,11 +1,65 @@
'use server';
export async function deleteInvoice(id: number) {
// TO DO: Add delete invoice logic
console.log('Delete invoice', id);
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
const InvoiceSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
const UpdateInvoice = InvoiceSchema.omit({ date: true });
const DeleteInvoice = InvoiceSchema.pick({ id: true });
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export async function addOrUpdateInvoice(formData: FormData) {
// TO DO: Add create/update invoice logic
console.log('Edit Invoice');
export async function updateInvoice(formData: FormData) {
const { id, customerId, amount, status } = UpdateInvoice.parse({
id: formData.get('id'),
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export async function deleteInvoice(formData: FormData) {
const { id } = DeleteInvoice.parse({
id: formData.get('id'),
});
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}

View File

@@ -1,30 +1,26 @@
import { sql } from '@vercel/postgres';
import { formatCurrency } from './utils';
import { Revenue, LatestInvoice } from './definitions';
import {
Revenue,
LatestInvoice,
InvoicesTable,
CustomersTable,
} from './definitions';
export async function fetchRevenue(): Promise<Revenue[]> {
try {
const revenueData = await sql`SELECT * FROM revenue`;
return revenueData.rows as Revenue[];
} catch (error) {
console.error('Failed to fetch revenue data:', error);
throw new Error('Failed to fetch revenue data.');
}
}
export async function fetchRevenueDelayed(): Promise<Revenue[]> {
export async function fetchRevenue() {
try {
// We artificially delay a reponse for demo purposes.
// Don't do this in real life :)
console.log('Fetching revenue data...');
await new Promise((resolve) => setTimeout(resolve, 3000));
// console.log('Fetching revenue data...');
// await new Promise((resolve) => setTimeout(resolve, 3000));
const revenueData = await sql`SELECT * FROM revenue`;
console.log('Data fetch complete after 3 seconds.');
const data = await sql`SELECT * FROM revenue`;
return revenueData.rows as Revenue[];
// console.log('Data fetch complete after 3 seconds.');
return data.rows as Revenue[];
} catch (error) {
console.error('Failed to fetch revenue data:', error);
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
@@ -39,23 +35,23 @@ export async function fetchCounts() {
return { numberOfCustomers, numberOfInvoices };
} catch (error) {
console.error('Failed to fetch counts:', error);
console.error('Database Error:', error);
throw new Error('Failed to fetch counts.');
}
}
export async function fetchTotalAmountByStatus() {
try {
const totalAmount = await sql`SELECT
const data = await 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 totalPaidInvoices = formatCurrency(totalAmount.rows[0].paid);
const totalPendingInvoices = formatCurrency(totalAmount.rows[0].pending);
const totalPaidInvoices = formatCurrency(data.rows[0].paid);
const totalPendingInvoices = formatCurrency(data.rows[0].pending);
return { totalPaidInvoices, totalPendingInvoices };
} catch (error) {
console.error('Failed to fetch total amounts by status:', error);
console.error('Database Error:', error);
throw new Error('Failed to fetch total amounts by status.');
}
}
@@ -68,65 +64,147 @@ export async function fetchLatestInvoices() {
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 as LatestInvoice[];
} catch (error) {
console.error('Failed to fetch the latest invoices:', error);
console.error('Database Error:', error);
throw new Error('Failed to fetch the latest invoices.');
}
}
export async function fetchAllInvoices() {
const invoicesData = await sql`SELECT * FROM invoices`;
return invoicesData.rows;
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
const itemsPerPage = 10;
const offset = (currentPage - 1) * itemsPerPage;
try {
const data = await sql`
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 ${itemsPerPage} OFFSET ${offset}
`;
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 totalRecords = Number(count.rows[0].count);
const totalPages = Math.ceil(totalRecords / itemsPerPage);
return {
invoices: data.rows as InvoicesTable[],
totalPages,
};
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoices.');
}
}
export async function fetchInvoiceById(id: string) {
try {
const data = await sql`
SELECT
invoices.id,
invoices.amount,
invoices.status,
customers.name
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE invoices.id = ${id};
`;
const invoice = data.rows.map((invoice) => ({
...invoice,
// Convert amount from cents to dollars
amount: invoice.amount / 100,
}));
return invoice[0] as {
id: string;
amount: number;
status: string;
name: string;
};
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoice.');
}
}
export async function fetchAllCustomers() {
const customersData = await sql`SELECT * FROM customers`;
return customersData.rows;
try {
const data = await sql`
SELECT
id,
name
FROM customers
ORDER BY name ASC
`;
const customers = data.rows;
return customers as { id: string; name: string }[];
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch all customers.');
}
}
export async function fetchFilteredInvoices(
searchTerm: string,
currentPage: number,
ITEMS_PER_PAGE: number,
) {
const invoicesData = await sql`
SELECT
invoices.*,
customers.name AS customer_name,
customers.email AS customer_email,
customers.image_url AS customer_image
FROM
invoices
JOIN
customers ON invoices.customer_id = customers.id
WHERE
invoices.id::text ILIKE ${`%${searchTerm}%`} OR
customers.name ILIKE ${`%${searchTerm}%`} OR
customers.email ILIKE ${`%${searchTerm}%`} OR
invoices.amount::text ILIKE ${`%${searchTerm}%`} OR
invoices.date::text ILIKE ${`%${searchTerm}%`} OR
invoices.status ILIKE ${`%${searchTerm}%`}
LIMIT ${ITEMS_PER_PAGE}
OFFSET ${(currentPage - 1) * ITEMS_PER_PAGE}
`;
return invoicesData.rows;
}
export async function fetchCustomersTable() {
try {
const data = await sql`
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
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
`;
export async function fetchInvoiceCountBySearchTerm(searchTerm: string) {
const { rows: countRows } = await sql`
SELECT COUNT(*)
FROM invoices
LEFT JOIN customers ON invoices.customer_id = customers.id
WHERE (invoices.id::text ILIKE ${`%${searchTerm}%`} OR customers.name ILIKE ${`%${searchTerm}%`} OR customers.email ILIKE ${`%${searchTerm}%`})
`;
return countRows[0].count;
}
const customers = data.rows.map((customer) => ({
...customer,
total_pending: formatCurrency(customer.total_pending),
total_paid: formatCurrency(customer.total_paid),
}));
export async function fetchInvoiceById(id: number | null) {
return await sql`SELECT * from INVOICES where id=${id}`;
return customers as CustomersTable[];
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch customer table.');
}
}

View File

@@ -1,6 +1,5 @@
// This file contains type definitions for you data.
// These describe the shape of the data, and what data type each property should accept.
export type User = {
id: number;
name: string;
@@ -9,31 +8,52 @@ export type User = {
};
export type Customer = {
id: number;
id: string;
name: string;
email: string;
image_url: string;
};
export type Invoice = {
id: number;
customer_id: number;
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';
date: string;
};
export type LatestInvoice = {
id: number;
name: string;
image_url: string;
email: string;
amount: string;
};
export type Revenue = {
month: string;
revenue: number;
};
export type LatestInvoice = {
id: string;
name: string;
image_url: string;
email: string;
amount: string;
};
export type InvoicesTable = {
id: string;
customer_id: string;
name: string;
email: string;
image_url: string;
date: string;
amount: number;
status: 'pending' | 'paid';
};
export type CustomersTable = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: string;
total_paid: string;
};

View File

@@ -10,25 +10,25 @@ const users = [
const customers = [
{
id: 1,
id: '93980f8c-a5e4-484c-a469-2d12ca8fdde3',
name: 'Ada Lovelace',
email: 'ada@lovelace.com',
image_url: '/customers/ada-lovelace.png',
},
{
id: 2,
id: 'e53120f8-0301-437b-924a-0288f4ec6040',
name: 'Grace Hopper',
email: 'grace@hopper.com',
image_url: '/customers/grace-hopper.png',
},
{
id: 3,
id: '030fab4c-18d7-4ed2-814c-4171cc67bca8',
name: 'Hedy Lammar',
email: 'hedy@lammar.com',
image_url: '/customers/hedy-lammar.png',
},
{
id: 4,
id: '3958dc9e-737f-4377-85e9-fec4b6a6442a',
name: 'Margaret Hamilton',
email: 'margaret@hamilton.com',
image_url: '/customers/margaret-hamilton.png',
@@ -37,106 +37,91 @@ const customers = [
const invoices = [
{
id: 1,
customer_id: 1,
customer_id: customers[0].id,
amount: 15795,
status: 'pending',
date: '2023-12-06',
date: '2022-12-06',
},
{
id: 2,
customer_id: 2,
customer_id: customers[1].id,
amount: 20348,
status: 'pending',
date: '2023-11-14',
date: '2022-11-14',
},
{
id: 3,
customer_id: 3,
customer_id: customers[2].id,
amount: 3040,
status: 'paid',
date: '2023-10-29',
date: '2022-10-29',
},
{
id: 4,
customer_id: 4,
customer_id: customers[3].id,
amount: 44800,
status: 'paid',
date: '2023-09-10',
},
{
id: 5,
customer_id: 1,
customer_id: customers[0].id,
amount: 34577,
status: 'pending',
date: '2023-08-05',
},
{
id: 6,
customer_id: 2,
customer_id: customers[1].id,
amount: 54246,
status: 'pending',
date: '2023-07-16',
},
{
id: 7,
customer_id: 3,
customer_id: customers[2].id,
amount: 8945,
status: 'pending',
date: '2023-06-27',
},
{
id: 8,
customer_id: 4,
customer_id: customers[3].id,
amount: 32545,
status: 'paid',
date: '2023-06-09',
},
{
id: 9,
customer_id: 3,
customer_id: customers[2].id,
amount: 1250,
status: 'paid',
date: '2023-06-17',
},
{
id: 10,
customer_id: 1,
customer_id: customers[0].id,
amount: 8945,
status: 'paid',
date: '2023-06-07',
},
{
id: 11,
customer_id: 2,
customer_id: customers[1].id,
amount: 500,
status: 'paid',
date: '2023-08-19',
},
{
id: 12,
customer_id: 3,
customer_id: customers[2].id,
amount: 8945,
status: 'paid',
date: '2023-06-03',
},
{
id: 13,
customer_id: 3,
customer_id: customers[2].id,
amount: 8945,
status: 'paid',
date: '2023-06-18',
},
{
id: 14,
customer_id: 4,
customer_id: customers[3].id,
amount: 8945,
status: 'paid',
date: '2023-10-04',
},
{
id: 15,
customer_id: 3,
customer_id: customers[2].id,
amount: 1000,
status: 'paid',
date: '2022-06-05',

View File

@@ -1,4 +1,4 @@
import { Invoice, Revenue, Customer } from './definitions';
import { Revenue } from './definitions';
export const formatCurrency = (amount: number) => {
return (amount / 100).toLocaleString('en-US', {
@@ -21,72 +21,6 @@ export const formatDateToLocal = (
return formatter.format(date);
};
export function findLatestInvoices(invoices: Invoice[], customers: Customer[]) {
// Sort the invoices by date in descending order and take the top 5
const latestInvoices = [...invoices]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5);
// Find corresponding customers for the latest 5 invoices
const latestInvoicesWithCustomerInfo = latestInvoices.map((invoice) => {
const customer = customers.find(
(customer) => customer.id === invoice.customer_id,
);
// Format the amount to USD
const formattedAmount = invoice.amount.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
});
return {
id: invoice.id,
name: customer?.name,
image_url: customer?.image_url,
email: customer?.email,
amount: formattedAmount,
};
});
return latestInvoicesWithCustomerInfo;
}
export const calculateInvoicesByStatus = (
invoices: Invoice[],
status: 'pending' | 'paid',
) => {
return invoices
.filter((invoice) => !status || invoice.status === status)
.reduce((total, invoice) => total + invoice.amount / 100, 0)
.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
});
};
export const calculateCustomerInvoices = (
invoices: Invoice[],
status: 'pending' | 'paid',
customerId: number,
) => {
return invoices
.filter((invoice) => invoice.customer_id === customerId)
.filter((invoice) => !status || invoice.status === status)
.reduce((total, invoice) => total + invoice.amount / 100, 0)
.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
});
};
export const countCustomerInvoices = (
invoices: Invoice[],
customerId: number,
) => {
return invoices.filter((invoice) => invoice.customer_id === customerId)
.length;
};
export const generateYAxis = (revenue: Revenue[]) => {
// Calculate what labels we need to display on the y-axis
// based on highest record and in 1000s

View File

@@ -21,7 +21,7 @@ export default function Page() {
</a>
</div>
<div className="w-full sm:w-1/2">
<Image src={HeroImage} alt="Dashboard Hero Image" placeholder="blur" />
<Image src={HeroImage} alt="Dashboard Hero Image" />
</div>
</main>
);

View File

@@ -1,14 +1,8 @@
import {
countCustomerInvoices,
calculateCustomerInvoices,
} from '@/app/lib/utils';
import { Customer, Invoice } from '@/app/lib/definitions';
import { fetchAllCustomers, fetchAllInvoices } from '@/app/lib/data';
import { fetchCustomersTable } from '@/app/lib/data';
import Image from 'next/image';
export default async function CustomersTable() {
const invoices = (await fetchAllInvoices()) as Invoice[];
const customers = (await fetchAllCustomers()) as Customer[];
const customers = await fetchCustomersTable();
return (
<div className="w-full">
@@ -22,19 +16,19 @@ export default async function CustomersTable() {
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50 text-left text-sm">
<tr>
<th scope="col" className="px-3.5 py-3.5 sm:pl-6">
<th scope="col" className="px-4 py-4 sm:pl-6">
Name
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
<th scope="col" className="px-4 py-4 font-semibold">
Email
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
<th scope="col" className="px-4 py-4 font-semibold">
Total Invoices
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
<th scope="col" className="px-4 py-4 font-semibold">
Total Pending
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
<th scope="col" className="px-4 py-4 font-semibold">
Total Paid
</th>
</tr>
@@ -55,25 +49,17 @@ export default async function CustomersTable() {
<p>{customer.name}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
<td className="whitespace-nowrap px-4 py-4 text-sm">
{customer.email}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{countCustomerInvoices(invoices, customer.id)}
<td className="whitespace-nowrap px-4 py-4 text-sm">
{customer.total_invoices}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{calculateCustomerInvoices(
invoices,
'pending',
customer.id,
)}
<td className="whitespace-nowrap px-4 py-4 text-sm">
{customer.total_pending}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{calculateCustomerInvoices(
invoices,
'paid',
customer.id,
)}
<td className="whitespace-nowrap px-4 py-4 text-sm">
{customer.total_paid}
</td>
</tr>
))}

View File

@@ -1,5 +1,5 @@
import { generateYAxis } from '@/app/lib/utils';
import { fetchRevenueDelayed } from '@/app/lib/data';
import { fetchRevenue } from '@/app/lib/data';
// This component is representational only.
// For data visualization UI, check out:
@@ -8,7 +8,7 @@ import { fetchRevenueDelayed } from '@/app/lib/data';
// https://airbnb.io/visx/
export default async function RevenueChart() {
const revenue = await fetchRevenueDelayed();
const revenue = await fetchRevenue();
const chartHeight = 350;
const { yAxisLabels, topLabel } = generateYAxis(revenue);

View File

@@ -1,11 +0,0 @@
import Link from 'next/link';
export default function AddInvoice() {
return (
<Link
href="/dashboard/invoices/create"
className="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Add Invoice
</Link>
);
}

View File

@@ -0,0 +1,32 @@
import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { deleteInvoice } from '@/app/lib/actions';
export function CreateInvoice() {
return (
<button className="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<Link href="/dashboard/invoices/create">Create Invoice</Link>
</button>
);
}
export function UpdateInvoice({ id }: { id: string }) {
return (
<button className="rounded-md border p-1">
<Link href={`/dashboard/invoices/${id}/edit`}>
<PencilSquareIcon className="w-4" />
</Link>
</button>
);
}
export function DeleteInvoice({ id }: { id: string }) {
return (
<form action={deleteInvoice}>
<input type="hidden" name="id" value={id} />
<button className="rounded-md border p-1">
<TrashIcon className="w-4" />
</button>
</form>
);
}

View File

@@ -1,18 +0,0 @@
'use client';
import { TrashIcon } from '@heroicons/react/24/outline';
import { useTransition } from 'react';
import { deleteInvoice } from '@/app/lib/actions';
export default function DeleteInvoice({ id }: { id: number }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => deleteInvoice(id))}
className="rounded-md border p-1"
>
<TrashIcon className="w-4" />
</button>
);
}

View File

@@ -1,12 +0,0 @@
import { PencilSquareIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
export default function EditInvoice({ id }: { id: number }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-1"
>
<PencilSquareIcon className="w-4" />
</Link>
);
}

View File

@@ -1,127 +0,0 @@
'use client';
import { Invoice } from '@/app/lib/definitions';
import { customers } from '@/app/lib/dummy-data';
import { useState, FormEvent } from 'react';
// import { addOrUpdateInvoice } from "@/app/lib/actions";
// export const dynamic = "force-dynamic";
export default function InvoiceForm({
type,
invoice,
}: {
type: 'new' | 'edit';
invoice?: Invoice;
}) {
// TO DO: Replace state and handleSubmit with a Server Action
const customer = customers.find(
(customer) => customer.id === invoice?.customer_id,
);
const initialCustomer = customer ? customer.id : 0;
const initialAmount = invoice?.amount ? invoice.amount / 100 : 0;
const initialStatus = invoice?.status || 'pending';
const [selectedCustomer, setSelectedCustomer] = useState(initialCustomer);
const [amount, setAmount] = useState<number>(initialAmount);
const [status, setStatus] = useState<'pending' | 'paid'>(initialStatus);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (selectedCustomer && amount) {
const newInvoice: Invoice = {
customer_id: selectedCustomer,
amount: amount * 100, // Convert to cents
// These would be generated on the server
id: 1, // Record ID will be automatically incremented
status: status, // Default status for a new invoice
date: new Date().toISOString().split('T')[0],
};
// TODO: Add this invoice to the database
console.log('New Invoice:', newInvoice);
}
};
return (
<div className="mx-auto max-w-sm rounded-lg border px-6 py-8 shadow-sm">
<h2 className="mb-6 text-xl font-semibold text-gray-900">
{type === 'new' ? 'New Invoice' : 'Edit Invoice'}
</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="customer"
className="mb-2 block text-sm font-semibold"
>
Customer
</label>
<select
id="customer"
onChange={(e) => setSelectedCustomer(Number(e.target.value))}
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
value={selectedCustomer}
>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="mb-2 block text-sm font-semibold">Amount</label>
<div className="relative mt-2 rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-600 sm:text-sm">$</span>
</div>
<input
type="number"
value={amount}
placeholder="00.00"
onChange={(e) => setAmount(Number(e.target.value))}
className="block w-full rounded-md border-0 py-1.5 pl-7 text-sm leading-6 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400"
/>
</div>
</div>
{type === 'edit' ? (
<div className="mb-4">
<label
className="mb-2 block text-sm font-semibold"
htmlFor="status"
>
Status
</label>
<select
id="status"
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
onChange={(e) => {
const value = e.target.value;
if (value === 'paid' || value === 'pending') {
setStatus(value);
}
}}
value={status}
>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
</select>
</div>
) : null}
<button
type="submit"
className="rounded-md bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-blue-600"
>
Create Invoice
</button>
</form>
</div>
);
}

View File

@@ -1,33 +1,30 @@
'use client';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { usePathname } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import clsx from 'clsx';
import Link from 'next/link';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function PaginationButtons({
totalPages,
export default function Pagination({
currentPage,
searchParams,
totalPages,
}: {
totalPages: number;
currentPage: number;
searchParams: { query: string; page: string };
totalPages: number;
}) {
const pathname = usePathname();
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
const createPageUrl = (pageNumber: number) => {
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.set('page', pageNumber.toString());
return `${pathname}?${newSearchParams.toString()}`;
};
const searchParams = useSearchParams();
const allPages = Array.from({ length: totalPages }, (_, i) => i + 1);
const PreviousPageTag = currentPage === 1 ? 'p' : Link;
const NextPageTag = currentPage === totalPages ? 'p' : Link;
const createPageUrl = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
<div className="inline-flex -space-x-px">
<PreviousPageTag
@@ -41,7 +38,7 @@ export default function PaginationButtons({
>
<ChevronLeftIcon className="w-4" />
</PreviousPageTag>
{pageNumbers.map((page) => {
{allPages.map((page) => {
const PageTag = page === currentPage ? 'p' : Link;
return (
<PageTag

View File

@@ -1,17 +1,17 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter } from 'next/navigation';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({
searchParams,
}: {
searchParams: { query: string; page: string };
}) {
export default function Search() {
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();
function handleSearch(term: string) {
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
@@ -19,7 +19,7 @@ export default function Search({
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}
}, 300);
return (
<div className="relative max-w-md flex-grow">
@@ -31,11 +31,11 @@ export default function Search({
<input
type="text"
placeholder="Search..."
value={searchParams.query || ''}
className="absolute inset-0 w-full rounded-md border border-gray-300 bg-transparent p-2 pl-8 text-sm"
onChange={(e) => {
handleSearch(e.target.value);
}}
className="absolute inset-0 w-full rounded-md border border-gray-300 bg-transparent p-2 pl-8 text-sm"
defaultValue={searchParams.get('query')?.toString()}
/>
</div>
</div>

View File

@@ -1,126 +1,77 @@
import Image from 'next/image';
import DeleteInvoice from '@/app/ui/invoices/delete-button';
import EditInvoice from '@/app/ui/invoices/edit-button';
import AddInvoice from '@/app/ui/invoices/add-button';
import Search from '@/app/ui/invoices/search';
import PaginationButtons from '@/app/ui/invoices/pagination';
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
import InvoiceStatus from '@/app/ui/invoices/status';
import {
fetchFilteredInvoices,
fetchInvoiceCountBySearchTerm,
} from '@/app/lib/data';
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
import { InvoicesTable } from '@/app/lib/definitions';
export default async function InvoicesTable({
searchParams,
invoices,
}: {
searchParams: {
query: string;
page: string;
};
invoices: InvoicesTable[];
}) {
const ITEMS_PER_PAGE = 10;
const searchTerm = searchParams.query ?? '';
let currentPage = 1;
if (!searchTerm) {
currentPage = parseInt(searchParams.page ?? '1');
}
const invoices = await fetchFilteredInvoices(
searchTerm,
currentPage,
ITEMS_PER_PAGE,
);
const totalCount = await fetchInvoiceCountBySearchTerm(searchTerm);
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className="text-base font-semibold">Invoices</h1>
<AddInvoice />
</div>
<div className="mt-8 flex items-center justify-between gap-2">
<Search searchParams={searchParams} />
<PaginationButtons
searchParams={searchParams}
totalPages={totalPages}
currentPage={currentPage}
/>
</div>
<div className="mt-4 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden rounded-md border">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50 text-left text-sm">
<tr>
<th scope="col" className="px-3.5 py-3.5 sm:pl-6">
#
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
Customer
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
Email
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
Amount
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
Date
</th>
<th scope="col" className="px-3 py-3.5 font-semibold">
Status
</th>
<th
scope="col"
className="relative py-3.5 pl-3 pr-4 sm:pr-6"
>
<span className="sr-only">Edit</span>
</th>
<div className="mt-4 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden rounded-md border">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50 text-left text-sm">
<tr>
<th scope="col" className="px-6 pr-4 font-semibold">
Customer
</th>
<th scope="col" className="px-3 py-4 font-semibold">
Email
</th>
<th scope="col" className="px-3 py-4 font-semibold">
Amount
</th>
<th scope="col" className="px-3 py-4 font-semibold">
Date
</th>
<th scope="col" className="px-3 py-4 font-semibold">
Status
</th>
<th scope="col" className="relative py-4 pl-3 pr-6 sm:pr-6">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-500">
{invoices?.map((invoice) => (
<tr key={invoice.id}>
<td className="whitespace-nowrap px-6 py-4 text-sm">
<div className="flex items-center gap-3">
<Image
src={invoice.image_url}
className="rounded-full"
alt="Customer Image"
width={28}
height={28}
/>
<p>{invoice.name}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{invoice.email}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{formatCurrency(invoice.amount)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{formatDateToLocal(invoice.date)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
<InvoiceStatus status={invoice.status} />
</td>
<td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</td>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-500">
{invoices?.map((invoice) => (
<tr key={invoice.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-black sm:pl-6">
{invoice.id}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
<div className="flex items-center gap-3">
<Image
src={`${invoice.customer_image}`}
className="rounded-full"
alt="Customer Image"
width={28}
height={28}
/>
<p>{invoice.customer_name}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{invoice.customer_email}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{formatCurrency(invoice.amount)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
{formatDateToLocal(invoice.date)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
<InvoiceStatus status={invoice.status} />
</td>
<td className="flex justify-end gap-2 whitespace-nowrap py-4 pl-3 pr-6 text-sm">
<EditInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -13,7 +13,6 @@ import Image from 'next/image';
export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,9 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.3",
"typescript": "5.2.2"
"typescript": "5.2.2",
"use-debounce": "^9.0.4",
"zod": "^3.22.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",

View File

@@ -47,16 +47,18 @@ async function seedUsers() {
async function seedInvoices() {
try {
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "invoices" table if it doesn't exist
const createTable = await sql`
CREATE TABLE IF NOT EXISTS invoices (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
amount INT NOT NULL,
status VARCHAR(255) NOT NULL,
date DATE NOT NULL
);
`;
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`);
@@ -64,8 +66,8 @@ async function seedInvoices() {
const insertedInvoices = await Promise.all(
invoices.map(
(invoice) => sql`
INSERT INTO invoices (id, customer_id, amount, status, date)
VALUES (${invoice.id}, ${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
ON CONFLICT (id) DO NOTHING;
`,
),
@@ -85,10 +87,12 @@ async function seedInvoices() {
async function seedCustomers() {
try {
// Create the "invoices" table if it doesn't exist
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
// Create the "customers" table if it doesn't exist
const createTable = await sql`
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
image_url VARCHAR(255) NOT NULL

123
pnpm-lock.yaml generated
View File

@@ -37,7 +37,7 @@ importers:
version: 4.0.3
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -55,7 +55,7 @@ importers:
dependencies:
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -73,7 +73,7 @@ importers:
version: 4.0.3
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -91,7 +91,7 @@ importers:
dependencies:
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -109,7 +109,7 @@ importers:
version: 4.0.3
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -130,7 +130,7 @@ importers:
version: 4.0.3
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -145,7 +145,7 @@ importers:
version: 4.0.3
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -157,7 +157,7 @@ importers:
dependencies:
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -169,7 +169,7 @@ importers:
dependencies:
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -242,10 +242,10 @@ importers:
version: 2.0.0
next:
specifier: ^13.4.19
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
next-auth:
specifier: ^4.23.1
version: 4.23.1(next@13.5.2)(react-dom@18.2.0)(react@18.2.0)
version: 4.23.1(next@13.5.3)(react-dom@18.2.0)(react@18.2.0)
postcss:
specifier: 8.4.28
version: 8.4.28
@@ -261,6 +261,12 @@ importers:
typescript:
specifier: 5.2.2
version: 5.2.2
use-debounce:
specifier: ^9.0.4
version: 9.0.4(react@18.2.0)
zod:
specifier: ^3.22.2
version: 3.22.2
devDependencies:
'@types/bcrypt':
specifier: ^5.0.0
@@ -279,7 +285,7 @@ importers:
version: 4.17.21
next:
specifier: latest
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -633,8 +639,8 @@ packages:
resolution: {integrity: sha512-FsAT5x0jF2kkhNkKkukhsyYOrRqtSxrEhfliniIq0bwWbuXLgyt3Gv0Ml+b91XwjwArmuP7NxCiGd++GGKdNMQ==}
dev: false
/@next/env@13.5.2:
resolution: {integrity: sha512-dUseBIQVax+XtdJPzhwww4GetTjlkRSsXeQnisIJWBaHsnxYcN2RGzsPHi58D6qnkATjnhuAtQTJmR1hKYQQPg==}
/@next/env@13.5.3:
resolution: {integrity: sha512-X4te86vsbjsB7iO4usY9jLPtZ827Mbx+WcwNBGUOIuswuTAKQtzsuoxc/6KLxCMvogKG795MhrR1LDhYgDvasg==}
dev: false
/@next/eslint-plugin-next@13.4.19:
@@ -652,8 +658,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-arm64@13.5.2:
resolution: {integrity: sha512-7eAyunAWq6yFwdSQliWMmGhObPpHTesiKxMw4DWVxhm5yLotBj8FCR4PXGkpRP2tf8QhaWuVba+/fyAYggqfQg==}
/@next/swc-darwin-arm64@13.5.3:
resolution: {integrity: sha512-6hiYNJxJmyYvvKGrVThzo4nTcqvqUTA/JvKim7Auaj33NexDqSNwN5YrrQu+QhZJCIpv2tULSHt+lf+rUflLSw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@@ -670,8 +676,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@13.5.2:
resolution: {integrity: sha512-WxXYWE7zF1ch8rrNh5xbIWzhMVas6Vbw+9BCSyZvu7gZC5EEiyZNJsafsC89qlaSA7BnmsDXVWQmc+s1feSYbQ==}
/@next/swc-darwin-x64@13.5.3:
resolution: {integrity: sha512-UpBKxu2ob9scbpJyEq/xPgpdrgBgN3aLYlxyGqlYX5/KnwpJpFuIHU2lx8upQQ7L+MEmz+fA1XSgesoK92ppwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@@ -688,8 +694,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@13.5.2:
resolution: {integrity: sha512-URSwhRYrbj/4MSBjLlefPTK3/tvg95TTm6mRaiZWBB6Za3hpHKi8vSdnCMw5D2aP6k0sQQIEG6Pzcfwm+C5vrg==}
/@next/swc-linux-arm64-gnu@13.5.3:
resolution: {integrity: sha512-5AzM7Yx1Ky+oLY6pHs7tjONTF22JirDPd5Jw/3/NazJ73uGB05NqhGhB4SbeCchg7SlVYVBeRMrMSZwJwq/xoA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -706,8 +712,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@13.5.2:
resolution: {integrity: sha512-HefiwAdIygFyNmyVsQeiJp+j8vPKpIRYDlmTlF9/tLdcd3qEL/UEBswa1M7cvO8nHcr27ZTKXz5m7dkd56/Esg==}
/@next/swc-linux-arm64-musl@13.5.3:
resolution: {integrity: sha512-A/C1shbyUhj7wRtokmn73eBksjTM7fFQoY2v/0rTM5wehpkjQRLOXI8WJsag2uLhnZ4ii5OzR1rFPwoD9cvOgA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -724,8 +730,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@13.5.2:
resolution: {integrity: sha512-htGVVroW0tdHgMYwKWkxWvVoG2RlAdDXRO1RQxYDvOBQsaV0nZsgKkw0EJJJ3urTYnwKskn/MXm305cOgRxD2w==}
/@next/swc-linux-x64-gnu@13.5.3:
resolution: {integrity: sha512-FubPuw/Boz8tKkk+5eOuDHOpk36F80rbgxlx4+xty/U71e3wZZxVYHfZXmf0IRToBn1Crb8WvLM9OYj/Ur815g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -742,8 +748,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@13.5.2:
resolution: {integrity: sha512-UBD333GxbHVGi7VDJPPDD1bKnx30gn2clifNJbla7vo5nmBV+x5adyARg05RiT9amIpda6yzAEEUu+s774ldkw==}
/@next/swc-linux-x64-musl@13.5.3:
resolution: {integrity: sha512-DPw8nFuM1uEpbX47tM3wiXIR0Qa+atSzs9Q3peY1urkhofx44o7E1svnq+a5Q0r8lAcssLrwiM+OyJJgV/oj7g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -760,8 +766,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@13.5.2:
resolution: {integrity: sha512-Em9ApaSFIQnWXRT3K6iFnr9uBXymixLc65Xw4eNt7glgH0eiXpg+QhjmgI2BFyc7k4ZIjglfukt9saNpEyolWA==}
/@next/swc-win32-arm64-msvc@13.5.3:
resolution: {integrity: sha512-zBPSP8cHL51Gub/YV8UUePW7AVGukp2D8JU93IHbVDu2qmhFAn9LWXiOOLKplZQKxnIPUkJTQAJDCWBWU4UWUA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@@ -778,8 +784,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@13.5.2:
resolution: {integrity: sha512-TBACBvvNYU+87X0yklSuAseqdpua8m/P79P0SG1fWUvWDDA14jASIg7kr86AuY5qix47nZLEJ5WWS0L20jAUNw==}
/@next/swc-win32-ia32-msvc@13.5.3:
resolution: {integrity: sha512-ONcL/lYyGUj4W37D4I2I450SZtSenmFAvapkJQNIJhrPMhzDU/AdfLkW98NvH1D2+7FXwe7yclf3+B7v28uzBQ==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@@ -796,8 +802,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@13.5.2:
resolution: {integrity: sha512-LfTHt+hTL8w7F9hnB3H4nRasCzLD/fP+h4/GUVBTxrkMJOnh/7OZ0XbYDKO/uuWwryJS9kZjhxcruBiYwc5UDw==}
/@next/swc-win32-x64-msvc@13.5.3:
resolution: {integrity: sha512-2Vz2tYWaLqJvLcWbbTlJ5k9AN6JD7a5CN2pAeIzpbecK8ZF/yobA39cXtv6e+Z8c5UJuVOmaTldEAIxvsIux/Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3687,7 +3693,7 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
/next-auth@4.23.1(next@13.5.2)(react-dom@18.2.0)(react@18.2.0):
/next-auth@4.23.1(next@13.5.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-mL083z8KgRtlrIV6CDca2H1kduWJuK/3pTS0Fe2og15KOm4v2kkLGdSDfc2g+019aEBrJUT0pPW2Xx42ImN1WA==}
peerDependencies:
next: ^12.2.5 || ^13
@@ -3702,11 +3708,11 @@ packages:
'@panva/hkdf': 1.1.1
cookie: 0.5.0
jose: 4.14.6
next: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
next: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
oauth: 0.9.15
openid-client: 5.5.0
preact: 10.17.1
preact-render-to-string: 5.2.6(preact@10.17.1)
preact: 10.18.0
preact-render-to-string: 5.2.6(preact@10.18.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
uuid: 8.3.2
@@ -3752,8 +3758,8 @@ packages:
- babel-plugin-macros
dev: false
/next@13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-vog4UhUaMYAzeqfiAAmgB/QWLW7p01/sg+2vn6bqc/CxHFYizMzLv6gjxKzl31EVFkfl/F+GbxlKizlkTE9RdA==}
/next@13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-4Nt4HRLYDW/yRpJ/QR2t1v63UOMS55A38dnWv3UDOWGezuY0ZyFO1ABNbD7mulVzs9qVhgy2+ppjdsANpKP1mg==}
engines: {node: '>=16.14.0'}
hasBin: true
peerDependencies:
@@ -3767,7 +3773,7 @@ packages:
sass:
optional: true
dependencies:
'@next/env': 13.5.2
'@next/env': 13.5.3
'@swc/helpers': 0.5.2
busboy: 1.6.0
caniuse-lite: 1.0.30001527
@@ -3778,15 +3784,15 @@ packages:
watchpack: 2.4.0
zod: 3.21.4
optionalDependencies:
'@next/swc-darwin-arm64': 13.5.2
'@next/swc-darwin-x64': 13.5.2
'@next/swc-linux-arm64-gnu': 13.5.2
'@next/swc-linux-arm64-musl': 13.5.2
'@next/swc-linux-x64-gnu': 13.5.2
'@next/swc-linux-x64-musl': 13.5.2
'@next/swc-win32-arm64-msvc': 13.5.2
'@next/swc-win32-ia32-msvc': 13.5.2
'@next/swc-win32-x64-msvc': 13.5.2
'@next/swc-darwin-arm64': 13.5.3
'@next/swc-darwin-x64': 13.5.3
'@next/swc-linux-arm64-gnu': 13.5.3
'@next/swc-linux-arm64-musl': 13.5.3
'@next/swc-linux-x64-gnu': 13.5.3
'@next/swc-linux-x64-musl': 13.5.3
'@next/swc-win32-arm64-msvc': 13.5.3
'@next/swc-win32-ia32-msvc': 13.5.3
'@next/swc-win32-x64-msvc': 13.5.3
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -4236,17 +4242,17 @@ packages:
xtend: 4.0.2
dev: false
/preact-render-to-string@5.2.6(preact@10.17.1):
/preact-render-to-string@5.2.6(preact@10.18.0):
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
preact: '>=10'
dependencies:
preact: 10.17.1
preact: 10.18.0
pretty-format: 3.8.0
dev: false
/preact@10.17.1:
resolution: {integrity: sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA==}
/preact@10.18.0:
resolution: {integrity: sha512-O4dGFmErPd3RNVDvXmCbOW6hetnve6vYtjx5qf51mCUmBS96s66MrNQkEII5UThDGoNF7953ptA+aNupiDxVeg==}
dev: false
/prelude-ls@1.2.1:
@@ -5192,6 +5198,15 @@ packages:
punycode: 2.3.0
dev: true
/use-debounce@9.0.4(react@18.2.0):
resolution: {integrity: sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==}
engines: {node: '>= 10.0.0'}
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
dev: false
/utf-8-validate@6.0.3:
resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==}
engines: {node: '>=6.14.2'}
@@ -5385,6 +5400,10 @@ packages:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
dev: false
/zod@3.22.2:
resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==}
dev: false
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false