Add Code for Chapter 12 - Accessibility and Form Validation (#180)

This commit is contained in:
Delba de Oliveira
2023-10-02 16:43:55 +01:00
committed by GitHub
parent e5af71ca45
commit da17909352
19 changed files with 885 additions and 944 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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.' };
}
}

View File

@@ -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.');

View File

@@ -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';
};

View File

@@ -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>
);

View File

@@ -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}
/>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff