diff --git a/.env.example b/.env.example
deleted file mode 100644
index 8a85ba7..0000000
--- a/.env.example
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copy from .env.local on the Vercel dashboard
-# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
-POSTGRES_URL=
-POSTGRES_PRISMA_URL=
-POSTGRES_URL_NON_POOLING=
-POSTGRES_USER=
-POSTGRES_HOST=
-POSTGRES_PASSWORD=
-POSTGRES_DATABASE=
-
-# `openssl rand -base64 32`
-AUTH_SECRET=
-AUTH_URL=http://localhost:3000/api/auth
\ No newline at end of file
diff --git a/app/dashboard/(overview)/loading.tsx b/app/dashboard/(overview)/loading.tsx
new file mode 100644
index 0000000..6b56116
--- /dev/null
+++ b/app/dashboard/(overview)/loading.tsx
@@ -0,0 +1,5 @@
+import DashboardSkeleton from '@/app/ui/skeletons';
+
+export default function Loading() {
+ return ;
+ }
\ No newline at end of file
diff --git a/app/dashboard/(overview)/page.tsx b/app/dashboard/(overview)/page.tsx
new file mode 100644
index 0000000..6996f12
--- /dev/null
+++ b/app/dashboard/(overview)/page.tsx
@@ -0,0 +1,33 @@
+import { Card } from '@/app/ui/dashboard/cards';
+import RevenueChart from '@/app/ui/dashboard/revenue-chart';
+import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
+import { lusitana } from '@/app/ui/fonts';
+import { Suspense } from 'react';
+import { RevenueChartSkeleton, LatestInvoicesSkeleton, CardSkeleton } from '@/app/ui/skeletons';
+import CardWrapper from '@/app/ui/dashboard/cards';
+
+export default async function Page() {
+
+ return (
+
+
+ Dashboard
+
+
+
+ }>
+
+
+ } >
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/dashboard/invoices/[id]/edit/not-found.tsx b/app/dashboard/invoices/[id]/edit/not-found.tsx
new file mode 100644
index 0000000..1373306
--- /dev/null
+++ b/app/dashboard/invoices/[id]/edit/not-found.tsx
@@ -0,0 +1,18 @@
+import Link from 'next/link';
+import { FaceFrownIcon } from '@heroicons/react/24/outline';
+
+export default function NotFound() {
+ return (
+
+
+ 404 Not Found
+ Could not find the requested invoice.
+
+ Go Back
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/dashboard/invoices/[id]/edit/page.tsx b/app/dashboard/invoices/[id]/edit/page.tsx
new file mode 100644
index 0000000..2a3517c
--- /dev/null
+++ b/app/dashboard/invoices/[id]/edit/page.tsx
@@ -0,0 +1,33 @@
+import Form from '@/app/ui/invoices/edit-form';
+import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
+import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
+import { notFound } from 'next/navigation';
+
+
+export default async function Page({ params }: { params: { id: string } }) {
+ const id = params.id;
+ const [invoice, customers] = await Promise.all([
+ fetchInvoiceById(id),
+ fetchCustomers(),
+ ]);
+
+ if (!invoice) {
+ notFound()
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/app/dashboard/invoices/create/page.tsx b/app/dashboard/invoices/create/page.tsx
new file mode 100644
index 0000000..45feed0
--- /dev/null
+++ b/app/dashboard/invoices/create/page.tsx
@@ -0,0 +1,23 @@
+import Form from '@/app/ui/invoices/create-form';
+import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
+import { fetchCustomers } from '@/app/lib/data';
+
+export default async function Page() {
+ const customers = await fetchCustomers();
+
+ return (
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/dashboard/invoices/error.tsx b/app/dashboard/invoices/error.tsx
new file mode 100644
index 0000000..e285214
--- /dev/null
+++ b/app/dashboard/invoices/error.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ // Optionally log the error to an error reporting service
+ console.error(error);
+ }, [error]);
+
+ return (
+
+ Something went wrong!
+ reset()
+ }
+ >
+ Try again
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/dashboard/invoices/page.tsx b/app/dashboard/invoices/page.tsx
index f0a0073..ca4678d 100644
--- a/app/dashboard/invoices/page.tsx
+++ b/app/dashboard/invoices/page.tsx
@@ -1,3 +1,44 @@
-export default function Page() {
- return
Invoices Page
;
+
+import Pagination from '@/app/ui/invoices/pagination';
+import Search from '@/app/ui/search';
+import Table from '@/app/ui/invoices/table';
+
+
+import { CreateInvoice } from '@/app/ui/invoices/buttons';
+import { fetchInvoicesPages } from '@/app/lib/data';
+import { lusitana } from '@/app/ui/fonts';
+import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
+import { Suspense } from 'react';
+
+export default async function Page({
+ searchParams,
+}: {
+ searchParams?: {
+ query?: string;
+ page?: string;
+ };
+}) {
+
+ const query = searchParams?.query || '';
+ const currentPage = Number(searchParams?.page) || 1;
+ const totalPages = await fetchInvoicesPages(query);
+
+
+ return (
+
+
+
Invoices
+
+
+
+
+
+
}>
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
deleted file mode 100644
index 60f2c6d..0000000
--- a/app/dashboard/page.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-
-export default function Page() {
- return Dashboard Page
;
-}
\ No newline at end of file
diff --git a/app/lib/actions.ts b/app/lib/actions.ts
new file mode 100644
index 0000000..8cc337a
--- /dev/null
+++ b/app/lib/actions.ts
@@ -0,0 +1,146 @@
+'use server';
+import { z } from 'zod';
+import { sql } from '@vercel/postgres';
+import { revalidatePath } from 'next/cache';
+import { redirect } from 'next/navigation';
+import { signIn } from '@/auth';
+import { AuthError } from 'next-auth';
+
+
+export async function authenticate(
+ prevState: string | undefined,
+ formData: FormData,
+) {
+ try {
+ await signIn('credentials', FormData);
+ } catch (error) {
+ if (error instanceof AuthError) {
+ switch (error.type) {
+ case 'CredentialsSignin':
+ return 'Invalid credentials.';
+ default:
+ return 'Something went wrong.';
+ }
+ }
+ throw error;
+ }
+}
+
+const FormSchema = z.object({
+ id: z.string(),
+ customerId: z.string({
+ invalid_type_error: 'Please select a customer.'
+ }),
+ amount: z.coerce
+ .number()
+ .gt(0, { message: 'Amount must be greater than 0.' })
+ ,
+ status: z.enum(['pending', 'paid'], {
+ invalid_type_error: 'Please select an invoice status.'
+ }),
+ date: z.string(),
+});
+
+
+
+export type State = {
+ errors?: {
+ customerId?: string[];
+ amount?: string[];
+ status?: string[];
+ };
+ message?: string | null;
+};
+
+
+// Use Zod to update the expected types
+const UpdateInvoice = FormSchema.omit({ id: true, date: true });
+const CreateInvoice = FormSchema.omit({ id: true, data: true })
+
+// ...
+export async function createInvoice(prevState: State, formData: FormData) {
+ const date = new Date().toISOString().split('T')[0];
+ // Validate form using Zod
+ const validatedFields = CreateInvoice.safeParse({
+ customerId: formData.get('customerId'),
+ amount: formData.get('amount'),
+ status: formData.get('status'),
+ date: date,
+ });
+
+ // 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;
+
+ // Insert data into the database
+ try {
+ await sql`
+ INSERT INTO invoices (customer_id, amount, status, date)
+ VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
+ `;
+ } catch (error) {
+ // If a database error occurs, return a more specific error.
+ return {
+ message: 'Database Error: Failed to Create Invoice.',
+ };
+ }
+
+ // Revalidate the cache for the invoices page and redirect the user.
+ revalidatePath('/dashboard/invoices');
+ redirect('/dashboard/invoices');
+}
+
+export async function updateInvoice(
+ id: string,
+ prevState: State,
+ formData: FormData,
+) {
+ const validatedFields = UpdateInvoice.safeParse({
+ 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 { customerId, amount, status } = validatedFields.data;
+ const amountInCents = amount * 100;
+
+ try {
+ await sql`
+ UPDATE invoices
+ SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
+ WHERE id = ${id}
+ `;
+ } catch (error) {
+ return { message: 'Database Error: Failed to Update Invoice.' };
+ }
+
+ revalidatePath('/dashboard/invoices');
+ redirect('/dashboard/invoices');
+}
+
+
+export async function deleteInvoice(id: string) {
+
+ try {
+ await sql`DELETE FROM invoices WHERE id = ${id}`;
+ } catch (error) {
+ return { message: 'Database Error: Failed to delete invoice.' }
+ }
+
+ revalidatePath('/dashboard/invoices');
+}
diff --git a/app/lib/data.ts b/app/lib/data.ts
index 3e32426..f987b5e 100644
--- a/app/lib/data.ts
+++ b/app/lib/data.ts
@@ -1,4 +1,5 @@
import { sql } from '@vercel/postgres';
+
import {
CustomerField,
CustomersTableType,
@@ -11,6 +12,7 @@ import {
import { formatCurrency } from './utils';
export async function fetchRevenue() {
+ await new Promise((resolve) => setTimeout(resolve, 3000));
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
@@ -34,6 +36,7 @@ export async function fetchRevenue() {
export async function fetchLatestInvoices() {
try {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
const data = await sql`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
FROM invoices
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..d65e4b0
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,18 @@
+'use client'
+import AcmeLogo from '@/app/ui/acme-logo';
+import LoginForm from '@/app/ui/login-form';
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/ui/dashboard/cards.tsx b/app/ui/dashboard/cards.tsx
index 0ee0286..ef8c390 100644
--- a/app/ui/dashboard/cards.tsx
+++ b/app/ui/dashboard/cards.tsx
@@ -5,6 +5,7 @@ import {
InboxIcon,
} from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
+import { fetchCardData } from '@/app/lib/data';
const iconMap = {
collected: BanknotesIcon,
@@ -14,18 +15,24 @@ const iconMap = {
};
export default async function CardWrapper() {
+ const {
+ numberOfInvoices,
+ numberOfCustomers,
+ totalPaidInvoices,
+ totalPendingInvoices,
+ } = await fetchCardData();
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
- {/*
+
*/}
+ />
>
);
}
diff --git a/app/ui/dashboard/latest-invoices.tsx b/app/ui/dashboard/latest-invoices.tsx
index 92962ee..9bdecf9 100644
--- a/app/ui/dashboard/latest-invoices.tsx
+++ b/app/ui/dashboard/latest-invoices.tsx
@@ -2,12 +2,10 @@ import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
-import { LatestInvoice } from '@/app/lib/definitions';
-export default async function LatestInvoices({
- latestInvoices,
-}: {
- latestInvoices: LatestInvoice[];
-}) {
+import { fetchLatestInvoices } from '@/app/lib/data';
+
+export default async function LatestInvoices() {
+ const latestInvoices = await fetchLatestInvoices();
return (
@@ -16,7 +14,7 @@ export default async function LatestInvoices({
{/* NOTE: comment in this code when you get to this point in the course */}
- {/*
+
{latestInvoices.map((invoice, i) => {
return (
);
})}
-
*/}
+
Updated just now
diff --git a/app/ui/dashboard/revenue-chart.tsx b/app/ui/dashboard/revenue-chart.tsx
index 7ccc409..16ab4ca 100644
--- a/app/ui/dashboard/revenue-chart.tsx
+++ b/app/ui/dashboard/revenue-chart.tsx
@@ -1,7 +1,7 @@
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
-import { Revenue } from '@/app/lib/definitions';
+import {fetchRevenue} from '@/app/lib/data';
// This component is representational only.
// For data visualization UI, check out:
@@ -9,19 +9,16 @@ import { Revenue } from '@/app/lib/definitions';
// https://www.chartjs.org/
// https://airbnb.io/visx/
-export default async function RevenueChart({
- revenue,
-}: {
- revenue: Revenue[];
-}) {
+export default async function RevenueChart() {
+ const revenue = await fetchRevenue();
const chartHeight = 350;
// NOTE: comment in this code when you get to this point in the course
- // const { yAxisLabels, topLabel } = generateYAxis(revenue);
+ const { yAxisLabels, topLabel } = generateYAxis(revenue);
- // if (!revenue || revenue.length === 0) {
- // return
No data available.
;
- // }
+ if (!revenue || revenue.length === 0) {
+ return
No data available.
;
+ }
return (
@@ -30,7 +27,7 @@ export default async function RevenueChart({
{/* NOTE: comment in this code when you get to this point in the course */}
- {/*
);
}
diff --git a/app/ui/dashboard/sidenav.tsx b/app/ui/dashboard/sidenav.tsx
index 3d55b46..e979374 100644
--- a/app/ui/dashboard/sidenav.tsx
+++ b/app/ui/dashboard/sidenav.tsx
@@ -2,6 +2,8 @@ import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
+import { signOut } from '@/auth';
+
export default function SideNav() {
return (
@@ -17,7 +19,12 @@ export default function SideNav() {
+
+ {state.errors?.amount &&
+ state.errors.amount.map((error: string) => (
+
+ {error}
+
+ ))}
+
@@ -71,6 +97,7 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
type="radio"
value="pending"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
+ aria-describedby="status-error"
/>
+
+ {state.errors?.status &&
+ state.errors.status.map((error: string) => (
+
+ {error}
+
+ ))}
+
+
+ {state.message !== 'null' &&
+ (
+
+ {state.message}
+
+ )
+ }
+
diff --git a/app/ui/invoices/edit-form.tsx b/app/ui/invoices/edit-form.tsx
index 8673667..f667cfa 100644
--- a/app/ui/invoices/edit-form.tsx
+++ b/app/ui/invoices/edit-form.tsx
@@ -8,7 +8,9 @@ import {
UserCircleIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
+import { useFormState } from 'react-dom';
import { Button } from '@/app/ui/button';
+import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
@@ -17,8 +19,12 @@ export default function EditInvoiceForm({
invoice: InvoiceForm;
customers: CustomerField[];
}) {
+ const initialState = { message: null, errors: {} };
+ const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
+ const [state, dispatch] = useFormState(updateInvoiceWithId, initialState);
+
return (
-
+
{/* Customer Name */}
diff --git a/app/ui/invoices/pagination.tsx b/app/ui/invoices/pagination.tsx
index 1a29cce..8eaaebf 100644
--- a/app/ui/invoices/pagination.tsx
+++ b/app/ui/invoices/pagination.tsx
@@ -4,17 +4,27 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
+import { usePathname, useSearchParams } from 'next/navigation';
export default function Pagination({ totalPages }: { totalPages: number }) {
// NOTE: comment in this code when you get to this point in the course
- // const allPages = generatePagination(currentPage, totalPages);
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const currentPage = Number(searchParams.get('page')) || 1;
+ const allPages = generatePagination(currentPage, totalPages);
+ const createPageURL = (pageNumber: number | string) => {
+ const params = new URLSearchParams(searchParams);
+ params.set('page', pageNumber.toString());
+ console.log(pathname + "?" + params.toString())
+ return `${pathname}?${params.toString()}`;
+ };
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
- {/*
>
);
}
diff --git a/app/ui/login-form.tsx b/app/ui/login-form.tsx
index 80d4b17..8a305b9 100644
--- a/app/ui/login-form.tsx
+++ b/app/ui/login-form.tsx
@@ -1,3 +1,4 @@
+'use client'
import { lusitana } from '@/app/ui/fonts';
import {
AtSymbolIcon,
@@ -6,10 +7,14 @@ import {
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from './button';
+import { useFormState, useFormStatus } from 'react-dom';
+import { authenticate } from '../lib/actions';
export default function LoginForm() {
+ const [errorMessage, dispatch] = useFormState(authenticate, undefined);
+
return (
-
+
Please log in to continue.
@@ -56,8 +61,17 @@ export default function LoginForm() {
-
- {/* Add form errors here */}
+
+ {errorMessage && (
+ <>
+
+
{errorMessage}
+ >
+ )}
@@ -65,8 +79,10 @@ export default function LoginForm() {
}
function LoginButton() {
+
+ const { pending } = useFormStatus();
return (
-
+
Log in
);
diff --git a/app/ui/search.tsx b/app/ui/search.tsx
index e6e9391..8d5687c 100644
--- a/app/ui/search.tsx
+++ b/app/ui/search.tsx
@@ -1,8 +1,24 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { useSearchParams, useRouter, usePathname } from 'next/navigation';
+import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
+ const searchParam = useSearchParams();
+ const pathName = usePathname();
+ const { replace } = useRouter();
+
+ const handlerSearch = useDebouncedCallback((term: string) => {
+ const params = new URLSearchParams(searchParam);
+ if (term) {
+ params.set('query', term);
+ } else {
+ params.delete('query');
+ }
+ replace(`${pathName}?${params.toString()}`);
+ }, 300);
+
return (
@@ -11,6 +27,8 @@ export default function Search({ placeholder }: { placeholder: string }) {
handlerSearch(e.target.value)}
+ defaultValue={searchParam.get('query')?.toString()}
/>
diff --git a/auth.config.ts b/auth.config.ts
new file mode 100644
index 0000000..b480147
--- /dev/null
+++ b/auth.config.ts
@@ -0,0 +1,21 @@
+import type { NextAuthConfig } from 'next-auth';
+
+export const authConfig = {
+ pages: {
+ signIn: '/login',
+ },
+ callbacks: {
+ authorized({ auth, request: { nextUrl } }) {
+ const isLoggedIn = !!auth?.user;
+ const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
+ if (isOnDashboard) {
+ if (isLoggedIn) return true;
+ return false; // Redirect unauthenticated users to login page
+ } else if (isLoggedIn) {
+ return Response.redirect(new URL('/dashboard', nextUrl));
+ }
+ return true;
+ },
+ },
+ providers: [], // Add providers with an empty array for now
+} satisfies NextAuthConfig;
\ No newline at end of file
diff --git a/auth.ts b/auth.ts
new file mode 100644
index 0000000..8c98548
--- /dev/null
+++ b/auth.ts
@@ -0,0 +1,44 @@
+import NextAuth from 'next-auth';
+import { authConfig } from './auth.config';
+import Credentials from 'next-auth/providers/credentials';
+import { z } from 'zod';
+import { sql } from '@vercel/postgres';
+import type { User } from '@/app/lib/definitions';
+import bcrypt from 'bcrypt';
+
+
+async function getUser(email: string): Promise {
+ try {
+ const user = await sql`SELECT * FROM users WHERE email=${email}`;
+ return user.rows[0];
+ } catch (error) {
+ console.error('Failed to fetch user:', error);
+ throw new Error('Failed to fetch user.');
+ }
+ }
+
+
+
+export const { auth, signIn, signOut } = NextAuth({
+ ...authConfig,
+ providers: [
+ Credentials({
+ async authorize(credentials) {
+ const parsedCredentials = z
+ .object({ email: z.string().email(), password: z.string().min(6) })
+ .safeParse(credentials);
+
+ if (parsedCredentials.success) {
+ const { email, password } = parsedCredentials.data;
+ const user = await getUser(email);
+ if (!user) return null;
+ const passwordsMatch = await bcrypt.compare(password, user.password);
+ if (passwordsMatch) return user;
+ }
+
+ return null
+ },
+ }),
+ ],
+
+});
\ No newline at end of file
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..1fdda5b
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,9 @@
+import NextAuth from 'next-auth';
+import { authConfig } from './auth.config';
+
+export default NextAuth(authConfig).auth;
+
+export const config = {
+ // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
+ matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
+};
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 43432d7..c0a8cf6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,16 +8,18 @@
"@heroicons/react": "^2.0.18",
"@tailwindcss/forms": "^0.5.7",
"@types/node": "20.5.7",
- "@vercel/postgres": "^0.5.0",
+ "@vercel/postgres": "^0.5.1",
"autoprefixer": "10.4.15",
"bcrypt": "^5.1.1",
"clsx": "^2.0.0",
"next": "^14.0.2",
+ "next-auth": "^5.0.0-beta.13",
"postcss": "8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.3",
"typescript": "5.2.2",
+ "use-debounce": "^10.0.0",
"zod": "^3.22.2"
},
"devDependencies": {
@@ -69,6 +71,36 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@auth/core": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.27.0.tgz",
+ "integrity": "sha512-3bydnRJIM/Al6mkYmb53MsC+6G8ojw3lLPzwgVnX4dCo6N2lrib6Wq6r0vxZIhuHGjLObqqtUfpeaEj5aeTHFg==",
+ "dependencies": {
+ "@panva/hkdf": "^1.1.1",
+ "@types/cookie": "0.6.0",
+ "cookie": "0.6.0",
+ "jose": "^5.1.3",
+ "oauth4webapi": "^2.4.0",
+ "preact": "10.11.3",
+ "preact-render-to-string": "5.2.3"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^6.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
@@ -1035,6 +1067,14 @@
"node": ">= 8"
}
},
+ "node_modules/@panva/hkdf": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz",
+ "integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/@pkgr/utils": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz",
@@ -1089,6 +1129,11 @@
"@types/node": "*"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
+ },
"node_modules/@types/json-schema": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz",
@@ -1534,12 +1579,12 @@
"dev": true
},
"node_modules/@vercel/postgres": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@vercel/postgres/-/postgres-0.5.0.tgz",
- "integrity": "sha512-MFWp9SZmADqBe2x2mzEvwmGLiwOd8PVkUxYeBZx/RqdHl0bd8/1BH0zBR+zSimGyi9P/MVtZoJLdf5dkWw9m5Q==",
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/@vercel/postgres/-/postgres-0.5.1.tgz",
+ "integrity": "sha512-JKl8QOBIDnifhkxAhIKtY0A5Tb8oWBf2nzZhm0OH7Ffjsl0hGVnDL2w1/FCfpX8xna3JAWM034NGuhZfTFdmiw==",
"dependencies": {
"@neondatabase/serverless": "0.6.0",
- "bufferutil": "4.0.7",
+ "bufferutil": "4.0.8",
"utf-8-validate": "6.0.3",
"ws": "8.14.2"
},
@@ -2037,9 +2082,9 @@
}
},
"node_modules/bufferutil": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz",
- "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
+ "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"hasInstallScript": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
@@ -2296,6 +2341,14 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -4655,6 +4708,14 @@
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==",
"dev": true
},
+ "node_modules/jose": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz",
+ "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5056,6 +5117,32 @@
}
}
},
+ "node_modules/next-auth": {
+ "version": "5.0.0-beta.13",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.13.tgz",
+ "integrity": "sha512-2m2Gq69WQ0YXcHCCpHn2y5z1bxSlqD/XOuAgrdtz49/VIAdTFFeYZz97RYqf6xMF8VGmoG32VUnJ6LzaHk6Fwg==",
+ "dependencies": {
+ "@auth/core": "0.27.0"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "next": "^14",
+ "nodemailer": "^6.6.5",
+ "react": "^18.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -5184,6 +5271,14 @@
"set-blocking": "^2.0.0"
}
},
+ "node_modules/oauth4webapi": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.3.tgz",
+ "integrity": "sha512-9FkXEXfzVKzH63GUOZz1zMr3wBaICSzk6DLXx+CGdrQ10ItNk2ePWzYYc1fdmKq1ayGFb2aX97sRCoZ2s0mkDw==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5704,6 +5799,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/preact": {
+ "version": "10.11.3",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
+ "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
+ "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
+ "dependencies": {
+ "pretty-format": "^3.8.0"
+ },
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5818,6 +5933,11 @@
}
}
},
+ "node_modules/pretty-format": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
+ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7100,6 +7220,17 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-debounce": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.0.tgz",
+ "integrity": "sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==",
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/utf-8-validate": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.3.tgz",
diff --git a/package.json b/package.json
index 0a75375..66ffb49 100644
--- a/package.json
+++ b/package.json
@@ -5,22 +5,25 @@
"dev": "next dev",
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
- "start": "next start"
+ "start": "next start",
+ "seed": "node -r dotenv/config ./scripts/seed.js"
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@tailwindcss/forms": "^0.5.7",
"@types/node": "20.5.7",
- "@vercel/postgres": "^0.5.0",
+ "@vercel/postgres": "^0.5.1",
"autoprefixer": "10.4.15",
"bcrypt": "^5.1.1",
"clsx": "^2.0.0",
"next": "^14.0.2",
+ "next-auth": "^5.0.0-beta.13",
"postcss": "8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.3",
"typescript": "5.2.2",
+ "use-debounce": "^10.0.0",
"zod": "^3.22.2"
},
"devDependencies": {