awesome ui changes

This commit is contained in:
shrt 2025-04-07 19:58:58 +02:00
parent 0cec39717e
commit 2ddbef6627
52 changed files with 1965 additions and 484 deletions

View File

@ -26,6 +26,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",

112
pnpm-lock.yaml generated
View File

@ -32,6 +32,12 @@ importers:
'@radix-ui/react-popover': '@radix-ui/react-popover':
specifier: ^1.1.6 specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-separator':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2(@types/react@19.1.0)(react@19.1.0) version: 1.1.2(@types/react@19.1.0)(react@19.1.0)
@ -705,6 +711,9 @@ packages:
'@petamoriken/float16@3.9.2': '@petamoriken/float16@3.9.2':
resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
'@radix-ui/primitive@1.1.1': '@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
@ -948,6 +957,32 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-select@2.1.6':
resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.2':
resolution: {integrity: sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.2': '@radix-ui/react-slot@1.1.2':
resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==}
peerDependencies: peerDependencies:
@ -1006,6 +1041,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0': '@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies: peerDependencies:
@ -1024,6 +1068,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-visually-hidden@1.1.2':
resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.0': '@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
@ -2085,6 +2142,8 @@ snapshots:
'@petamoriken/float16@3.9.2': {} '@petamoriken/float16@3.9.2': {}
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {} '@radix-ui/primitive@1.1.1': {}
'@radix-ui/react-arrow@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
@ -2334,6 +2393,44 @@ snapshots:
'@types/react': 19.1.0 '@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0) '@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/react-select@2.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-collection': 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-direction': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-popper': 1.2.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.4(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.1.2(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
aria-hidden: 1.2.4
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-remove-scroll: 2.6.3(@types/react@19.1.0)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/react-separator@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/react-slot@1.1.2(@types/react@19.1.0)(react@19.1.0)': '@radix-ui/react-slot@1.1.2(@types/react@19.1.0)(react@19.1.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0)
@ -2383,6 +2480,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.0 '@types/react': 19.1.0
'@radix-ui/react-use-previous@1.1.0(@types/react@19.1.0)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.0
'@radix-ui/react-use-rect@1.1.0(@types/react@19.1.0)(react@19.1.0)': '@radix-ui/react-use-rect@1.1.0(@types/react@19.1.0)(react@19.1.0)':
dependencies: dependencies:
'@radix-ui/rect': 1.1.0 '@radix-ui/rect': 1.1.0
@ -2397,6 +2500,15 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.0 '@types/react': 19.1.0
'@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/rect@1.1.0': {} '@radix-ui/rect@1.1.0': {}
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}

View File

@ -9,27 +9,27 @@ export const appConfig: AppConfig = {
{ {
name: "Home", name: "Home",
href: "/", href: "/",
icon: "Home", icon: "home",
}, },
{ {
name: "Expenses", name: "Expenses",
href: "/expense", href: "/expense",
icon: "Wallet", icon: "wallet",
}, },
{ {
name: "Add Expense", name: "Add",
href: "/add", href: "/add",
icon: "Plus", icon: "addSquare",
}, },
{ {
name: "Groups", name: "Groups",
href: "/group", href: "/group",
icon: "Users", icon: "group",
}, },
{ {
name: "Friends", name: "Friends",
href: "/friend", href: "/friend",
icon: "User", icon: "friends",
}, },
], ],
}; };

View File

@ -1,6 +1,7 @@
import ExpenseForm from "@/app/_components/expense/expense-form"; import ExpenseForm from "@/app/_components/expense/expense-form";
import Header from "@/components/header"; import Header from "@/components/header";
import Section from "@/components/section"; import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { auth } from "@/server/auth"; import { auth } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server"; import { api, HydrateClient } from "@/trpc/server";
import React from "react"; import React from "react";
@ -10,9 +11,13 @@ export default async function Page() {
if (session?.user) void api.friend.getAll.prefetch(); if (session?.user) void api.friend.getAll.prefetch();
return ( return (
<HydrateClient> <HydrateClient>
<Header text="Add Expense"></Header> <Header text="Add Expense">
<Button type="submit" form="expense-form">
Create Expense
</Button>
</Header>
<Section> <Section>
<ExpenseForm /> <ExpenseForm hideSubmit session={session!} />
</Section> </Section>
</HydrateClient> </HydrateClient>
); );

View File

@ -1,5 +1,16 @@
import Header from "@/components/header";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import React from "react"; import React from "react";
export default function Page() { export default function Page() {
return <div>Page</div>; return (
<>
<Header text="Expenses">
<Button asChild>
<Link href="/add">Add Expense</Link>
</Button>
</Header>
</>
);
} }

View File

@ -1,48 +1,18 @@
import AddFriendDrawer from "@/app/_components/friend/add-friend-drawer"; import AddFriendDrawer from "@/app/_components/friend/add-friend-drawer";
import FriendCard from "@/app/_components/friend/friend-card"; import FriendList from "@/app/_components/friend/friend-list";
import PendingRequestButton from "@/app/_components/friend/pending-request-button";
import Header from "@/components/header"; import Header from "@/components/header";
import Section from "@/components/section"; import { api, HydrateClient } from "@/trpc/server";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/server";
import Link from "next/link";
import React from "react"; import React from "react";
export default async function Page() { export default async function Page() {
const friendResult = await api.friend.getAll(); void api.friend.getAll.prefetch();
const pendingFriends = friendResult.filter((f) => f.status === "pending");
const friends = friendResult.filter((f) => f.status === "accepted");
return ( return (
<> <HydrateClient>
<Header text="Friends"> <Header text="Friends">
<AddFriendDrawer /> <AddFriendDrawer />
</Header> </Header>
{pendingFriends.length ? ( <FriendList />
<Section heading="Requests"> </HydrateClient>
{pendingFriends.map(({ user, requestedBy, id }) => (
<FriendCard key={user.id} user={user}>
<PendingRequestButton
friendshipId={id}
requestedBy={requestedBy}
/>
</FriendCard>
))}
</Section>
) : null}
<Section heading={`Friends (${friends?.length ?? 0})`}>
{friends?.length ? (
friends.map(({ user }) => (
<FriendCard key={user.id} user={user}>
<Button size={"sm"} className="ml-auto" asChild>
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
</Button>
</FriendCard>
))
) : (
<p className="text-muted-foreground text-sm">No friends yet</p>
)}
</Section>
</>
); );
} }

View File

@ -1,5 +1,13 @@
import CreateGroupDrawer from "@/app/_components/group/create-group-drawer";
import Header from "@/components/header";
import React from "react"; import React from "react";
export default function Page() { export default function Page() {
return <div>Page</div>; return (
<>
<Header text="Groups">
<CreateGroupDrawer />
</Header>
</>
);
} }

View File

@ -11,8 +11,10 @@ export default async function Layout({
const session = await auth(); const session = await auth();
if (!session?.user) return redirect("/api/auth/signin"); if (!session?.user) return redirect("/api/auth/signin");
return ( return (
<div className="space-y-2 w-full mx-auto max-w bg-muted/25 h-screen flex flex-col justify-between"> <div className="space-y-2 w-full mx-auto max-w h-screen flex flex-col justify-between bg-card/25 overflow-hidden">
<main className="space-y-4 ">{children}</main> <main className="space-y-4 rounded-b-[3rem] bg-background h-full border border-card z-20 relative overflow-y-auto pb-8">
{children}
</main>
<Navbar /> <Navbar />
</div> </div>
); );

View File

@ -1,13 +1,18 @@
import { appConfig } from "@/app.config"; import { appConfig } from "@/app.config";
import Header from "@/components/header"; import Header from "@/components/header";
import { ModeToggle } from "@/components/mode-toggle"; import Section from "@/components/section";
import { auth } from "@/server/auth";
import UserDropdown from "../_components/user-dropdown";
export default async function Home() { export default async function Home() {
const session = await auth();
return ( return (
<> <>
<Header text={appConfig.name}> <Header text={appConfig.name}>
<ModeToggle /> <UserDropdown user={session?.user!} />
</Header> </Header>
<Section></Section>
</> </>
); );
} }

View File

@ -15,33 +15,92 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { expenseSchema } from "@/lib/validations/expense"; import { expenseSchema } from "@/lib/validations/expense";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MoneyInput } from "@/components/number-input";
import FriendSelect from "../friend/friend-select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
function ExpenseForm({ initialExpense }: { initialExpense?: Expense }) { import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split";
import type { Session } from "next-auth";
import { useExpenseSplit } from "@/hooks/use-expense-split";
import { toast } from "sonner";
import { api } from "@/trpc/react";
function ExpenseForm({
initialExpense,
session,
hideSubmit,
}: {
initialExpense?: Expense;
session: Session;
hideSubmit?: boolean;
}) {
const createExpense = api.expense.create.useMutation();
const form = useForm<z.infer<typeof expenseSchema>>({ const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema), resolver: zodResolver(expenseSchema),
defaultValues: { defaultValues: {
description: initialExpense?.description ?? "", description: initialExpense?.description ?? "",
amount: initialExpense?.amount ?? 0, amount: initialExpense?.amount ?? 0,
groupId: initialExpense?.groupId ?? "",
}, },
}); });
const amount = form.watch("amount");
const expenseSplitHook = useExpenseSplit(amount, session);
function onSubmit(values: z.infer<typeof expenseSchema>) { function onSubmit(values: z.infer<typeof expenseSchema>) {
console.log(values); if (expenseSplitHook.participants.length <= 1)
return toast.error("Please add at least 2 participants");
if (expenseSplitHook.splitType !== "Equal")
return toast.error("You can only split equal for now.");
toast.success("Expense created!");
} }
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 "> <form
<div className="flex items-center"> onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
id="expense-form"
>
{!hideSubmit && (
<Button type="submit" className="ml-auto"> <Button type="submit" className="ml-auto">
{initialExpense?.id?.length ? "Update" : "Create"} Expense {initialExpense?.id?.length ? "Update" : "Create"} Expense
</Button> </Button>
</div> )}
<Tabs defaultValue="expense" className="size-full space-y-4"> <FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount</FormLabel>
<div className="group flex border-b gap-2 px-4 py-2 border-input items-center ">
<NumberInput
value={field.value}
onChange={field.onChange}
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 p-0"
/>
<EuroIcon className="size-6 text-muted-foreground" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-muted-foreground border-0 border-b rounded-none resize-none"
placeholder="About this expense"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ExpenseSplit expenseSplitHook={expenseSplitHook} session={session} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0"> <TabsList className="w-full bg-transparent p-0">
<TabsTrigger value="expense">Expense</TabsTrigger> <TabsTrigger value="expense">Expense</TabsTrigger>
<TabsTrigger value="split">Split</TabsTrigger> <TabsTrigger value="split">Split</TabsTrigger>
@ -49,35 +108,48 @@ function ExpenseForm({ initialExpense }: { initialExpense?: Expense }) {
<TabsContent value="expense" className="space-y-8 size-full"> <TabsContent value="expense" className="space-y-8 size-full">
<FormField <FormField
control={form.control} control={form.control}
name="description" name="amount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-col justify-between h-full ">
<FormLabel>Description</FormLabel> <FormLabel> Amount</FormLabel>
<FormControl> {/* <MoneyInput
<Textarea placeholder="About this expense" {...field} /> onChange={field.onChange}
</FormControl> showNumpad={true}
className="absolute w-full max-w left-1/2 -translate-x-1/2 transform bottom-20 px-4"
/>
<div className="flex border-b gap-2 px-4 py-2 border-input items-center">
<NumberInput
value={field.value}
onChange={field.onChange}
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 p-0"
/>
<EuroIcon className="size-6 text-muted-foreground" />
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="amount" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full "> <FormItem>
<FormLabel> Amount: {field.value?.toFixed(2)}</FormLabel> <FormLabel>Description</FormLabel>
<MoneyInput <FormControl>
onChange={field.onChange} <Textarea
showNumpad={true} className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 border-b rounded-none resize-none"
className="absolute w-full max-w left-1/2 -translate-x-1/2 transform bottom-20 px-4" placeholder="About this expense"
/> {...field}
/>
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<ExpenseSplit amount={amount} session={session} />
</TabsContent> </TabsContent>
<TabsContent value="split"> <TabsContent value="split">
<FormField {/* <FormField
control={form.control} control={form.control}
name="friendId" name="friendId"
render={({ field }) => ( render={({ field }) => (
@ -94,7 +166,7 @@ function ExpenseForm({ initialExpense }: { initialExpense?: Expense }) {
)} )}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs> */}
</form> </form>
</Form> </Form>
); );

View File

@ -0,0 +1,148 @@
"use client";
import React from "react";
import FriendSelect from "../friend/friend-select";
import { Button } from "@/components/ui/button";
import type { Session } from "next-auth";
import FriendCard from "../friend/friend-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Icons } from "@/components/icons";
import {
splitTypeKeys,
type ExpenseSplitHook,
type SplitType,
} from "@/hooks/use-expense-split";
import PaidByInput from "./paid-by";
import { Separator } from "@radix-ui/react-select";
export default function ExpenseSplit({
expenseSplitHook,
session,
}: {
expenseSplitHook: ExpenseSplitHook;
session: Session;
}) {
const {
friends,
splitType,
setSplitType,
friendTarget,
setFriendTarget,
addParticipant,
removeParticipant,
participants,
splits,
} = expenseSplitHook;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PaidByInput
expenseSplitHook={expenseSplitHook}
sessionUser={session.user}
/>
</div>
<Separator className=" bg-primary w-full h-px my-12" />
<Select
value={splitType}
onValueChange={(currentValue) =>
setSplitType(currentValue as SplitType)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Split" />
</SelectTrigger>
<SelectContent>
{splitTypeKeys.map((splitType) => (
<SelectItem key={splitType} value={splitType}>
Split {splitType}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2 items-center w-full">
<FriendSelect
className="grow"
onSelect={setFriendTarget}
initialValue={friendTarget}
excludeIds={participants.map((user) => user.id!)}
/>
<Button
variant={"outline"}
size={"icon"}
onClick={(e) => {
e.preventDefault();
if (!friendTarget) return;
const newUser = friends.find(
(friend) => friend.user.id === friendTarget
)!;
addParticipant(newUser.user!);
setFriendTarget(undefined);
}}
>
<Icons.add className="size-4" />
</Button>
</div>
<h4 className="text-lg">Owed to</h4>
<ul className="space-y-2">
{participants.map((user) => (
<li key={user.id}>
<FriendCard
user={user}
className="border bg-background relative pr-6"
>
{session.user.id !== user.id ? (
<Button
variant={"destructive"}
className="p-0 w-6 h-6 rounded-full absolute top-1/2 -translate-y-1/2 transform -right-2"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<Icons.trash className="size-4" />
</Button>
) : null}
<div className="flex ml-auto">
<p className="text-muted-foreground">{splits[user.id!]}</p>
</div>
</FriendCard>
</li>
))}
</ul>
<h4 className="text-lg">Paid from</h4>
<ul className="space-y-2">
{participants.map((user) => (
<li key={user.id}>
<FriendCard
user={user}
className="border bg-background relative pr-6"
>
{session.user.id !== user.id ? (
<Button
variant={"destructive"}
className="p-0 w-6 h-6 rounded-full absolute top-1/2 -translate-y-1/2 transform -right-2"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<Icons.trash className="size-4" />
</Button>
) : null}
<div className="flex ml-auto">
<p className="text-muted-foreground">{splits[user.id!]}</p>
</div>
</FriendCard>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,86 @@
"use client";
import type { Payment } from "@/lib/utils/expense";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
import React from "react";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function PaidByCustomSplit({
users,
payments,
}: {
users: Array<PublicUser>;
payments: Array<Payment>;
}) {
const [customAmounts, setCustomAmounts] = React.useState<
Record<string, number>
>({});
const error = Object.values(customAmounts).reduce(
(acc, curr) => acc + curr,
0
);
console.log(error);
return (
<Dialog>
<DialogTrigger className="mt-4">Multiple Friends</DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<DialogClose asChild>
<Button className="ml-auto">Done</Button>
</DialogClose>
</div>
{users.map((user) => {
return (
<div
key={user.id}
className="p-4 flex w-full h-max rounded-2xl border justify-between gap-2 overflow-hidden "
>
<div className="flex items-center gap-2">
<Avatar
className={cn("size-8 transition-transform duration-300")}
src={user.image}
fb={user.name}
/>
<span className={cn(" transition-all duration-300")}>
{user.name}
</span>
</div>
<div className="flex items-center gap-1 border-b">
<EuroIcon className="size-4" />
<NumberInput
value={customAmounts[user.id!] ?? 0.0}
onChange={(value) =>
setCustomAmounts((prev) => ({
...prev,
[user.id!]: value,
}))
}
className="w-20 rounded-none border-0"
/>
</div>
</div>
);
})}
</DialogHeader>
</DialogContent>
</Dialog>
);
}
export default PaidByCustomSplit;

View File

@ -0,0 +1,95 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
import type { ExpenseSplitHook } from "@/hooks/use-expense-split";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split";
/* saved result is an array of objects with user and amount */
export default function PaidByInput({
sessionUser,
expenseSplitHook,
}: {
sessionUser: PublicUser;
expenseSplitHook: ExpenseSplitHook;
}) {
const { amount, payments, setPayments, participants } = expenseSplitHook;
return (
<Dialog>
<DialogTrigger className="size-full flex items-center flex-wrap gap-2">
Paid by
<div className="py-1 rounded-md px-4 ">
{payments.length > 1 ? (
"2+ Users"
) : (
<span className="text-xl">
{payments.find(({ userId }) => userId === sessionUser.id)
? "You"
: participants.find(({ id }) => id === payments[0]?.userId)
?.name}
</span>
)}
</div>
</DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<DialogClose asChild>
<Button className="ml-auto">Go Back</Button>
</DialogClose>
</div>
{participants.map((user) => {
const selected = payments.find(({ userId }) => userId === user.id);
return (
<Button
variant={"none"}
key={user.id}
className="p-4 w-full h-max rounded-2xl border justify-between gap-2 overflow-hidden "
onClick={() =>
setPayments([{ amount: amount, userId: user.id! }])
}
>
<div className="flex items-center gap-2">
<Avatar
className={cn(
"size-8 transition-transform duration-300",
selected && "scale-[225%]"
)}
src={user.image}
fb={user.name}
/>
<span
className={cn(
" transition-all duration-300",
selected && "translate-x-6 text-lg"
)}
>
{user.name}
</span>
</div>
{payments.length === 1 && selected && (
<Icons.check className="ml-auto size-8" />
)}
</Button>
);
})}
<PaidByCustomSplit payments={payments} users={participants} />
</DialogHeader>
</DialogContent>
</Dialog>
);
}

View File

@ -1,7 +0,0 @@
import React from "react";
function SplitExpense() {
return <div>SplitExpense</div>;
}
export default SplitExpense;

View File

@ -45,6 +45,7 @@ const SearchResultCard = ({
}; };
export default function AddFriendDrawer() { export default function AddFriendDrawer() {
const utils = api.useUtils();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000); const [searchValue] = useDebounce(value, 1000);
@ -65,7 +66,8 @@ export default function AddFriendDrawer() {
onSuccess() { onSuccess() {
toast.success("Friend request sent!"); toast.success("Friend request sent!");
setOpen(false); setOpen(false);
refetch(); setValue("");
utils.friend.getAll.invalidate();
}, },
}); });
const handleAddFriend = (userId: string) => { const handleAddFriend = (userId: string) => {
@ -88,6 +90,7 @@ export default function AddFriendDrawer() {
autoFocus autoFocus
value={value} value={value}
onChange={(e) => setValue(e.currentTarget.value)} onChange={(e) => setValue(e.currentTarget.value)}
placeholder="Search for a friend"
/> />
<ul> <ul>
{isFetching ? ( {isFetching ? (

View File

@ -1,16 +1,24 @@
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils";
import type { PublicUser } from "next-auth"; import type { PublicUser } from "next-auth";
import React from "react"; import React from "react";
export default function FriendCard({ export default function FriendCard({
user: { name, image }, user: { name, image },
children, children,
className,
}: { }: {
user: PublicUser; user: PublicUser;
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
}) { }) {
return ( return (
<div className="flex items-center gap-2 p-2 hover:bg-accent/25 rounded-md "> <div
className={cn(
"flex items-center gap-2 p-2 hover:bg-accent/25 rounded-md",
className
)}
>
<Avatar fb={name} src={image} /> <Avatar fb={name} src={image} />
<span>{name}</span> <span>{name}</span>
{children} {children}

View File

@ -0,0 +1,50 @@
"use client";
import React from "react";
import FriendCard from "@/app/_components/friend/friend-card";
import PendingRequestButton from "@/app/_components/friend/pending-request-button";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { api } from "@/trpc/react";
function FriendList() {
const [friendResult] = api.friend.getAll.useSuspenseQuery();
const pendingFriends = friendResult.filter((f) => f.status === "pending");
const friends = friendResult.filter((f) => f.status === "accepted");
return (
<>
{pendingFriends.length ? (
<Section heading="Requests">
{pendingFriends.map(({ user, requestedBy, id }) => (
<FriendCard key={user.id} user={user}>
<PendingRequestButton
friendshipId={id}
requestedBy={requestedBy}
/>
</FriendCard>
))}
</Section>
) : null}
<Section heading={`Friends (${friends?.length ?? 0})`}>
{friends?.length ? (
friends.map(({ user }) => (
<FriendCard key={user.id} user={user}>
<Button
size={"sm"}
className="ml-auto"
variant={"outline"}
asChild
>
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
</Button>
</FriendCard>
))
) : (
<p className="text-muted-foreground text-sm">No friends yet</p>
)}
</Section>
</>
);
}
export default FriendList;

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Combobox } from "@/components/combobox"; import { Combobox } from "@/components/combobox";
import { Icons } from "@/components/icons";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import React from "react"; import React from "react";
@ -8,22 +9,35 @@ import React from "react";
export default function FriendSelect({ export default function FriendSelect({
onSelect, onSelect,
initialValue, initialValue,
className,
excludeIds,
}: { }: {
onSelect: (value?: string) => void; onSelect: (value?: string) => void;
initialValue?: string; initialValue?: string;
className?: string;
excludeIds?: Array<string>;
}) { }) {
const [friends] = api.friend.getAll.useSuspenseQuery(); const [friends] = api.friend.getAll.useSuspenseQuery();
const data = friends
.filter((friend) => !excludeIds?.includes(friend.user.id))
.map(({ user }) => ({
label: user.name!,
value: user.id,
children: <Avatar src={user.image} fb={user.name} className="size-6" />,
}));
return ( return (
<Combobox <Combobox
className="w-full" className={className}
data={friends.map(({ user }) => ({ data={data}
label: user.name!,
value: user.id,
children: <Avatar src={user.image} fb={user.name} className="size-6" />,
}))}
onSelect={onSelect} onSelect={onSelect}
initialValue={initialValue} initialValue={initialValue}
messageUi={{
select: "Select Friend",
empty: "No friends found",
placeholder: "Search friends",
}}
/> />
); );
} }

View File

@ -11,20 +11,23 @@ export default function PendingRequestButton({
friendshipId: string; friendshipId: string;
requestedBy: string; requestedBy: string;
}) { }) {
const utils = api.useUtils();
const handleSuccess = () => {
toast.success(
`Friend request ${requestedBy === "them" ? "accepted" : "cancelled"}`
);
utils.friend.getAll.invalidate();
};
const acceptRequest = api.friend.accept.useMutation({ const acceptRequest = api.friend.accept.useMutation({
onSuccess() { onSuccess: handleSuccess,
toast.success("Friend request accepted!");
},
}); });
const cancleRequest = api.friend.cancel.useMutation({ const cancleRequest = api.friend.cancel.useMutation({
onSuccess() { onSuccess: handleSuccess,
toast.success("Friend request cancelled!");
},
}); });
return ( return (
<Button <Button
variant={requestedBy === "me" ? "destructive" : "outline"} variant={requestedBy === "me" ? "destructive" : "default"}
className="ml-auto" className="ml-auto"
size="sm" size="sm"
onClick={() => { onClick={() => {

View File

@ -0,0 +1,34 @@
"use client";
import React from "react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
export default function CreateGroupDrawer() {
const [open, setOpen] = React.useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"}>Create Group</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create Group</DrawerTitle>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { User } from "next-auth";
import Avatar from "@/components/avatar";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons";
import Link from "next/link";
export default function UserDropdown({ user }: { user: User }) {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar fb={user.name} src={user.image} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between " asChild>
<Link href={"/me"}>
Profile
<Icons.user className="size-4 text-foreground" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="justify-between " asChild>
<Link href={"/billing"}>
Billing
<Icons.wallet className="size-4 text-foreground" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between text-destructive" asChild>
<Link href={"/api/auth/signout"}>
Logout
<Icons.logout className="size-4 text-destructive" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -31,7 +31,7 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
{children} {children}
<Toaster /> <Toaster position="top-center" />
</ThemeProvider> </ThemeProvider>
</TRPCReactProvider> </TRPCReactProvider>
</body> </body>

View File

@ -18,6 +18,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { IconComponent } from "./icons";
export type ComboboxProps = { export type ComboboxProps = {
data: { data: {
@ -29,7 +30,7 @@ export type ComboboxProps = {
onSelect: (value?: string) => void; onSelect: (value?: string) => void;
messageUi?: { messageUi?: {
select?: string; select?: string;
selectIcon?: LucideIcon; selectIcon?: LucideIcon | IconComponent;
empty?: string; empty?: string;
placeholder?: string; placeholder?: string;
}; };

View File

@ -1,16 +1,19 @@
import React from "react"; import React from "react";
import { ModeToggle } from "@/components/mode-toggle";
export default function Header({ export default function Header({
text, text,
children, children,
glow = true,
}: { }: {
text: string; text: string;
children?: React.ReactNode; children?: React.ReactNode;
glow?: boolean;
}) { }) {
return ( return (
<div className="w-full p-4 border-b flex justify-between items-center"> <div className="w-full p-4 border-b flex justify-between items-center relative">
<h2 className="font-bold text-xl ">{text}</h2> <h2 className="font-black text-2xl uppercase">{text}</h2>
<div className="glow absolute right-0 top-0 w-20 h-8 bg-primary blur-2xl -z-10" />
{children} {children}
</div> </div>
); );

286
src/components/icons.tsx Normal file
View File

@ -0,0 +1,286 @@
export type IconComponent = React.FC<React.SVGProps<SVGSVGElement>>;
type IconName =
| "logo"
| "home"
| "wallet"
| "add"
| "group"
| "friends"
| "logout"
| "login"
| "trash"
| "user"
| "addSquare"
| "check";
export const Icons: Record<IconName, IconComponent> = {
logo(props) {
return (
<svg
width="78"
height="32"
viewBox="0 0 78 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M55.5 0H77.5L58.5 32H36.5L55.5 0Z"
fill="currentColor"
fillOpacity={0.65}
/>
<path
d="M35.5 0H51.5L32.5 32H16.5L35.5 0Z"
fill="currentColor"
fillOpacity={0.85}
/>
<path d="M19.5 0H31.5L12.5 32H0.5L19.5 0Z" fill="currentColor" />
</svg>
);
},
home(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.29367 4.96556C8.96048 3.028 10.2939 2.05923 11.8167 2.00336C11.9389 1.99888 12.0611 1.99888 12.1833 2.00336C13.7061 2.05923 15.0395 3.028 17.7063 4.96556C20.3732 6.90311 21.7066 7.87189 22.2303 9.30291C22.2723 9.41771 22.3101 9.534 22.3436 9.65157C22.761 11.1171 22.2517 12.6846 21.2331 15.8197L19.512 21.1164C19.2386 21.958 18.4543 22.5279 17.5694 22.5279C16.4412 22.5279 15.5267 21.6133 15.5267 20.4852V17.7363C15.5267 16.6778 14.6686 15.8197 13.6101 15.8197H10.3899C9.3314 15.8197 8.47329 16.6778 8.47329 17.7363V20.4852C8.47329 21.6133 7.55877 22.5279 6.43065 22.5279C5.54572 22.5279 4.76144 21.958 4.48798 21.1164L2.76696 15.8197C1.74832 12.6846 1.23901 11.1171 1.65645 9.65157C1.68994 9.534 1.72773 9.41771 1.76974 9.30291C2.29344 7.87189 3.62685 6.90311 6.29367 4.96556Z"
fill="currentColor"
strokeWidth="1.5"
/>
</svg>
);
},
wallet(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M13.0336 3.25C14.4053 3.25 15.4807 3.24999 16.3451 3.32061C17.2252 3.39252 17.9523 3.54138 18.6104 3.87671C19.6924 4.42798 20.572 5.30762 21.1233 6.38955C21.5254 7.17877 21.6643 8.08233 21.7165 9.25H2.28354C2.33571 8.08233 2.47459 7.17877 2.87671 6.38955C3.42798 5.30762 4.30762 4.42798 5.38955 3.87671C6.04769 3.54138 6.77479 3.39252 7.65494 3.32061C8.51929 3.24999 9.59472 3.25 10.9664 3.25H13.0336Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.25227 10.75C2.25 11.1341 2.25 11.548 2.25 11.9942V12.0336C2.25 13.4053 2.24999 14.4807 2.32061 15.3451C2.39252 16.2252 2.54138 16.9523 2.87671 17.6104C3.42798 18.6924 4.30762 19.572 5.38955 20.1233C6.04769 20.4586 6.77479 20.6075 7.65494 20.6794C8.51924 20.75 9.59461 20.75 10.9662 20.75H13.0336C14.4052 20.75 15.4808 20.75 16.3451 20.6794C17.2252 20.6075 17.9523 20.4586 18.6104 20.1233C19.6924 19.572 20.572 18.6924 21.1233 17.6104C21.4586 16.9523 21.6075 16.2252 21.6794 15.3451C21.75 14.4808 21.75 13.4054 21.75 12.0338V11.9942C21.75 11.548 21.75 11.1341 21.7477 10.75H2.25227ZM12.25 17C12.25 16.5858 12.5858 16.25 13 16.25H17C17.4142 16.25 17.75 16.5858 17.75 17C17.75 17.4142 17.4142 17.75 17 17.75H13C12.5858 17.75 12.25 17.4142 12.25 17Z"
fill="currentColor"
/>
</svg>
);
},
add(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12.75 4C12.75 3.58579 12.4142 3.25 12 3.25C11.5858 3.25 11.25 3.58579 11.25 4L11.25 11.25H4C3.58579 11.25 3.25 11.5858 3.25 12C3.25 12.4142 3.58579 12.75 4 12.75H11.25V20C11.25 20.4142 11.5858 20.75 12 20.75C12.4142 20.75 12.75 20.4142 12.75 20V12.75H20C20.4142 12.75 20.75 12.4142 20.75 12C20.75 11.5858 20.4142 11.25 20 11.25H12.75L12.75 4Z"
fill="currentColor"
/>
</svg>
);
},
group(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M8.65063 10.3681C7.78554 9.50781 7.25 8.31645 7.25 7C7.25 5.68355 7.78554 4.49219 8.65063 3.63188C8.15256 3.38733 7.59232 3.25 7 3.25C4.92893 3.25 3.25 4.92893 3.25 7C3.25 9.07107 4.92893 10.75 7 10.75C7.59232 10.75 8.15256 10.6127 8.65063 10.3681Z"
fill="currentColor"
/>
<path
d="M8.25 7C8.25 4.92893 9.92893 3.25 12 3.25C14.0711 3.25 15.75 4.92893 15.75 7C15.75 9.07107 14.0711 10.75 12 10.75C9.92893 10.75 8.25 9.07107 8.25 7Z"
fill="currentColor"
/>
<path
d="M15.3494 3.63188C16.2145 4.49219 16.75 5.68355 16.75 7C16.75 8.31645 16.2145 9.50781 15.3494 10.3681C15.8474 10.6127 16.4077 10.75 17 10.75C19.0711 10.75 20.75 9.07107 20.75 7C20.75 4.92893 19.0711 3.25 17 3.25C16.4077 3.25 15.8474 3.38733 15.3494 3.63188Z"
fill="currentColor"
/>
<path
d="M5.25 16C5.25 13.9289 6.92893 12.25 9 12.25H15C17.0711 12.25 18.75 13.9289 18.75 16C18.75 18.0711 17.0711 19.75 15 19.75H9C6.92893 19.75 5.25 18.0711 5.25 16Z"
fill="currentColor"
/>
<path
d="M0.25 15C0.25 12.9289 1.92893 11.25 4 11.25H9C6.37665 11.25 4.25 13.3766 4.25 16C4.25 17.0249 4.57458 17.9739 5.12655 18.75H4C1.92893 18.75 0.25 17.0711 0.25 15Z"
fill="currentColor"
/>
<path
d="M19.75 16C19.75 17.0249 19.4254 17.9739 18.8734 18.75H20C22.0711 18.75 23.75 17.0711 23.75 15C23.75 12.9289 22.0711 11.25 20 11.25H15C17.6234 11.25 19.75 13.3766 19.75 16Z"
fill="currentColor"
/>
</svg>
);
},
friends(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.5 7.75C18.5 5.67893 16.8211 4 14.75 4C12.6789 4 11 5.67893 11 7.75C11 9.82107 12.6789 11.5 14.75 11.5C16.8211 11.5 18.5 9.82107 18.5 7.75Z"
fill="currentColor"
/>
<path
d="M11.4006 4.38188C10.5355 5.24219 10 6.43355 10 7.75C10 9.06645 10.5355 10.2578 11.4006 11.1181C10.9026 11.3627 10.3423 11.5 9.75 11.5C7.67893 11.5 6 9.82107 6 7.75C6 5.67893 7.67893 4 9.75 4C10.3423 4 10.9026 4.13733 11.4006 4.38188Z"
fill="currentColor"
/>
<path
d="M21.5 16.75C21.5 14.6789 19.8211 13 17.75 13H11.75C9.67893 13 8 14.6789 8 16.75C8 18.8211 9.67893 20.5 11.75 20.5H17.75C19.8211 20.5 21.5 18.8211 21.5 16.75Z"
fill="currentColor"
/>
<path
d="M7 16.75C7 17.7749 7.32458 18.7239 7.87655 19.5H6.75C4.67893 19.5 3 17.8211 3 15.75C3 13.6789 4.67893 12 6.75 12H11.75C9.12665 12 7 14.1266 7 16.75Z"
fill="currentColor"
/>
</svg>
);
},
logout(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 11.25C6.58579 11.25 6.25 11.5858 6.25 12C6.25 12.4142 6.58579 12.75 7 12.75L15 12.75L15 15C15 15.929 15 16.3935 14.9384 16.7822C14.5996 18.9216 12.9216 20.5996 10.7822 20.9384C10.3935 21 9.929 21 9 21C8.07099 21 7.60649 21 7.21783 20.9384C5.07836 20.5996 3.40041 18.9216 3.06156 16.7822C3 16.3935 3 15.929 3 15L3 9C3 8.07099 3 7.60649 3.06156 7.21783C3.40042 5.07836 5.07837 3.40042 7.21783 3.06156C7.60649 3 8.07099 3 9 3C9.92901 3 10.3935 3 10.7822 3.06156C12.9216 3.40042 14.5996 5.07836 14.9384 7.21783C15 7.60649 15 8.07099 15 9L15 11.25L7 11.25ZM15 11.25L19.8105 11.25C19.483 10.9273 19.001 10.5437 18.297 9.98553L16.534 8.58768C16.2095 8.33034 16.155 7.8586 16.4123 7.53403C16.6697 7.20946 17.1414 7.15497 17.466 7.41232L19.2648 8.83857C19.9372 9.37175 20.4922 9.81172 20.8875 10.2055C21.2932 10.6096 21.6294 11.0582 21.7208 11.6313C21.7402 11.7534 21.75 11.8766 21.75 12C21.75 12.1234 21.7402 12.2466 21.7208 12.3687C21.6294 12.9418 21.2932 13.3904 20.8875 13.7945C20.4922 14.1883 19.9373 14.6282 19.2648 15.1614L17.466 16.5877C17.1414 16.845 16.6697 16.7905 16.4123 16.466C16.155 16.1414 16.2095 15.6697 16.534 15.4123L18.297 14.0145C19.001 13.4563 19.483 13.0727 19.8105 12.75L15 12.75L15 11.25Z"
fill="currentColor"
/>
</svg>
);
},
login(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.466 7.41232C13.1414 7.15497 12.6697 7.20946 12.4123 7.53403C12.155 7.8586 12.2095 8.33034 12.534 8.58768L14.297 9.98553C15.001 10.5437 15.483 10.9273 15.8105 11.25H9V9C9 8.07099 9 7.60649 9.06156 7.21783C9.40042 5.07837 11.0784 3.40042 13.2178 3.06156C13.6065 3 14.071 3 15 3C15.929 3 16.3935 3 16.7822 3.06156C18.9216 3.40042 20.5996 5.07836 20.9384 7.21783C21 7.60649 21 8.07099 21 9V15C21 15.929 21 16.3935 20.9384 16.7822C20.5996 18.9216 18.9216 20.5996 16.7822 20.9384C16.3935 21 15.929 21 15 21C14.071 21 13.6065 21 13.2178 20.9384C11.0784 20.5996 9.40042 18.9216 9.06156 16.7822C9 16.3935 9 15.929 9 15V12.75H15.8105C15.483 13.0727 15.001 13.4563 14.297 14.0145L12.534 15.4123C12.2095 15.6697 12.155 16.1414 12.4123 16.466C12.6697 16.7905 13.1414 16.845 13.466 16.5877L15.2648 15.1614C15.9372 14.6282 16.4922 14.1883 16.8875 13.7945C17.2932 13.3904 17.6294 12.9418 17.7208 12.3687C17.7402 12.2466 17.75 12.1234 17.75 12C17.75 11.8766 17.7402 11.7534 17.7208 11.6313C17.6294 11.0582 17.2932 10.6096 16.8875 10.2055C16.4922 9.81172 15.9372 9.37175 15.2648 8.83857L13.466 7.41232ZM9 12.75L3 12.75C2.58579 12.75 2.25 12.4142 2.25 12C2.25 11.5858 2.58579 11.25 3 11.25L9 11.25V12.75Z"
fill="currentColor"
/>
</svg>
);
},
trash(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.2549 14.6645L18.5172 12.7795C18.5979 12.1995 18.6717 11.6693 18.7373 11.1829C18.8721 10.1834 18.9395 9.68367 18.6406 9.34184C18.3418 9 17.8282 9 16.801 9H7.19905C6.17182 9 5.65821 9 5.35938 9.34184C5.06054 9.68367 5.12793 10.1834 5.26272 11.1829C5.32833 11.6695 5.40207 12.1993 5.48281 12.7795L5.74515 14.6645C6.03026 16.7132 6.17282 17.7376 6.47509 18.5603C7.04034 20.0988 8.01744 21.2447 9.18366 21.7368C9.8073 22 10.5382 22 12 22C13.4618 22 14.1927 22 14.8164 21.7368C15.9826 21.2447 16.9597 20.0988 17.5249 18.5603C17.8272 17.7376 17.9698 16.7132 18.2549 14.6645ZM10.75 11C10.75 10.5858 10.4142 10.25 10 10.25C9.58579 10.25 9.25 10.5858 9.25 11V19C9.25 19.4142 9.58579 19.75 10 19.75C10.4142 19.75 10.75 19.4142 10.75 19V11ZM14.75 11C14.75 10.5858 14.4142 10.25 14 10.25C13.5858 10.25 13.25 10.5858 13.25 11V19C13.25 19.4142 13.5858 19.75 14 19.75C14.4142 19.75 14.75 19.4142 14.75 19V11Z"
fill="currentColor"
/>
<path
d="M12 1.25C9.37665 1.25 7.25 3.37665 7.25 6V6.25H4C3.58579 6.25 3.25 6.58579 3.25 7C3.25 7.41421 3.58579 7.75 4 7.75H20C20.4142 7.75 20.75 7.41421 20.75 7C20.75 6.58579 20.4142 6.25 20 6.25H16.75V6C16.75 3.37665 14.6234 1.25 12 1.25Z"
fill="currentColor"
/>
</svg>
);
},
user(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 2C9.37665 2 7.25 4.12665 7.25 6.75C7.25 9.37335 9.37665 11.5 12 11.5C14.6234 11.5 16.75 9.37335 16.75 6.75C16.75 4.12665 14.6234 2 12 2Z"
fill="currentColor"
/>
<path
d="M9 13C6.37665 13 4.25 15.1266 4.25 17.75C4.25 20.3734 6.37665 22.5 9 22.5H15C17.6234 22.5 19.75 20.3734 19.75 17.75C19.75 15.1266 17.6234 13 15 13H9Z"
fill="currentColor"
/>
</svg>
);
},
addSquare(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107ZM12.75 9C12.75 8.58579 12.4142 8.25 12 8.25C11.5858 8.25 11.25 8.58579 11.25 9V11.25H9C8.58579 11.25 8.25 11.5858 8.25 12C8.25 12.4142 8.58579 12.75 9 12.75H11.25V15C11.25 15.4142 11.5858 15.75 12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V12.75H15C15.4142 12.75 15.75 12.4142 15.75 12C15.75 11.5858 15.4142 11.25 15 11.25H12.75V9Z"
fill="currentColor"
/>
</svg>
);
},
check(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M20.5761 7.48016C20.8413 7.16195 20.7983 6.68903 20.4801 6.42385C20.1619 6.15868 19.689 6.20167 19.4238 6.51988L14.0331 12.9887C12.9503 14.2881 12.1885 15.1994 11.5278 15.796C10.8826 16.3787 10.4372 16.5639 9.99996 16.5639C9.5627 16.5639 9.11736 16.3787 8.47207 15.796C7.81137 15.1994 7.04963 14.2881 5.9668 12.9887L4.57612 11.3199C4.31095 11.0017 3.83803 10.9587 3.51982 11.2239C3.20161 11.489 3.15862 11.9619 3.42379 12.2802L4.85306 13.9953C5.88833 15.2376 6.71742 16.2326 7.46678 16.9092C8.24083 17.6082 9.03209 18.0639 9.99996 18.0639C10.9678 18.0639 11.7591 17.6082 12.5331 16.9092C13.2825 16.2326 14.1116 15.2377 15.1468 13.9953L20.5761 7.48016Z"
fill="currentColor"
/>
</svg>
);
},
};

View File

@ -0,0 +1,190 @@
"use client";
import type React from "react";
import { useState, useRef } from "react";
import { Delete } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface MoneyInputProps {
placeholder?: string;
defaultValue?: string | number;
onChange?: (value: number | null) => void;
className?: string;
disabled?: boolean;
error?: string;
min?: number;
max?: number;
showNumpad?: boolean;
}
export function MoneyInput({
placeholder = "0.00",
defaultValue = "",
onChange,
className,
disabled = false,
error,
min,
max,
showNumpad = true,
}: MoneyInputProps) {
const [value, setValue] = useState(() => {
if (typeof defaultValue === "number") {
return defaultValue.toString();
}
return defaultValue;
});
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const updateValue = (newValue: string) => {
setValue(newValue);
const numericValue = newValue === "" ? null : Number.parseFloat(newValue);
onChange?.(numericValue);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Allow only numbers and decimal point
const inputValue = e.target.value.replace(/[^0-9.]/g, "");
// Ensure only one decimal point
const parts = inputValue.split(".")!;
let formattedValue = inputValue;
if (parts.length > 1) {
// Limit to 2 decimal places
formattedValue = `${parts[0]}.${parts[1]?.substring(0, 2)}`;
}
updateValue(formattedValue);
};
const handleNumpadClick = (digit: string) => {
let newValue = value;
// Handle decimal point - only add if there isn't one already
if (digit === "." && value.includes(".")) {
return;
}
// Check if we're adding a digit after the decimal point
const parts = value.split(".");
if (parts.length > 1 && parts[1]?.length! >= 2 && digit !== ".") {
// Already have 2 decimal places, don't add more digits
return;
}
// Append digit
newValue += digit;
updateValue(newValue);
inputRef.current?.focus();
};
const handleBackspace = () => {
if (value.length > 0) {
const newValue = value.slice(0, -1);
updateValue(newValue);
}
inputRef.current?.focus();
};
const handleClear = () => {
updateValue("");
inputRef.current?.focus();
};
// Focus the input when clicking on the container
const handleContainerClick = () => {
inputRef.current?.focus();
};
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
" overflow-hidden",
error ? "border-red-500" : "border-input"
)}
onClick={handleContainerClick}
>
<Input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleInputChange}
placeholder={placeholder}
className={cn(
"pl-7 border-0 focus-visible:ring-0 focus-visible:ring-offset-0",
error && "border-red-500 focus-visible:ring-red-500"
)}
disabled={disabled}
min={min}
max={max}
/>
{showNumpad && (
<div className="grid grid-cols-3 gap-1 mt-2">
{Array.from({ length: 9 }).map((_, idx) => {
const num = idx + 1;
return (
<Button
key={num}
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(num.toString())}
disabled={disabled}
>
{num}
</Button>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(".")}
disabled={disabled || value.includes(".")}
>
.
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick("0")}
disabled={disabled}
>
0
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg"
onClick={handleBackspace}
disabled={disabled || value.length === 0}
>
<Delete className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -2,25 +2,46 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React from "react"; import React from "react";
import { Icons } from "./icons";
import * as LucideIcons from "lucide-react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function NavLink({ href, name, icon }: NavLink) { function NavLink({ href, name, icon }: NavLink) {
const active = usePathname() === href; const active = usePathname() === href;
const IconComponent = icon const IconComponent = icon ? Icons[icon as keyof typeof Icons] : null;
? (LucideIcons[icon as keyof typeof LucideIcons] as LucideIcons.LucideIcon)
: null;
return ( return (
<Button <div className="relative">
asChild <Button
size={"icon"} asChild
variant={"ghost"} variant={"ghost"}
className={cn(active && "text-primary bg-accent/25")} className={cn(
> "p-2 size-12 text-muted-foreground/75 flex flex-col gap-2 ",
<Link href={href}>{IconComponent && <IconComponent />}</Link> active && "text-foreground "
</Button> )}
>
<Link href={href}>
{IconComponent && (
<IconComponent
className={cn(
"size-6 transition-transform duration-300",
active && "scale-150"
)}
/>
)}
<span
className={cn(
"text-xs -translate-y-1 transition-transform duration-300",
active && "translate-y-0"
)}
>
{name}
</span>
</Link>
</Button>
{active && (
<div className="h-16 w-16 left-1/2 -z-10 -translate-x-1/2 transform blur-3xl absolute -bottom-8 bg-primary" />
)}
</div>
); );
} }

View File

@ -1,19 +1,17 @@
import React from "react"; import React from "react";
import { ModeToggle } from "./mode-toggle";
import { appConfig } from "@/app.config"; import { appConfig } from "@/app.config";
import NavLink from "./nav-link"; import NavLink from "./nav-link";
export default function Navbar() { export default function Navbar() {
return ( return (
<nav className="flex items-center justify-between p-4"> <nav className="flex items-center justify-between p-4">
<menu className="flex items-center gap-1 justify-around w-full"> <menu className="flex items-center gap-1 justify-around w-full ">
{appConfig.navigator.map((navItem, idx) => ( {appConfig.navigator.map((navItem, idx) => (
<li key={idx}> <li key={idx}>
<NavLink {...navItem} /> <NavLink {...navItem} />
</li> </li>
))} ))}
</menu> </menu>
{/* <ModeToggle /> */}
</nav> </nav>
); );
} }

View File

@ -1,190 +1,80 @@
"use client"; "use client";
import type React from "react";
import { useState, useRef } from "react";
import { Delete } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface MoneyInputProps { interface NumberInputProps
placeholder?: string; extends Omit<React.ComponentProps<"input">, "type" | "value" | "onChange"> {
value: number;
defaultValue?: string | number; onChange: (value: number) => void;
onChange?: (value: number | null) => void;
className?: string;
disabled?: boolean;
error?: string;
min?: number; min?: number;
max?: number; max?: number;
showNumpad?: boolean; step?: number;
decimalPlaces?: number;
} }
export function MoneyInput({ export function NumberInput({
placeholder = "0.00", value,
defaultValue = "",
onChange, onChange,
className,
disabled = false,
error,
min, min,
max, max,
showNumpad = true, step,
}: MoneyInputProps) { decimalPlaces = 2,
const [value, setValue] = useState(() => { ...props
if (typeof defaultValue === "number") { }: NumberInputProps) {
return defaultValue.toString(); const [stringValue, setStringValue] = useState(value.toString());
}
return defaultValue;
});
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const updateValue = (newValue: string) => { // Sync internal string value when external value changes
setValue(newValue); useEffect(() => {
const numericValue = newValue === "" ? null : Number.parseFloat(newValue); setStringValue(value.toString());
onChange?.(numericValue); }, [value]);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Allow only numbers and decimal point const rawValue = e.target.value?.startsWith(".")
const inputValue = e.target.value.replace(/[^0-9.]/g, ""); ? "0." + e.target.value
: e.target.value;
// Ensure only one decimal point // Allow empty string (to allow clearing the input)
const parts = inputValue.split(".")!; if (!rawValue.length) {
let formattedValue = inputValue; setStringValue("");
onChange(0); // or you could use NaN or keep previous value
if (parts.length > 1) {
// Limit to 2 decimal places
formattedValue = `${parts[0]}.${parts[1]?.substring(0, 2)}`;
}
updateValue(formattedValue);
};
const handleNumpadClick = (digit: string) => {
let newValue = value;
// Handle decimal point - only add if there isn't one already
if (digit === "." && value.includes(".")) {
return; return;
} }
const decimalRegex = new RegExp(`^-?\\d*(\\.\\d{0,${decimalPlaces}})?$`);
// Check if we're adding a digit after the decimal point if (decimalRegex.test(rawValue)) {
const parts = value.split("."); setStringValue(rawValue);
if (parts.length > 1 && parts[1]?.length! >= 2 && digit !== ".") {
// Already have 2 decimal places, don't add more digits // Only call onChange if the input is a complete valid number
return; if (/^-?\d+\.?\d*$/.test(rawValue)) {
const numValue = parseFloat(rawValue);
if (!isNaN(numValue)) {
// Round to specified decimal places
const roundedValue = parseFloat(numValue.toFixed(decimalPlaces));
onChange(roundedValue);
}
}
} }
// Append digit
newValue += digit;
updateValue(newValue);
inputRef.current?.focus();
}; };
const handleBackspace = () => { const handleBlur = () => {
if (value.length > 0) { // When input loses focus, ensure the displayed value matches the stored number
const newValue = value.slice(0, -1); setStringValue(value.toString());
updateValue(newValue);
}
inputRef.current?.focus();
}; };
const decimalPattern =
const handleClear = () => { decimalPlaces > 0 ? `^-?\\d+(\\.\\d{0,${decimalPlaces}})?$` : `^-?\\d+$`;
updateValue("");
inputRef.current?.focus();
};
// Focus the input when clicking on the container
const handleContainerClick = () => {
inputRef.current?.focus();
};
return ( return (
<div className={cn("space-y-2", className)}> <Input
<div {...props}
className={cn( type="text" // 'tel' shows numeric keypad on mobile but allows more flexible input than 'number'
" overflow-hidden", inputMode="numeric" // Ensures numeric keypad on mobile
error ? "border-red-500" : "border-input" pattern={decimalPattern} // Helps with mobile numeric input
)} value={stringValue}
onClick={handleContainerClick} onChange={handleChange}
> onBlur={handleBlur}
<Input min={min}
ref={inputRef} max={max}
type="text" step={step}
inputMode="decimal" />
value={value}
onChange={handleInputChange}
placeholder={placeholder}
className={cn(
"pl-7 border-0 focus-visible:ring-0 focus-visible:ring-offset-0",
error && "border-red-500 focus-visible:ring-red-500"
)}
disabled={disabled}
min={min}
max={max}
/>
{showNumpad && (
<div className="grid grid-cols-3 gap-1 mt-2">
{Array.from({ length: 9 }).map((_, idx) => {
const num = idx + 1;
return (
<Button
key={num}
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(num.toString())}
disabled={disabled}
>
{num}
</Button>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(".")}
disabled={disabled || value.includes(".")}
>
.
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick("0")}
disabled={disabled}
>
0
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg"
onClick={handleBackspace}
disabled={disabled || value.length === 0}
>
<Delete className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
); );
} }

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Avatar({ function Avatar({
className, className,
@ -18,7 +18,7 @@ function Avatar({
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ function AvatarImage({
@ -31,7 +31,7 @@ function AvatarImage({
className={cn("aspect-square size-full", className)} className={cn("aspect-square size-full", className)}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({
@ -47,7 +47,7 @@ function AvatarFallback({
)} )}
{...props} {...props}
/> />
) );
} }
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
@ -20,6 +20,7 @@ const buttonVariants = cva(
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
none: "bg-transparent text-foreground hover:bg-transparent focus-visible:border-0 focus-visible:ring-0 size-max p-0",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -12,7 +12,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -58,7 +58,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@ -1,17 +1,17 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
function Command({ function Command({
className, className,
@ -26,7 +26,7 @@ function Command({
)} )}
{...props} {...props}
/> />
) );
} }
function CommandDialog({ function CommandDialog({
@ -35,8 +35,8 @@ function CommandDialog({
children, children,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: React.ComponentProps<typeof Dialog> & {
title?: string title?: string;
description?: string description?: string;
}) { }) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
@ -50,7 +50,7 @@ function CommandDialog({
</Command> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
function CommandInput({ function CommandInput({
@ -72,7 +72,7 @@ function CommandInput({
{...props} {...props}
/> />
</div> </div>
) );
} }
function CommandList({ function CommandList({
@ -88,7 +88,7 @@ function CommandList({
)} )}
{...props} {...props}
/> />
) );
} }
function CommandEmpty({ function CommandEmpty({
@ -100,7 +100,7 @@ function CommandEmpty({
className="py-6 text-center text-sm" className="py-6 text-center text-sm"
{...props} {...props}
/> />
) );
} }
function CommandGroup({ function CommandGroup({
@ -116,7 +116,7 @@ function CommandGroup({
)} )}
{...props} {...props}
/> />
) );
} }
function CommandSeparator({ function CommandSeparator({
@ -129,7 +129,7 @@ function CommandSeparator({
className={cn("bg-border -mx-1 h-px", className)} className={cn("bg-border -mx-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function CommandItem({ function CommandItem({
@ -145,7 +145,7 @@ function CommandItem({
)} )}
{...props} {...props}
/> />
) );
} }
function CommandShortcut({ function CommandShortcut({
@ -161,7 +161,7 @@ function CommandShortcut({
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -174,4 +174,4 @@ export {
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator,
} };

View File

@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@ -43,14 +43,17 @@ function DialogOverlay({
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
className, className,
children, children,
hideClose,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
hideClose?: boolean;
}) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
@ -63,13 +66,15 @@ function DialogContent({
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> {!hideClose && (
<XIcon /> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<span className="sr-only">Close</span> <XIcon />
</DialogPrimitive.Close> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -79,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -92,7 +97,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@ -105,7 +110,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@ -118,7 +123,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -132,4 +137,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -48,7 +48,7 @@ function DropdownMenuContent({
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@ -56,7 +56,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@ -65,8 +65,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@ -79,7 +79,7 @@ function DropdownMenuItem({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -148,7 +148,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@ -160,7 +160,7 @@ function DropdownMenuLabel({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@ -173,7 +173,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
@ -189,13 +189,13 @@ function DropdownMenuShortcut({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -219,7 +219,7 @@ function DropdownMenuSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -235,7 +235,7 @@ function DropdownMenuSubContent({
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -254,4 +254,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@ -1,8 +1,8 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
FormProvider, FormProvider,
@ -11,27 +11,27 @@ import {
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from "react-hook-form" } from "react-hook-form";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue
) );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
@ -39,21 +39,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext() const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name }) const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormField>");
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@ -62,19 +62,19 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue
) );
function FormItem({ className, ...props }: React.ComponentProps<"div">) { function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
@ -84,14 +84,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
} }
function FormLabel({ function FormLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
@ -101,11 +101,12 @@ function FormLabel({
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return ( return (
<Slot <Slot
@ -119,11 +120,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
} }
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
@ -132,15 +133,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? "") : props.children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
@ -152,7 +153,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
> >
{body} {body}
</p> </p>
) );
} }
export { export {
@ -164,4 +165,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };

View File

@ -9,7 +9,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Label({ function Label({
className, className,
@ -18,7 +18,7 @@ function Label({
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@ -36,13 +36,13 @@ function PopoverContent({
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,185 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground dark:bg-background [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
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)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Tabs({ function Tabs({
className, className,
@ -15,7 +15,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function TabsList({ function TabsList({
@ -31,7 +31,7 @@ function TabsList({
)} )}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({
@ -47,7 +47,7 @@ function TabsTrigger({
)} )}
{...props} {...props}
/> />
) );
} }
function TabsContent({ function TabsContent({
@ -60,7 +60,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
) );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,81 @@
"use client";
import React from "react";
import { api } from "@/trpc/react";
import type { Session } from "next-auth";
import type { PublicUser } from "next-auth";
import { calculateSplits, type Payment } from "@/lib/utils/expense";
export enum SplitTypeEnum {
Fixed = "fixed",
Percentage = "percentage",
Equal = "equal",
}
export type SplitType = keyof typeof SplitTypeEnum;
export const splitTypeKeys = Object.keys(SplitTypeEnum);
export const useExpenseSplit = (amount: number, session: Session) => {
const [friends] = api.friend.getAll.useSuspenseQuery();
const [friendTarget, setFriendTarget] = React.useState<string>();
const [participants, setParticipants] = React.useState<Array<PublicUser>>([
session.user,
]);
const [activeSplitType, setActiveSplitType] =
React.useState<SplitType>("Equal");
const [payments, setPayments] = React.useState<Array<Payment>>([
{ amount: amount, userId: session.user.id },
]);
const [splits, setSplits] = React.useState<
Record<string /* user.id */, string /* amount */>
>({});
React.useEffect(() => {
// const generateSplitsAmount = () => {
// var newSplits: Record<string, SplitType> = {};
// participants.forEach((user) => {
// newSplits[user.id!] = splits[user.id!] ?? {};
// });
// console.log(newSplits);
// return newSplits;
// };
setSplits(
calculateSplits(
{
amount,
users: participants.map((user) => user.id!),
splitType: activeSplitType,
}
// generateSplitsAmount()
)
);
}, [participants, activeSplitType, amount]);
const addParticipant = (newUser: PublicUser) => {
setParticipants((prev) =>
prev.find((user) => user.id === newUser.id) ? prev : [...prev, newUser]
);
};
const removeParticipant = (userId: string) => {
setParticipants((prev) => prev.filter((user) => user.id !== userId));
};
return {
friends,
amount,
participants,
addParticipant,
removeParticipant,
friendTarget,
setFriendTarget,
splitType: activeSplitType,
setSplitType: setActiveSplitType,
payments,
setPayments,
//
splits,
};
};
export type ExpenseSplitHook = ReturnType<typeof useExpenseSplit>;

View File

@ -3,4 +3,5 @@ type NavLink = {
name: string; name: string;
href: string; href: string;
icon?: string; icon?: string;
big?: boolean;
}; };

117
src/lib/utils/expense.ts Normal file
View File

@ -0,0 +1,117 @@
import type { SplitType } from "@/hooks/use-expense-split";
export type Debt = {
from: string;
to: string;
amount: number;
};
export type Payment = {
userId: string;
amount: number;
};
export function calculateSplits(args: {
amount: number;
users: Array<string>;
splitType: SplitType;
payments?: Array<Payment>;
}): Record<string, string> {
const result: Record<string, string> = {};
const { splitType = "Equal", amount, users, payments } = args;
// If splits are provided, validate them
// const amount = Object.values(payments).reduce((sum, value) => sum + value, 0);
switch (splitType) {
case "Equal":
var splits: Record<string, string> = {};
users.forEach((userId) => {
splits[userId] = `${amount / users.length}`;
});
return splits;
// case "Percentage":
// if (!customDepts) {
// throw new Error("Splits must be provided for percentage type.");
// }
// participants.forEach((userId) => {
// if (customDepts[userId] !== undefined) {
// const percentage = customDepts[userId] / 100;
// const amountSplit = amount * percentage;
// result[userId] = `${amountSplit.toFixed(2)}%`; // Add "%" for percentage
// } else {
// throw new Error(`No percentage split defined for user ID: ${userId}`);
// }
// });
// break;
// case "Fixed":
// if (!customDepts) {
// throw new Error("Splits must be provided for fixed type.");
// }
// participants.forEach((userId) => {
// if (customDepts[userId] !== undefined) {
// result[userId] = `${customDepts[userId].toFixed(2)}`; // Format to 2 decimal places
// } else {
// throw new Error(`No fixed amount defined for user ID: ${userId}`);
// }
// });
// break;
default:
return {};
// throw new Error("Invalid split type.");
}
return result;
}
export function calculateDepts(
total: number,
users: string[],
payments: Array<Payment>
): Debt[] {
console.log("Payments: ", payments);
const share = total / users.length;
const balances: Record<string, number> = {};
// Initialize balances
users.forEach((userId) => {
const paid = payments.find((p) => p.userId === userId)?.amount || 0;
balances[userId] = paid - share;
});
// Separate creditors and debtors
const creditors = Object.entries(balances)
.filter(([_, balance]) => balance > 0)
.map(([userId, balance]) => ({ userId, balance }));
const debtors = Object.entries(balances)
.filter(([_, balance]) => balance < 0)
.map(([userId, balance]) => ({ userId, balance: -balance }));
const debts: Debt[] = [];
for (const debtor of debtors) {
let amountToPay = debtor.balance;
for (const creditor of creditors) {
if (amountToPay === 0) break;
if (creditor.balance === 0) continue;
const payment = Math.min(amountToPay, creditor.balance);
debts.push({
from: debtor.userId,
to: creditor.userId,
amount: parseFloat(payment.toFixed(2)), // for rounding safety
});
amountToPay -= payment;
creditor.balance -= payment;
}
}
return debts;
}

View File

@ -3,13 +3,10 @@ import { z } from "zod";
export const expenseSchema = z.object({ export const expenseSchema = z.object({
amount: z.number().min(0.01), amount: z.number().min(0.01),
description: z.string().optional(), description: z.string().optional(),
friendId: z.string().optional(),
groupId: z.string().optional(),
}); });
export const expenseSplitSchema = z.object({ export const expenseSplitSchema = z.object({
expenseId: z.string(), expenseId: z.string(),
groupId: z.string().optional(),
paidById: z.string().min(1), paidById: z.string().min(1),
owedById: z.string().min(1), owedById: z.string().min(1),
amount: z.number().min(1), amount: z.number().min(1),

View File

@ -2,11 +2,13 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { expenses } from "@/server/db/schema"; import { expenses } from "@/server/db/schema";
import { expenseSchema } from "@/lib/validations/expense"; import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
export const expenseRouter = createTRPCRouter({ export const expenseRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(z.object({ expense: expenseSchema })) .input(
z.object({ expense: expenseSchema, split: z.array(expenseSplitSchema) })
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [expense] = await ctx.db.insert(expenses).values({ const [expense] = await ctx.db.insert(expenses).values({
createdById: ctx.session.user.id, createdById: ctx.session.user.id,

View File

@ -18,14 +18,12 @@ export const friendRouter = createTRPCRouter({
userTwo: true, userTwo: true,
}, },
}); });
const returnOne = friends.map((f) => ({ return friends.map((f) => ({
id: f.id, id: f.id,
status: f.status, status: f.status,
requestedBy: ctx.session.user.id === f.userOneId ? "me" : "them", requestedBy: ctx.session.user.id === f.userOneId ? "me" : "them",
user: ctx.session.user.id === f.userOneId ? f.userTwo : f.userOne, user: ctx.session.user.id === f.userOneId ? f.userTwo : f.userOne,
})); }));
console.log(returnOne[0]);
return returnOne;
}), }),
getPendingFriendRequests: protectedProcedure.query( getPendingFriendRequests: protectedProcedure.query(
async ({ ctx }) => async ({ ctx }) =>

View File

@ -56,6 +56,7 @@ export const authConfig = {
sessionsTable: sessions, sessionsTable: sessions,
verificationTokensTable: verificationTokens, verificationTokensTable: verificationTokens,
}), }),
callbacks: { callbacks: {
session: ({ session, user }) => ({ session: ({ session, user }) => ({
...session, ...session,

View File

@ -9,72 +9,72 @@
} }
:root { :root {
--radius: 0.625rem; --radius: 0.5rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.205 0 0); --primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0); --muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.97 0 0); --accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.92 0.004 286.32);
--input: oklch(0.922 0 0); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.708 0 0); --ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.705 0.213 47.604);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.269 0 0); --accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.646 0.222 41.116);
} }
@theme inline { @theme inline {
@ -126,6 +126,11 @@
.max-w { .max-w {
@apply max-w-md; @apply max-w-md;
} }
.text-stroke {
color: transparent !important; /* Make the fill transparent */
text-shadow: 1px var(--color-foreground); /* Stroke width and color */
}
} }
.popover-content-width-same-as-its-trigger { .popover-content-width-same-as-its-trigger {