mirror of
https://github.com/vercel/next-learn.git
synced 2026-06-11 09:51:47 +00:00
Add Code for Chapter 12 - Accessibility and Form Validation (#180)
This commit is contained in:
committed by
GitHub
parent
e5af71ca45
commit
da17909352
@@ -7,9 +7,12 @@ export default function NotFound() {
|
||||
<FaceFrownIcon className="w-12 text-gray-400" />
|
||||
<h2 className="text-lg font-semibold">Not Found</h2>
|
||||
<p>Could not find the requested invoice.</p>
|
||||
<button className="mt-4 rounded-md bg-black px-4 py-2 text-sm text-white">
|
||||
<Link href="/dashboard/invoices">Go Back</Link>
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard/invoices"
|
||||
className="mt-4 rounded-md bg-black px-4 py-2 text-sm text-white"
|
||||
>
|
||||
Go Back
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,19 @@
|
||||
import { fetchInvoiceById, fetchAllCustomers } from '@/app/lib/data';
|
||||
import { updateInvoice } from '@/app/lib/actions';
|
||||
import { fetchInvoiceById, fetchCustomerNames } from '@/app/lib/data';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Form from '@/app/ui/invoices/edit-form';
|
||||
|
||||
export default async function Page({ params }: { params: { id: string } }) {
|
||||
const id = params.id;
|
||||
const invoice = await fetchInvoiceById(id);
|
||||
const customers = await fetchAllCustomers();
|
||||
const customerNames = await fetchCustomerNames();
|
||||
|
||||
if (!invoice) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Form invoice={invoice} customerNames={customerNames} id={id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,12 @@
|
||||
import { createInvoice } from '@/app/lib/actions';
|
||||
import { fetchAllCustomers } from '@/app/lib/data';
|
||||
import { fetchCustomerNames } from '@/app/lib/data';
|
||||
import Form from '@/app/ui/invoices/create-form';
|
||||
|
||||
export default async function Page() {
|
||||
const customers = await fetchAllCustomers();
|
||||
const customerNames = await fetchCustomerNames();
|
||||
|
||||
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>
|
||||
<main>
|
||||
<Form customerNames={customerNames} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,48 +5,86 @@ import { sql } from '@vercel/postgres';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const InvoiceSchema = z.object({
|
||||
const FormSchema = z.object({
|
||||
id: z.string(),
|
||||
customerId: z.string(),
|
||||
amount: z.coerce.number(),
|
||||
customerId: z.string({
|
||||
invalid_type_error: 'Please select a customer.',
|
||||
}),
|
||||
amount: z.coerce
|
||||
.number()
|
||||
.gt(0, { message: 'Please enter an amount greater than $0.' }),
|
||||
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({
|
||||
const CreateInvoice = FormSchema.omit({ id: true, date: true });
|
||||
const UpdateInvoice = FormSchema.omit({ date: true });
|
||||
const DeleteInvoice = FormSchema.pick({ id: true });
|
||||
|
||||
// This is temporary
|
||||
export type State = {
|
||||
errors?: {
|
||||
customerId?: string[];
|
||||
amount?: string[];
|
||||
status?: string[];
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function createInvoice(prevState: State, formData: FormData) {
|
||||
// Validate form fields using Zod
|
||||
const validatedFields = CreateInvoice.safeParse({
|
||||
customerId: formData.get('customerId'),
|
||||
amount: formData.get('amount'),
|
||||
status: formData.get('status'),
|
||||
});
|
||||
|
||||
// If form validation fails, return errors early. Otherwise, continue.
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
message: 'Missing Fields. Failed to Create Invoice.',
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare data for insertion into the database
|
||||
const { customerId, amount, status } = validatedFields.data;
|
||||
const amountInCents = amount * 100;
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Insert data into the database
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO invoices (customer_id, amount, status, date)
|
||||
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
|
||||
`;
|
||||
INSERT INTO invoices (customer_id, amount, status, date)
|
||||
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
|
||||
`;
|
||||
|
||||
revalidatePath('/dashboard/invoices');
|
||||
redirect('/dashboard/invoices');
|
||||
} catch (error) {
|
||||
throw new Error('Failed to Create Invoice');
|
||||
// If a database error occurs, return a more specific error.
|
||||
return {
|
||||
message: 'Database error: Failed to create invoice.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateInvoice(formData: FormData) {
|
||||
const { id, customerId, amount, status } = UpdateInvoice.parse({
|
||||
export async function updateInvoice(prevState: State, formData: FormData) {
|
||||
const validatedFields = UpdateInvoice.safeParse({
|
||||
id: formData.get('id'),
|
||||
customerId: formData.get('customerId'),
|
||||
amount: formData.get('amount'),
|
||||
status: formData.get('status'),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
message: 'Missing Fields. Failed to Update Invoice.',
|
||||
};
|
||||
}
|
||||
|
||||
const { id, customerId, amount, status } = validatedFields.data;
|
||||
const amountInCents = amount * 100;
|
||||
|
||||
try {
|
||||
@@ -59,7 +97,7 @@ export async function updateInvoice(formData: FormData) {
|
||||
revalidatePath('/dashboard/invoices');
|
||||
redirect('/dashboard/invoices');
|
||||
} catch (error) {
|
||||
throw new Error('Failed to Update Invoice');
|
||||
return { message: 'Database error: Failed to update invoice.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +111,6 @@ export async function deleteInvoice(formData: FormData) {
|
||||
revalidatePath('/dashboard/invoices');
|
||||
return { message: 'Deleted Invoice' };
|
||||
} catch (error) {
|
||||
throw new Error('Failed to Delete Invoice');
|
||||
return { message: 'Database error: Failed to delete invoice.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { sql } from '@vercel/postgres';
|
||||
import { formatCurrency } from './utils';
|
||||
import {
|
||||
Revenue,
|
||||
LatestInvoice,
|
||||
InvoicesTable,
|
||||
CustomersTable,
|
||||
InvoiceForm,
|
||||
CustomerName,
|
||||
LatestInvoiceRaw,
|
||||
} from './definitions';
|
||||
|
||||
export async function fetchRevenue() {
|
||||
@@ -14,11 +16,11 @@ export async function fetchRevenue() {
|
||||
// console.log('Fetching revenue data...');
|
||||
// await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const data = await sql`SELECT * FROM revenue`;
|
||||
const data = await sql<Revenue>`SELECT * FROM revenue`;
|
||||
|
||||
// console.log('Data fetch complete after 3 seconds.');
|
||||
|
||||
return data.rows as Revenue[];
|
||||
return data.rows;
|
||||
} catch (error) {
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch revenue data.');
|
||||
@@ -58,7 +60,7 @@ export async function fetchTotalAmountByStatus() {
|
||||
|
||||
export async function fetchLatestInvoices() {
|
||||
try {
|
||||
const data = await sql`
|
||||
const data = await sql<LatestInvoiceRaw>`
|
||||
SELECT invoices.amount, customers.name, customers.image_url, customers.email
|
||||
FROM invoices
|
||||
JOIN customers ON invoices.customer_id = customers.id
|
||||
@@ -69,7 +71,7 @@ export async function fetchLatestInvoices() {
|
||||
...invoice,
|
||||
amount: formatCurrency(invoice.amount),
|
||||
}));
|
||||
return latestInvoices as LatestInvoice[];
|
||||
return latestInvoices;
|
||||
} catch (error) {
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch the latest invoices.');
|
||||
@@ -84,7 +86,7 @@ export async function fetchFilteredInvoices(
|
||||
const offset = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
try {
|
||||
const data = await sql`
|
||||
const data = await sql<InvoicesTable>`
|
||||
SELECT
|
||||
invoices.id,
|
||||
invoices.amount,
|
||||
@@ -121,7 +123,7 @@ export async function fetchFilteredInvoices(
|
||||
const totalPages = Math.ceil(totalRecords / itemsPerPage);
|
||||
|
||||
return {
|
||||
invoices: data.rows as InvoicesTable[],
|
||||
invoices: data.rows,
|
||||
totalPages,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -132,7 +134,7 @@ export async function fetchFilteredInvoices(
|
||||
|
||||
export async function fetchInvoiceById(id: string) {
|
||||
try {
|
||||
const data = await sql`
|
||||
const data = await sql<InvoiceForm>`
|
||||
SELECT
|
||||
invoices.id,
|
||||
invoices.amount,
|
||||
@@ -149,23 +151,16 @@ export async function fetchInvoiceById(id: string) {
|
||||
amount: invoice.amount / 100,
|
||||
}));
|
||||
|
||||
return invoice[0] as
|
||||
| {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
name: string;
|
||||
}
|
||||
| undefined;
|
||||
return invoice[0];
|
||||
} catch (error) {
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllCustomers() {
|
||||
export async function fetchCustomerNames() {
|
||||
try {
|
||||
const data = await sql`
|
||||
const data = await sql<CustomerName>`
|
||||
SELECT
|
||||
id,
|
||||
name
|
||||
@@ -174,7 +169,7 @@ export async function fetchAllCustomers() {
|
||||
`;
|
||||
|
||||
const customers = data.rows;
|
||||
return customers as { id: string; name: string }[];
|
||||
return customers;
|
||||
} catch (err) {
|
||||
console.error('Database Error:', err);
|
||||
throw new Error('Failed to fetch all customers.');
|
||||
@@ -183,7 +178,7 @@ export async function fetchAllCustomers() {
|
||||
|
||||
export async function fetchCustomersTable() {
|
||||
try {
|
||||
const data = await sql`
|
||||
const data = await sql<CustomersTable>`
|
||||
SELECT
|
||||
customers.id,
|
||||
customers.name,
|
||||
@@ -204,7 +199,7 @@ export async function fetchCustomersTable() {
|
||||
total_paid: formatCurrency(customer.total_paid),
|
||||
}));
|
||||
|
||||
return customers as CustomersTable[];
|
||||
return customers;
|
||||
} catch (err) {
|
||||
console.error('Database Error:', err);
|
||||
throw new Error('Failed to fetch customer table.');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// This file contains type definitions for you data.
|
||||
// These describe the shape of the data, and what data type each property should accept.
|
||||
// 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, you're using an ORM such as Prisma, these types are generated automatically.
|
||||
export type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -37,6 +39,11 @@ export type LatestInvoice = {
|
||||
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;
|
||||
@@ -54,6 +61,18 @@ export type CustomersTable = {
|
||||
email: string;
|
||||
image_url: string;
|
||||
total_invoices: number;
|
||||
total_pending: string;
|
||||
total_paid: string;
|
||||
total_pending: number;
|
||||
total_paid: number;
|
||||
};
|
||||
|
||||
export type CustomerName = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type InvoiceForm = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid';
|
||||
};
|
||||
|
||||
@@ -14,14 +14,18 @@ export default function Page() {
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut vulputate
|
||||
dapibus consectetur. Duis quis eros euismod.
|
||||
</p>
|
||||
<a href="/login">
|
||||
<button className="rounded-md bg-black px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-800">
|
||||
Log in
|
||||
</button>
|
||||
<a
|
||||
href="/login"
|
||||
className="rounded-md bg-black px-4 py-2 text-center text-sm text-white outline-2 outline-offset-4 hover:bg-gray-800"
|
||||
>
|
||||
Log in
|
||||
</a>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<Image src={HeroImage} alt="Dashboard Hero Image" />
|
||||
<Image
|
||||
src={HeroImage}
|
||||
alt="A collection of UI elements from the dashboard application."
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ export default async function CustomersTable() {
|
||||
<Image
|
||||
src={customer.image_url}
|
||||
className="rounded-full"
|
||||
alt={customer.name}
|
||||
alt={`${customer.name}'s profile picture`}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
|
||||
@@ -27,9 +27,7 @@ export default function Card({
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="flex justify-between ">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{Icon ? (
|
||||
<Icon className="h-5 w-5 text-gray-700" aria-label={type} />
|
||||
) : null}
|
||||
{Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null}
|
||||
</div>
|
||||
<p className="mt-2 truncate text-2xl font-semibold tracking-wide md:text-3xl">
|
||||
{value}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function LatestInvoices({
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src={invoice.image_url}
|
||||
alt={invoice.name}
|
||||
alt={`${invoice.name}'s profile picture`}
|
||||
className="mr-4 rounded-full"
|
||||
width={32}
|
||||
height={32}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function SideNav() {
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Logo"
|
||||
alt="The Next.js Symbol, a white N inside a black circle"
|
||||
className="mb-4 ml-1"
|
||||
width={32}
|
||||
height={32}
|
||||
|
||||
@@ -4,19 +4,23 @@ 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>
|
||||
<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"
|
||||
>
|
||||
Create Invoice
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<Link
|
||||
href={`/dashboard/invoices/${id}/edit`}
|
||||
className="rounded-md border p-1"
|
||||
>
|
||||
<PencilSquareIcon className="w-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +29,7 @@ export function DeleteInvoice({ id }: { id: string }) {
|
||||
<form action={deleteInvoice}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<button className="rounded-md border p-1">
|
||||
<span className="sr-only">Delete</span>
|
||||
<TrashIcon className="w-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
136
dashboard/15-final/app/ui/invoices/create-form.tsx
Normal file
136
dashboard/15-final/app/ui/invoices/create-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { createInvoice } from '@/app/lib/actions';
|
||||
import { CustomerName } from '@/app/lib/definitions';
|
||||
// @ts-ignore React types do not yet include useFormState
|
||||
import { experimental_useFormState as useFormState } from 'react-dom';
|
||||
|
||||
export default function Form({
|
||||
customerNames,
|
||||
}: {
|
||||
customerNames: CustomerName[];
|
||||
}) {
|
||||
const initialState = { message: null, errors: [] };
|
||||
const [state, dispatch] = useFormState(createInvoice, initialState);
|
||||
|
||||
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">Update Invoice</h2>
|
||||
<form action={dispatch}>
|
||||
{/* Customer Name */}
|
||||
<div className="mb-4">
|
||||
<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 border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
defaultValue=""
|
||||
aria-describedby="customer-error"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a customer
|
||||
</option>
|
||||
{customerNames.map((name) => (
|
||||
<option key={name.id} value={name.id}>
|
||||
{name.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{state.errors?.customerId ? (
|
||||
<div
|
||||
id="customer-error"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-sm text-red-500"
|
||||
>
|
||||
{state.errors.customerId.map((error: string) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Invoice Amount */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="amount" className="mb-2 block text-sm font-semibold">
|
||||
Amount
|
||||
</label>
|
||||
<div className="relative mt-2 rounded-md">
|
||||
<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
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="00.00"
|
||||
className="block w-full rounded-md border border-gray-200 py-2 pl-6 text-sm outline-2 placeholder:text-gray-200"
|
||||
aria-describedby="amount-error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.errors?.amount ? (
|
||||
<div
|
||||
id="amount-error"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-sm text-red-500"
|
||||
>
|
||||
{state.errors.amount.map((error: string) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Invoice Status */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="status" className="mb-2 block text-sm font-semibold">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="block w-full rounded-md border border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
defaultValue="pending"
|
||||
aria-describedby="status-error"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
</select>
|
||||
|
||||
{state.errors?.status ? (
|
||||
<div
|
||||
aria-describedby="status-error"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-sm text-red-500"
|
||||
>
|
||||
{state.errors.status.map((error: string) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.message ? (
|
||||
<div aria-live="polite" className="my-2 text-sm text-red-500">
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-2 rounded-md bg-black px-4 py-2 text-center text-sm text-white outline-2 outline-offset-4 hover:bg-gray-800"
|
||||
>
|
||||
Create Invoice
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
dashboard/15-final/app/ui/invoices/edit-form.tsx
Normal file
138
dashboard/15-final/app/ui/invoices/edit-form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { CustomerName, InvoiceForm } from '@/app/lib/definitions';
|
||||
import { updateInvoice } from '@/app/lib/actions';
|
||||
// @ts-ignore React types do not yet include useFormState
|
||||
import { experimental_useFormState as useFormState } from 'react-dom';
|
||||
|
||||
export default function EditInvoiceForm({
|
||||
id,
|
||||
invoice,
|
||||
customerNames,
|
||||
}: {
|
||||
id: string;
|
||||
invoice: InvoiceForm;
|
||||
customerNames: CustomerName[];
|
||||
}) {
|
||||
const initialState = { message: null, errors: [] };
|
||||
const [state, dispatch] = useFormState(updateInvoice, initialState);
|
||||
|
||||
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">Update Invoice</h2>
|
||||
<form action={dispatch}>
|
||||
<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 border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
defaultValue={invoice.name}
|
||||
aria-describedby="customer-error"
|
||||
>
|
||||
{customerNames.map((name) => (
|
||||
<option key={name.id} value={name.id}>
|
||||
{name.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{state.errors?.customerId ? (
|
||||
<div
|
||||
id="customer-error"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-sm text-red-500"
|
||||
>
|
||||
{state.errors.customerId.map((error: string) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</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 border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
aria-describedby="amount-error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.errors?.amount ? (
|
||||
<div
|
||||
id="amount-error"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-sm text-red-500"
|
||||
>
|
||||
{state.errors.amount.map((error: string) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</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 border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
defaultValue={invoice.status}
|
||||
aria-describedby="status-error"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
</select>
|
||||
|
||||
{state.errors?.status ? (
|
||||
<div
|
||||
aria-describedby="status-error"
|
||||
aria-live="polite"
|
||||
className="mt-2 text-sm text-red-500"
|
||||
>
|
||||
{state.errors.status.map((error: string) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.message ? (
|
||||
<div aria-live="polite" className="my-2 text-sm text-red-500">
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-2 rounded-md bg-black px-4 py-2 text-center text-sm text-white outline-2 outline-offset-4 hover:bg-gray-800"
|
||||
>
|
||||
Update Invoice
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,9 +45,9 @@ export default async function InvoicesTable({
|
||||
<Image
|
||||
src={invoice.image_url}
|
||||
className="rounded-full"
|
||||
alt="Customer Image"
|
||||
width={28}
|
||||
height={28}
|
||||
alt={`${invoice.name}'s profile picture`}
|
||||
/>
|
||||
<p>{invoice.name}</p>
|
||||
</div>
|
||||
|
||||
@@ -7,15 +7,11 @@ import BackgroundBlur from '@/app/ui/background-blur';
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
// This component contains basic logic for a React Form.
|
||||
// We'll be updating it in Chapter 8 - Adding Authentication.
|
||||
|
||||
export default function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const router = useRouter();
|
||||
const { replace } = useRouter();
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
@@ -32,7 +28,7 @@ export default function LoginForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace('dashboard');
|
||||
replace('/dashboard');
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
@@ -43,7 +39,12 @@ export default function LoginForm() {
|
||||
|
||||
<div className="mx-auto flex w-full flex-col items-center space-y-2 rounded-xl border bg-white px-4 py-6 shadow-sm sm:max-w-sm sm:space-y-4 sm:px-8 sm:py-12">
|
||||
<Link href="/">
|
||||
<Image width={40} height={40} src="/logo.png" alt="Next.js Logo" />
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
src="/logo.png"
|
||||
alt="The Next.js Symbol, a white N inside a black circle"
|
||||
/>
|
||||
</Link>
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -55,7 +56,7 @@ export default function LoginForm() {
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
className="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 sm:text-sm"
|
||||
className="block w-full rounded-md border border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -70,26 +71,28 @@ export default function LoginForm() {
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
className="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 sm:text-sm"
|
||||
className="block w-full rounded-md border border-gray-200 py-2 pl-3 text-sm outline-2 placeholder:text-gray-200"
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<button
|
||||
className="w-full rounded-md bg-black py-2 text-center text-sm font-semibold text-white transition-colors hover:bg-gray-800"
|
||||
type="submit"
|
||||
{error && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="mt-4 w-fit rounded-md py-1 text-sm text-red-500"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-2 w-fit rounded-md bg-red-500 px-3 py-1 text-sm text-white">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="mt-7 w-full rounded-md bg-black px-4 py-2 text-center text-sm text-white outline-2 outline-offset-4 hover:bg-gray-800"
|
||||
type="submit"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
160
dashboard/15-final/package-lock.json
generated
160
dashboard/15-final/package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"autoprefixer": "10.4.15",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.0.0",
|
||||
"next": "^13.4.19",
|
||||
"next": "^13.5.3",
|
||||
"next-auth": "^4.23.1",
|
||||
"postcss": "8.4.28",
|
||||
"react": "18.2.0",
|
||||
@@ -155,126 +155,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.3.tgz",
|
||||
"integrity": "sha512-UpBKxu2ob9scbpJyEq/xPgpdrgBgN3aLYlxyGqlYX5/KnwpJpFuIHU2lx8upQQ7L+MEmz+fA1XSgesoK92ppwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.3.tgz",
|
||||
"integrity": "sha512-5AzM7Yx1Ky+oLY6pHs7tjONTF22JirDPd5Jw/3/NazJ73uGB05NqhGhB4SbeCchg7SlVYVBeRMrMSZwJwq/xoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.3.tgz",
|
||||
"integrity": "sha512-A/C1shbyUhj7wRtokmn73eBksjTM7fFQoY2v/0rTM5wehpkjQRLOXI8WJsag2uLhnZ4ii5OzR1rFPwoD9cvOgA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.3.tgz",
|
||||
"integrity": "sha512-FubPuw/Boz8tKkk+5eOuDHOpk36F80rbgxlx4+xty/U71e3wZZxVYHfZXmf0IRToBn1Crb8WvLM9OYj/Ur815g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.3.tgz",
|
||||
"integrity": "sha512-DPw8nFuM1uEpbX47tM3wiXIR0Qa+atSzs9Q3peY1urkhofx44o7E1svnq+a5Q0r8lAcssLrwiM+OyJJgV/oj7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.3.tgz",
|
||||
"integrity": "sha512-zBPSP8cHL51Gub/YV8UUePW7AVGukp2D8JU93IHbVDu2qmhFAn9LWXiOOLKplZQKxnIPUkJTQAJDCWBWU4UWUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.3.tgz",
|
||||
"integrity": "sha512-ONcL/lYyGUj4W37D4I2I450SZtSenmFAvapkJQNIJhrPMhzDU/AdfLkW98NvH1D2+7FXwe7yclf3+B7v28uzBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "13.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.3.tgz",
|
||||
"integrity": "sha512-2Vz2tYWaLqJvLcWbbTlJ5k9AN6JD7a5CN2pAeIzpbecK8ZF/yobA39cXtv6e+Z8c5UJuVOmaTldEAIxvsIux/Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -546,9 +426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.0.tgz",
|
||||
"integrity": "sha512-v+Jcv64L2LbfTC6OnRcaxtqJNJuQAVhZKSJfR/6hn7lhnChUXl4amwVviqN1k411BB+3rRoKMitELRn1CojeRA==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz",
|
||||
"integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -564,8 +444,8 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001539",
|
||||
"electron-to-chromium": "^1.4.530",
|
||||
"caniuse-lite": "^1.0.30001541",
|
||||
"electron-to-chromium": "^1.4.535",
|
||||
"node-releases": "^2.0.13",
|
||||
"update-browserslist-db": "^1.0.13"
|
||||
},
|
||||
@@ -786,9 +666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.4.532",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.532.tgz",
|
||||
"integrity": "sha512-piIR0QFdIGKmOJTSNg5AwxZRNWQSXlRYycqDB9Srstx4lip8KpcmRxVP6zuFWExWziHYZpJ0acX7TxqX95KBpg=="
|
||||
"version": "1.4.536",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.536.tgz",
|
||||
"integrity": "sha512-L4VgC/76m6y8WVCgnw5kJy/xs7hXrViCFdNKVG8Y7B2isfwrFryFyJzumh3ugxhd/oB1uEaEEvRdmeLrnd7OFA=="
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
@@ -1452,9 +1332,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
@@ -1489,6 +1369,14 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -2094,14 +1982,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"seed": "node -r dotenv/config ./scripts/seed.js",
|
||||
"start": "next start"
|
||||
},
|
||||
@@ -18,7 +19,7 @@
|
||||
"autoprefixer": "10.4.15",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.0.0",
|
||||
"next": "^13.4.19",
|
||||
"next": "^13.5.3",
|
||||
"next-auth": "^4.23.1",
|
||||
"postcss": "8.4.28",
|
||||
"react": "18.2.0",
|
||||
|
||||
965
pnpm-lock.yaml
generated
965
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user