aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/globals.css85
-rw-r--r--src/app/groups/[groupId]/edit/page.tsx36
-rw-r--r--src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx42
-rw-r--r--src/app/groups/[groupId]/expenses/create/page.tsx37
-rw-r--r--src/app/groups/[groupId]/page.tsx141
-rw-r--r--src/app/groups/[groupId]/save-recent-group.tsx18
-rw-r--r--src/app/groups/create/page.tsx29
-rw-r--r--src/app/groups/page.tsx14
-rw-r--r--src/app/groups/recent-group-list.tsx43
-rw-r--r--src/app/groups/recent-groups-helpers.ts30
-rw-r--r--src/app/layout.tsx7
-rw-r--r--src/app/page.tsx115
-rw-r--r--src/components/expense-form.tsx186
-rw-r--r--src/components/group-form.tsx136
-rw-r--r--src/components/ui/badge.tsx36
-rw-r--r--src/components/ui/button.tsx56
-rw-r--r--src/components/ui/card.tsx79
-rw-r--r--src/components/ui/checkbox.tsx30
-rw-r--r--src/components/ui/form.tsx176
-rw-r--r--src/components/ui/input.tsx25
-rw-r--r--src/components/ui/label.tsx26
-rw-r--r--src/components/ui/select.tsx160
-rw-r--r--src/components/ui/table.tsx117
-rw-r--r--src/lib/api.ts155
-rw-r--r--src/lib/prisma.ts17
-rw-r--r--src/lib/schemas.ts50
-rw-r--r--src/lib/utils.ts6
27 files changed, 1722 insertions, 130 deletions
diff --git a/src/app/globals.css b/src/app/globals.css
index fd81e88..8abdb15 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2,26 +2,75 @@
@tailwind components;
@tailwind utilities;
-:root {
- --foreground-rgb: 0, 0, 0;
- --background-start-rgb: 214, 219, 220;
- --background-end-rgb: 255, 255, 255;
-}
-
-@media (prefers-color-scheme: dark) {
+@layer base {
:root {
- --foreground-rgb: 255, 255, 255;
- --background-start-rgb: 0, 0, 0;
- --background-end-rgb: 0, 0, 0;
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
}
}
-body {
- color: rgb(var(--foreground-rgb));
- background: linear-gradient(
- to bottom,
- transparent,
- rgb(var(--background-end-rgb))
- )
- rgb(var(--background-start-rgb));
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/src/app/groups/[groupId]/edit/page.tsx b/src/app/groups/[groupId]/edit/page.tsx
new file mode 100644
index 0000000..23bd865
--- /dev/null
+++ b/src/app/groups/[groupId]/edit/page.tsx
@@ -0,0 +1,36 @@
+import { GroupForm } from '@/components/group-form'
+import { Button } from '@/components/ui/button'
+import { getGroup, updateGroup } from '@/lib/api'
+import { groupFormSchema } from '@/lib/schemas'
+import { ChevronLeft } from 'lucide-react'
+import Link from 'next/link'
+import { notFound, redirect } from 'next/navigation'
+
+export default async function EditGroupPage({
+ params: { groupId },
+}: {
+ params: { groupId: string }
+}) {
+ const group = await getGroup(groupId)
+ if (!group) notFound()
+
+ async function updateGroupAction(values: unknown) {
+ 'use server'
+ const groupFormValues = groupFormSchema.parse(values)
+ const group = await updateGroup(groupId, groupFormValues)
+ redirect(`/groups/${group.id}`)
+ }
+
+ return (
+ <main>
+ <div className="mb-4">
+ <Button variant="ghost" asChild>
+ <Link href={`/groups/${groupId}`}>
+ <ChevronLeft className="w-4 h-4 mr-2" /> Back to group
+ </Link>
+ </Button>
+ </div>
+ <GroupForm group={group} onSubmit={updateGroupAction} />
+ </main>
+ )
+}
diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
new file mode 100644
index 0000000..f22ac08
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
@@ -0,0 +1,42 @@
+import { ExpenseForm } from '@/components/expense-form'
+import { Button } from '@/components/ui/button'
+import { getExpense, getGroup, updateExpense } from '@/lib/api'
+import { expenseFormSchema } from '@/lib/schemas'
+import { ChevronLeft } from 'lucide-react'
+import Link from 'next/link'
+import { notFound, redirect } from 'next/navigation'
+
+export default async function EditExpensePage({
+ params: { groupId, expenseId },
+}: {
+ params: { groupId: string; expenseId: string }
+}) {
+ const group = await getGroup(groupId)
+ if (!group) notFound()
+ const expense = await getExpense(groupId, expenseId)
+ if (!expense) notFound()
+
+ async function updateExpenseAction(values: unknown) {
+ 'use server'
+ const expenseFormValues = expenseFormSchema.parse(values)
+ await updateExpense(groupId, expenseId, expenseFormValues)
+ redirect(`/groups/${groupId}`)
+ }
+
+ return (
+ <main>
+ <div className="mb-4">
+ <Button variant="ghost" asChild>
+ <Link href={`/groups/${groupId}`}>
+ <ChevronLeft className="w-4 h-4 mr-2" /> Back to group
+ </Link>
+ </Button>
+ </div>
+ <ExpenseForm
+ group={group}
+ expense={expense}
+ onSubmit={updateExpenseAction}
+ />
+ </main>
+ )
+}
diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx
new file mode 100644
index 0000000..f8d0ff9
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/create/page.tsx
@@ -0,0 +1,37 @@
+import { ExpenseForm } from '@/components/expense-form'
+import { Button } from '@/components/ui/button'
+import { createExpense, getGroup } from '@/lib/api'
+import { expenseFormSchema } from '@/lib/schemas'
+import { ChevronLeft } from 'lucide-react'
+import Link from 'next/link'
+import { notFound, redirect } from 'next/navigation'
+
+export default async function ExpensePage({
+ params: { groupId },
+}: {
+ params: { groupId: string }
+}) {
+ const group = await getGroup(groupId)
+ if (!group) notFound()
+
+ async function createExpenseAction(values: unknown) {
+ 'use server'
+ const expenseFormValues = expenseFormSchema.parse(values)
+ await createExpense(expenseFormValues, groupId)
+ redirect(`/groups/${groupId}`)
+ }
+
+ return (
+ <main>
+ <div className="mb-4">
+ <Button variant="ghost" asChild>
+ <Link href={`/groups/${groupId}`}>
+ <ChevronLeft className="w-4 h-4 mr-2" /> Back to group
+ </Link>
+ </Button>
+ </div>
+
+ <ExpenseForm group={group} onSubmit={createExpenseAction} />
+ </main>
+ )
+}
diff --git a/src/app/groups/[groupId]/page.tsx b/src/app/groups/[groupId]/page.tsx
new file mode 100644
index 0000000..1577d7a
--- /dev/null
+++ b/src/app/groups/[groupId]/page.tsx
@@ -0,0 +1,141 @@
+import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { getGroup, getGroupExpenses } from '@/lib/api'
+import { ChevronLeft, ChevronRight, Edit, Plus } from 'lucide-react'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+
+export default async function GroupPage({
+ params: { groupId },
+}: {
+ params: { groupId: string }
+}) {
+ const group = await getGroup(groupId)
+ if (!group) notFound()
+
+ const expenses = await getGroupExpenses(groupId)
+
+ return (
+ <main>
+ <div className="mb-4 flex justify-between">
+ <Button variant="ghost" asChild>
+ <Link href="/groups">
+ <ChevronLeft className="w-4 h-4 mr-2" /> Back to recent groups
+ </Link>
+ </Button>
+ <Button variant="secondary" asChild>
+ <Link href={`/groups/${groupId}/edit`}>
+ <Edit className="w-4 h-4 mr-2" /> Edit group settings
+ </Link>
+ </Button>
+ </div>
+
+ <h1 className="font-bold mb-4">{group.name}</h1>
+
+ <Card className="mb-4">
+ <div className="flex flex-1">
+ <CardHeader className="flex-1">
+ <CardTitle>Expenses</CardTitle>
+ <CardDescription>
+ Here are the expenses that you created for your group.
+ </CardDescription>
+ </CardHeader>
+ <CardHeader>
+ <Button asChild size="icon">
+ <Link href={`/groups/${groupId}/expenses/create`}>
+ <Plus />
+ </Link>
+ </Button>
+ </CardHeader>
+ </div>
+ <CardContent className="p-0">
+ <Table className="">
+ <TableHeader>
+ <TableRow>
+ <TableHead>Title</TableHead>
+ <TableHead>Paid by</TableHead>
+ <TableHead>Paid for</TableHead>
+ <TableHead className="text-right">Amount</TableHead>
+ <TableHead className="w-0"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {expenses.map((expense) => (
+ <TableRow key={expense.id}>
+ <TableCell>{expense.title}</TableCell>
+ <TableCell>
+ <Badge variant="secondary">
+ {
+ group.participants.find(
+ (p) => p.id === expense.paidById,
+ )?.name
+ }
+ </Badge>
+ </TableCell>
+ <TableCell className="flex flex-wrap gap-1">
+ {expense.paidFor.map((paidFor, index) => (
+ <Badge variant="secondary" key={index}>
+ {
+ group.participants.find(
+ (p) => p.id === paidFor.participantId,
+ )?.name
+ }
+ </Badge>
+ ))}
+ </TableCell>
+ <TableCell className="text-right tabular-nums">
+ $ {expense.amount.toFixed(2)}
+ </TableCell>
+ <TableCell>
+ <Button
+ size="icon"
+ variant="link"
+ className="-my-2"
+ asChild
+ >
+ <Link
+ href={`/groups/${groupId}/expenses/${expense.id}/edit`}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Link>
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+
+ <Card className="mb-4">
+ <CardHeader>
+ <CardTitle>Participants</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ul>
+ {group.participants.map((participant) => (
+ <li key={participant.id}>{participant.name}</li>
+ ))}
+ </ul>
+ </CardContent>
+ </Card>
+ <SaveGroupLocally group={{ id: group.id, name: group.name }} />
+ </main>
+ )
+}
diff --git a/src/app/groups/[groupId]/save-recent-group.tsx b/src/app/groups/[groupId]/save-recent-group.tsx
new file mode 100644
index 0000000..e55d7c8
--- /dev/null
+++ b/src/app/groups/[groupId]/save-recent-group.tsx
@@ -0,0 +1,18 @@
+'use client'
+import {
+ RecentGroup,
+ saveRecentGroup,
+} from '@/app/groups/recent-groups-helpers'
+import { useEffect } from 'react'
+
+type Props = {
+ group: RecentGroup
+}
+
+export function SaveGroupLocally({ group }: Props) {
+ useEffect(() => {
+ saveRecentGroup(group)
+ }, [])
+
+ return null
+}
diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx
new file mode 100644
index 0000000..4f220d2
--- /dev/null
+++ b/src/app/groups/create/page.tsx
@@ -0,0 +1,29 @@
+import { GroupForm } from '@/components/group-form'
+import { Button } from '@/components/ui/button'
+import { createGroup } from '@/lib/api'
+import { groupFormSchema } from '@/lib/schemas'
+import { ChevronLeft } from 'lucide-react'
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+
+export default function CreateGroupPage() {
+ async function createGroupAction(values: unknown) {
+ 'use server'
+ const groupFormValues = groupFormSchema.parse(values)
+ const group = await createGroup(groupFormValues)
+ redirect(`/groups/${group.id}`)
+ }
+
+ return (
+ <main>
+ <div className="mb-4">
+ <Button variant="ghost" asChild>
+ <Link href="/groups">
+ <ChevronLeft className="w-4 h-4 mr-2" /> Back to recent groups
+ </Link>
+ </Button>
+ </div>
+ <GroupForm onSubmit={createGroupAction} />
+ </main>
+ )
+}
diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx
new file mode 100644
index 0000000..7e4f484
--- /dev/null
+++ b/src/app/groups/page.tsx
@@ -0,0 +1,14 @@
+import { RecentGroupList } from '@/app/groups/recent-group-list'
+import { Button } from '@/components/ui/button'
+import Link from 'next/link'
+
+export default async function GroupsPage() {
+ return (
+ <main>
+ <Button asChild>
+ <Link href="/groups/create">New group</Link>
+ </Button>
+ <RecentGroupList />
+ </main>
+ )
+}
diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx
new file mode 100644
index 0000000..e728193
--- /dev/null
+++ b/src/app/groups/recent-group-list.tsx
@@ -0,0 +1,43 @@
+'use client'
+import { getRecentGroups } from '@/app/groups/recent-groups-helpers'
+import { Button } from '@/components/ui/button'
+import Link from 'next/link'
+import { useEffect, useState } from 'react'
+import { z } from 'zod'
+
+const recentGroupsSchema = z.array(
+ z.object({
+ id: z.string().min(1),
+ name: z.string(),
+ }),
+)
+type RecentGroups = z.infer<typeof recentGroupsSchema>
+
+type State = { status: 'pending' } | { status: 'success'; groups: RecentGroups }
+
+export function RecentGroupList() {
+ const [state, setState] = useState<State>({ status: 'pending' })
+
+ useEffect(() => {
+ const groupsInStorage = getRecentGroups()
+ setState({ status: 'success', groups: groupsInStorage })
+ }, [])
+
+ return (
+ <ul className="flex flex-col gap-2 mt-2">
+ {state.status === 'pending' ? (
+ <li>
+ <em>Loading recent groups…</em>
+ </li>
+ ) : (
+ state.groups.map(({ id, name }) => (
+ <li key={id}>
+ <Button asChild variant="outline">
+ <Link href={`/groups/${id}`}>{name}</Link>
+ </Button>
+ </li>
+ ))
+ )}
+ </ul>
+ )
+}
diff --git a/src/app/groups/recent-groups-helpers.ts b/src/app/groups/recent-groups-helpers.ts
new file mode 100644
index 0000000..5d52141
--- /dev/null
+++ b/src/app/groups/recent-groups-helpers.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod'
+
+export const recentGroupsSchema = z.array(
+ z.object({
+ id: z.string().min(1),
+ name: z.string(),
+ }),
+)
+
+export type RecentGroups = z.infer<typeof recentGroupsSchema>
+export type RecentGroup = RecentGroups[number]
+
+const STORAGE_KEY = 'recentGroups'
+
+export function getRecentGroups() {
+ const groupsInStorageJson = localStorage.getItem(STORAGE_KEY)
+ const groupsInStorageRaw = groupsInStorageJson
+ ? JSON.parse(groupsInStorageJson)
+ : []
+ const parseResult = recentGroupsSchema.safeParse(groupsInStorageRaw)
+ return parseResult.success ? parseResult.data : []
+}
+
+export function saveRecentGroup(group: RecentGroup) {
+ const recentGroups = getRecentGroups()
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]),
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 40e027f..4a83b57 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,9 +1,6 @@
import type { Metadata } from 'next'
-import { Inter } from 'next/font/google'
import './globals.css'
-const inter = Inter({ subsets: ['latin'] })
-
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
@@ -16,7 +13,9 @@ export default function RootLayout({
}) {
return (
<html lang="en">
- <body className={inter.className}>{children}</body>
+ <body>
+ <div className="max-w-screen-md mx-auto p-4">{children}</div>
+ </body>
</html>
)
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index e38c626..a94e2d5 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,113 +1,12 @@
-import Image from 'next/image'
+import { Button } from '@/components/ui/button'
+import Link from 'next/link'
-export default function Home() {
+export default function HomePage() {
return (
- <main className="flex min-h-screen flex-col items-center justify-between p-24">
- <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
- <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
- Get started by editing&nbsp;
- <code className="font-mono font-bold">src/app/page.tsx</code>
- </p>
- <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
- <a
- className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
- href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
- target="_blank"
- rel="noopener noreferrer"
- >
- By{' '}
- <Image
- src="/vercel.svg"
- alt="Vercel Logo"
- className="dark:invert"
- width={100}
- height={24}
- priority
- />
- </a>
- </div>
- </div>
-
- <div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
- <Image
- className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
- src="/next.svg"
- alt="Next.js Logo"
- width={180}
- height={37}
- priority
- />
- </div>
-
- <div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
- <a
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
- className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
- target="_blank"
- rel="noopener noreferrer"
- >
- <h2 className={`mb-3 text-2xl font-semibold`}>
- Docs{' '}
- <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
- -&gt;
- </span>
- </h2>
- <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
- Find in-depth information about Next.js features and API.
- </p>
- </a>
-
- <a
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
- className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
- target="_blank"
- rel="noopener noreferrer"
- >
- <h2 className={`mb-3 text-2xl font-semibold`}>
- Learn{' '}
- <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
- -&gt;
- </span>
- </h2>
- <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
- Learn about Next.js in an interactive course with&nbsp;quizzes!
- </p>
- </a>
-
- <a
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
- className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
- target="_blank"
- rel="noopener noreferrer"
- >
- <h2 className={`mb-3 text-2xl font-semibold`}>
- Templates{' '}
- <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
- -&gt;
- </span>
- </h2>
- <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
- Explore the Next.js 13 playground.
- </p>
- </a>
-
- <a
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
- className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
- target="_blank"
- rel="noopener noreferrer"
- >
- <h2 className={`mb-3 text-2xl font-semibold`}>
- Deploy{' '}
- <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
- -&gt;
- </span>
- </h2>
- <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
- Instantly deploy your Next.js site to a shareable URL with Vercel.
- </p>
- </a>
- </div>
+ <main>
+ <Button asChild variant="link">
+ <Link href="/groups">My groups</Link>
+ </Button>
</main>
)
}
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
new file mode 100644
index 0000000..056d25e
--- /dev/null
+++ b/src/components/expense-form.tsx
@@ -0,0 +1,186 @@
+'use client'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { getExpense, getGroup } from '@/lib/api'
+import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useForm } from 'react-hook-form'
+
+export type Props = {
+ group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
+ expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
+ onSubmit: (values: ExpenseFormValues) => void
+}
+
+export function ExpenseForm({ group, expense, onSubmit }: Props) {
+ const form = useForm<ExpenseFormValues>({
+ resolver: zodResolver(expenseFormSchema),
+ defaultValues: expense
+ ? {
+ title: expense.title,
+ amount: expense.amount,
+ paidBy: expense.paidById,
+ paidFor: expense.paidFor.map(({ participantId }) => participantId),
+ }
+ : { title: '', amount: 0, paidFor: [] },
+ })
+
+ return (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
+ <Card>
+ <CardHeader>
+ <CardTitle>Expense information</CardTitle>
+ </CardHeader>
+ <CardContent className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Expense title</FormLabel>
+ <FormControl>
+ <Input placeholder="Monday evening restaurant" {...field} />
+ </FormControl>
+ <FormDescription>
+ Enter a description for the expense.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="paidBy"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Paid by</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a participant" />
+ </SelectTrigger>
+ <SelectContent>
+ {group.participants.map(({ id, name }) => (
+ <SelectItem key={id} value={id}>
+ {name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ Select the participant who paid the expense.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="amount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Amount</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min={0.01}
+ step={0.01}
+ placeholder="0.00"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>Enter the expense amount.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="paidFor"
+ render={() => (
+ <FormItem>
+ <div className="mb-4">
+ <FormLabel>Paid for</FormLabel>
+ <FormDescription>
+ Select who the expense was paid for.
+ </FormDescription>
+ </div>
+ {group.participants.map(({ id, name }) => (
+ <FormField
+ key={id}
+ control={form.control}
+ name="paidFor"
+ render={({ field }) => {
+ return (
+ <FormItem
+ key={id}
+ className="flex flex-row items-start space-x-3 space-y-0"
+ >
+ <FormControl>
+ <Checkbox
+ checked={field.value?.includes(id)}
+ onCheckedChange={(checked) => {
+ return checked
+ ? field.onChange([...field.value, id])
+ : field.onChange(
+ field.value?.filter(
+ (value) => value !== id,
+ ),
+ )
+ }}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-normal">
+ {name}
+ </FormLabel>
+ </FormItem>
+ )
+ }}
+ />
+ ))}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+
+ <CardFooter>
+ <Button variant="secondary" type="submit">
+ Submit
+ </Button>
+ </CardFooter>
+ </Card>
+ </form>
+ </Form>
+ )
+}
diff --git a/src/components/group-form.tsx b/src/components/group-form.tsx
new file mode 100644
index 0000000..0b6e560
--- /dev/null
+++ b/src/components/group-form.tsx
@@ -0,0 +1,136 @@
+'use client'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { getGroup } from '@/lib/api'
+import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useFieldArray, useForm } from 'react-hook-form'
+
+export type Props = {
+ group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
+ onSubmit: (groupFormValues: GroupFormValues) => void
+}
+
+export function GroupForm({ group, onSubmit }: Props) {
+ const form = useForm<GroupFormValues>({
+ resolver: zodResolver(groupFormSchema),
+ defaultValues: group
+ ? {
+ name: group.name,
+ participants: group.participants,
+ }
+ : {
+ name: '',
+ participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
+ },
+ })
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'participants',
+ })
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit((values) => {
+ onSubmit(values)
+ })}
+ className="space-y-8"
+ >
+ <Card>
+ <CardHeader>
+ <CardTitle>Group information</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Group name</FormLabel>
+ <FormControl>
+ <Input placeholder="Summer vacations" {...field} />
+ </FormControl>
+ <FormDescription>
+ Enter a name for your group.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader>
+ <CardTitle>Participants</CardTitle>
+ <CardDescription>
+ Enter the name for each participant
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <ul className="flex flex-col gap-4">
+ {fields.map((item, index) => (
+ <li key={item.id}>
+ <FormField
+ control={form.control}
+ name={`participants.${index}.name`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="sr-only">
+ Participant #{index + 1}
+ </FormLabel>
+ <FormControl>
+ <div className="flex gap-2">
+ <Input {...field} />
+ <Button
+ variant="destructive"
+ onClick={() => remove(index)}
+ type="button"
+ >
+ Remove
+ </Button>
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </li>
+ ))}
+ </ul>
+ </CardContent>
+ <CardFooter>
+ <Button
+ variant="secondary"
+ onClick={() => {
+ append({ name: 'New' })
+ }}
+ type="button"
+ >
+ Add participant
+ </Button>
+ </CardFooter>
+ </Card>
+
+ <Button type="submit">Submit</Button>
+ </form>
+ </Form>
+ )
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..0ba4277
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+ <Comp
+ className={cn(buttonVariants({ variant, size, className }))}
+ ref={ref}
+ {...props}
+ />
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..afa13ec
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn(
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
+ className
+ )}
+ {...props}
+ />
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
+ {...props}
+ />
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h3
+ ref={ref}
+ className={cn(
+ "text-2xl font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <p
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("flex items-center p-6 pt-0", className)}
+ {...props}
+ />
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..df61a13
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <CheckboxPrimitive.Root
+ ref={ref}
+ className={cn(
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+ className
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ className={cn("flex items-center justify-center text-current")}
+ >
+ <Check className="h-4 w-4" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx
new file mode 100644
index 0000000..4603f8b
--- /dev/null
+++ b/src/components/ui/form.tsx
@@ -0,0 +1,176 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ ControllerProps,
+ FieldPath,
+ FieldValues,
+ FormProvider,
+ useFormContext,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext<FormFieldContextValue>(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
+>({
+ ...props
+}: ControllerProps<TFieldValues, TName>) => {
+ return (
+ <FormFieldContext.Provider value={{ name: props.name }}>
+ <Controller {...props} />
+ </FormFieldContext.Provider>
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within <FormField>")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext<FormItemContextValue>(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+ <FormItemContext.Provider value={{ id }}>
+ <div ref={ref} className={cn("space-y-2", className)} {...props} />
+ </FormItemContext.Provider>
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef<typeof LabelPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+ <Label
+ ref={ref}
+ className={cn(error && "text-destructive", className)}
+ htmlFor={formItemId}
+ {...props}
+ />
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef<typeof Slot>,
+ React.ComponentPropsWithoutRef<typeof Slot>
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+ <Slot
+ ref={ref}
+ id={formItemId}
+ aria-describedby={
+ !error
+ ? `${formDescriptionId}`
+ : `${formDescriptionId} ${formMessageId}`
+ }
+ aria-invalid={!!error}
+ {...props}
+ />
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+ <p
+ ref={ref}
+ id={formDescriptionId}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message) : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+ <p
+ ref={ref}
+ id={formMessageId}
+ className={cn("text-sm font-medium text-destructive", className)}
+ {...props}
+ >
+ {body}
+ </p>
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..677d05f
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
+
+const Input = React.forwardRef<HTMLInputElement, InputProps>(
+ ({ className, type, ...props }, ref) => {
+ return (
+ <input
+ type={type}
+ className={cn(
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ ref={ref}
+ {...props}
+ />
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef<typeof LabelPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+ VariantProps<typeof labelVariants>
+>(({ className, ...props }, ref) => (
+ <LabelPrimitive.Root
+ ref={ref}
+ className={cn(labelVariants(), className)}
+ {...props}
+ />
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..cbe5a36
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+ <SelectPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon asChild>
+ <ChevronDown className="h-4 w-4 opacity-50" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollUpButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className
+ )}
+ {...props}
+ >
+ <ChevronUp className="h-4 w-4" />
+ </SelectPrimitive.ScrollUpButton>
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollDownButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className
+ )}
+ {...props}
+ >
+ <ChevronDown className="h-4 w-4" />
+ </SelectPrimitive.ScrollDownButton>
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ ref={ref}
+ className={cn(
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ position === "popper" &&
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+ className
+ )}
+ position={position}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ "p-1",
+ position === "popper" &&
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Label
+ ref={ref}
+ className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
+ {...props}
+ />
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+ <SelectPrimitive.Item
+ ref={ref}
+ className={cn(
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <Check className="h-4 w-4" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Separator
+ ref={ref}
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
+ {...props}
+ />
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..7f3502f
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+ <div className="relative w-full overflow-auto">
+ <table
+ ref={ref}
+ className={cn("w-full caption-bottom text-sm", className)}
+ {...props}
+ />
+ </div>
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tbody
+ ref={ref}
+ className={cn("[&_tr:last-child]:border-0", className)}
+ {...props}
+ />
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tfoot
+ ref={ref}
+ className={cn(
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+ <tr
+ ref={ref}
+ className={cn(
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+ className
+ )}
+ {...props}
+ />
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <th
+ ref={ref}
+ className={cn(
+ "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <td
+ ref={ref}
+ className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
+ {...props}
+ />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+ <caption
+ ref={ref}
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..6ea26f4
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,155 @@
+import { getPrisma } from '@/lib/prisma'
+import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
+import { Expense } from '@prisma/client'
+import { v4 as uuidv4 } from 'uuid'
+
+export async function createGroup(groupFormValues: GroupFormValues) {
+ return getPrisma().group.create({
+ data: {
+ id: uuidv4(),
+ name: groupFormValues.name,
+ participants: {
+ createMany: {
+ data: groupFormValues.participants.map(({ name }) => ({
+ id: uuidv4(),
+ name,
+ })),
+ },
+ },
+ },
+ include: { participants: true },
+ })
+}
+
+export async function createExpense(
+ expenseFormValues: ExpenseFormValues,
+ groupId: string,
+): Promise<Expense> {
+ const group = await getGroup(groupId)
+ if (!group) throw new Error(`Invalid group ID: ${groupId}`)
+
+ for (const participant of [
+ expenseFormValues.paidBy,
+ ...expenseFormValues.paidFor,
+ ]) {
+ if (!group.participants.some((p) => p.id === participant))
+ throw new Error(`Invalid participant ID: ${participant}`)
+ }
+
+ return getPrisma().expense.create({
+ data: {
+ id: uuidv4(),
+ groupId,
+ amount: expenseFormValues.amount,
+ title: expenseFormValues.title,
+ paidById: expenseFormValues.paidBy,
+ paidFor: {
+ createMany: {
+ data: expenseFormValues.paidFor.map((paidFor) => ({
+ participantId: paidFor,
+ })),
+ },
+ },
+ },
+ })
+}
+
+export async function updateExpense(
+ groupId: string,
+ expenseId: string,
+ expenseFormValues: ExpenseFormValues,
+) {
+ const group = await getGroup(groupId)
+ if (!group) throw new Error(`Invalid group ID: ${groupId}`)
+
+ const existingExpense = await getExpense(groupId, expenseId)
+ if (!existingExpense) throw new Error(`Invalid expense ID: ${expenseId}`)
+
+ for (const participant of [
+ expenseFormValues.paidBy,
+ ...expenseFormValues.paidFor,
+ ]) {
+ if (!group.participants.some((p) => p.id === participant))
+ throw new Error(`Invalid participant ID: ${participant}`)
+ }
+
+ return getPrisma().expense.update({
+ where: { id: expenseId },
+ data: {
+ amount: expenseFormValues.amount,
+ title: expenseFormValues.title,
+ paidById: expenseFormValues.paidBy,
+ paidFor: {
+ connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({
+ where: {
+ expenseId_participantId: { expenseId, participantId: paidFor },
+ },
+ create: { participantId: paidFor },
+ })),
+ deleteMany: existingExpense.paidFor.filter(
+ (paidFor) =>
+ !expenseFormValues.paidFor.some(
+ (pf) => pf === paidFor.participantId,
+ ),
+ ),
+ },
+ },
+ })
+}
+
+export async function updateGroup(
+ groupId: string,
+ groupFormValues: GroupFormValues,
+) {
+ const existingGroup = await getGroup(groupId)
+ if (!existingGroup) throw new Error('Invalid group ID')
+
+ return getPrisma().group.update({
+ where: { id: groupId },
+ data: {
+ name: groupFormValues.name,
+ participants: {
+ deleteMany: existingGroup.participants.filter(
+ (p) => !groupFormValues.participants.some((p2) => p2.id === p.id),
+ ),
+ updateMany: groupFormValues.participants
+ .filter((participant) => participant.id !== undefined)
+ .map((participant) => ({
+ where: { id: participant.id },
+ data: {
+ name: participant.name,
+ },
+ })),
+ createMany: {
+ data: groupFormValues.participants
+ .filter((participant) => participant.id === undefined)
+ .map((participant) => ({
+ id: uuidv4(),
+ name: participant.name,
+ })),
+ },
+ },
+ },
+ })
+}
+
+export async function getGroup(groupId: string) {
+ return getPrisma().group.findUnique({
+ where: { id: groupId },
+ include: { participants: true },
+ })
+}
+
+export async function getGroupExpenses(groupId: string) {
+ return getPrisma().expense.findMany({
+ where: { groupId },
+ include: { paidFor: { include: { participant: true } }, paidBy: true },
+ })
+}
+
+export async function getExpense(groupId: string, expenseId: string) {
+ return getPrisma().expense.findUnique({
+ where: { id: expenseId },
+ include: { paidBy: true, paidFor: true },
+ })
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..773547e
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -0,0 +1,17 @@
+import { PrismaClient } from '@prisma/client'
+
+let prisma: PrismaClient
+
+export function getPrisma() {
+ if (!prisma) {
+ if (process.env.NODE_ENV === 'production') {
+ prisma = new PrismaClient()
+ } else {
+ if (!(global as any).prisma) {
+ ;(global as any).prisma = new PrismaClient()
+ }
+ prisma = (global as any).prisma
+ }
+ }
+ return prisma
+}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
new file mode 100644
index 0000000..0219491
--- /dev/null
+++ b/src/lib/schemas.ts
@@ -0,0 +1,50 @@
+import * as z from 'zod'
+
+export const groupFormSchema = z
+ .object({
+ name: z
+ .string()
+ .min(2, 'Enter at least two characters.')
+ .max(50, 'Enter at most 50 characters.'),
+ participants: z
+ .array(
+ z.object({
+ id: z.string().optional(),
+ name: z
+ .string()
+ .min(2, 'Enter at least two characters.')
+ .max(50, 'Enter at most 50 characters.'),
+ }),
+ )
+ .min(1),
+ })
+ .superRefine(({ participants }, ctx) => {
+ participants.forEach((participant, i) => {
+ participants.slice(0, i).forEach((otherParticipant) => {
+ if (otherParticipant.name === participant.name) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Another participant already has this name.',
+ path: ['participants', i, 'name'],
+ })
+ }
+ })
+ })
+ })
+
+export type GroupFormValues = z.infer<typeof groupFormSchema>
+
+export const expenseFormSchema = z.object({
+ title: z
+ .string({ required_error: 'Please enter a title.' })
+ .min(2, 'Enter at least two characters.'),
+ amount: z.coerce
+ .number({ required_error: 'You must enter an amount.' })
+ .min(0.01, 'The amount must be higher than 0.01.'),
+ paidBy: z.string({ required_error: 'You must select a participant.' }),
+ paidFor: z
+ .array(z.string())
+ .min(1, 'The expense must be paid for at least 1 participant.'),
+})
+
+export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..ec79801
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}