Compare commits

...
13 Commits
Author SHA1 Message Date
pablo 4082107052 removed first 2 steos 2025-05-13 14:45:08 +02:00
pablo d914fe5df8 changed using runner 2025-05-13 14:43:40 +02:00
pablo 48ddb2d4a5 test to trigger 2025-05-13 14:40:27 +02:00
pablo a0b4b8d48c added gitea docker registry workflow 2025-05-13 14:34:32 +02:00
pablo 4a862ec06a test add page error fix 2025-05-03 19:32:58 +02:00
pablo a945005e76 production test 2025-05-03 15:06:07 +02:00
pablo 76f1685c71 updateEE 2025-04-18 11:39:03 +02:00
pablo 62f1689c1a main page and group car 2025-04-14 23:32:15 +02:00
pablo 3f4cc126ec started add Group to expense ui; ui improvments 2025-04-13 23:17:43 +02:00
pablo 3fc08a3c10 added groups 2025-04-13 22:19:54 +02:00
pablo a2492fefbd migrated from auth.js to clerk; some ui remake; added expense-card 2025-04-13 19:52:06 +02:00
pablo cd8a8d746d Merge pull request 'added pwa support' (#1) from feature/Capacitor into dev
Reviewed-on: #1
2025-04-08 12:14:23 +00:00
pablo f1ee071e62 added pwa support 2025-04-08 14:13:27 +02:00
82 changed files with 7914 additions and 970 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Publish Docker Image
on:
push:
branches:
- prod
jobs:
docker:
runs-on: linux # make sure your Gitea runner supports Docker
steps:
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.mrmoku.de -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Get Git commit SHA
id: commit_sha
run: echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build -t gitea.mrmoku.de/${{ secrets.REGISTRY_USER }}/bttersplit:${{ env.COMMIT_SHA }} .
- name: Push Docker image
run: |
docker push gitea.mrmoku.de/${{ secrets.REGISTRY_USER }}/bettersplit:${{ env.COMMIT_SHA }}
+16
View File
@@ -0,0 +1,16 @@
FROM node:18-alpine
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN pnpm postbuild
CMD ["pnpm", "start"]
+6
View File
@@ -27,3 +27,9 @@ You can check out the [create-t3-app GitHub repository](https://github.com/t3-os
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
to test webhooks locally
```bash
ngrok http --url=nkrok-url 3000
```
+37
View File
@@ -0,0 +1,37 @@
services:
nextapp:
build: .
container_name: bettersplit
restart: always
ports:
- "3004:3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.bettersplit.rule=Host(`bettersplit.shortman.me`)"
- "traefik.http.routers.bettersplit.entrypoints=websecure"
- "traefik.http.routers.bettersplit.tls.certresolver=myresolver"
networks:
- bettersplit_network
- webproxy
db:
image: postgres:latest
container_name: bettersplit-db
restart: always
shm_size: 128mb
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- bettersplit_network
volumes:
pgdata:
driver: local
networks:
bettersplit_network:
driver: bridge
webproxy:
external: true
-10
View File
@@ -1,10 +0,0 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};
export default config;
+24
View File
@@ -0,0 +1,24 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
import pwa from "next-pwa";
import { env } from "./src/env.js";
import { type NextConfig } from "next";
const withPWA = pwa({
dest: "public",
disable: env.NODE_ENV === "development",
register: true, // register the PWA service worker
skipWaiting: true,
});
const nextConfig = {
eslint: { ignoreDuringBuilds: true },
experimental: {
turbo: {},
},
} satisfies NextConfig;
export default withPWA(nextConfig);
+87 -73
View File
@@ -1,75 +1,89 @@
{
"name": "betterwise",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "biome check .",
"check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --turbo",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@hookform/resolvers": "^5.0.1",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@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-tabs": "^1.1.3",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.487.0",
"next": "^15.2.3",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.6",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"server-only": "^0.0.1",
"sonner": "^2.0.3",
"superjson": "^2.2.1",
"tailwind-merge": "^3.1.0",
"tw-animate-css": "^1.2.5",
"use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"drizzle-kit": "^0.30.5",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2"
},
"ct3aMetadata": {
"initVersion": "7.39.2"
},
"packageManager": "pnpm@10.3.0"
"name": "betterwise",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"postbuild": "pnpm db:push",
"export": "next export",
"check": "biome check .",
"check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx ./src/server/db/seed.ts",
"dev": "next dev --turbo",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit",
"android": "pnpm build && pnpm export && pnpm dlx cap sync android && pnpm dlx cap open android"
},
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@clerk/nextjs": "^6.14.3",
"@clerk/themes": "^2.2.31",
"@hookform/resolvers": "^5.0.1",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@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-tabs": "^1.1.3",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/next-pwa": "^5.6.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.487.0",
"next": "^15.2.3",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"recharts": "^2.15.2",
"server-only": "^0.0.1",
"sonner": "^2.0.3",
"superjson": "^2.2.1",
"svix": "^1.64.0",
"tailwind-merge": "^3.1.0",
"tsx": "^4.19.3",
"tw-animate-css": "^1.2.5",
"use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"drizzle-kit": "^0.30.5",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2"
},
"ct3aMetadata": {
"initVersion": "7.39.2"
},
"packageManager": "pnpm@10.3.0"
}
+5060 -80
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,10 +1,12 @@
type AppConfig = {
name: string;
shortName: string;
navigator: Array<NavLink>;
};
export const appConfig: AppConfig = {
name: "Betterwise",
name: "Bettesplit",
shortName: "BS",
navigator: [
{
name: "Home",
+5 -18
View File
@@ -1,22 +1,9 @@
import ExpenseForm from "@/app/_components/expense/expense-form";
import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { auth } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server";
import ExpensePage from "@/app/_components/expense/create-expense-page";
import { api } from "@/trpc/server";
import React from "react";
export default async function Page() {
const session = await auth();
if (session?.user) void api.friend.getAll.prefetch();
return (
<HydrateClient>
<Header text="Add Expense">
<Button type="submit" form="expense-form">
Create Expense
</Button>
</Header>
<ExpenseForm hideSubmit session={session!} />
</HydrateClient>
);
const sessionUser = await api.user.getSessionUser();
return <ExpensePage sessionUser={sessionUser!} />;
}
+20
View File
@@ -0,0 +1,20 @@
import { AnalyticsRadarChart } from "@/components/charts/radar-chart";
import Header from "@/components/header";
import Section from "@/components/section";
import { api } from "@/trpc/server";
import React from "react";
async function AnalyticsPage() {
const analyticsData = await api.expense.getAnalytics();
return (
<>
<Header text="Analytics"></Header>
<Section>
<AnalyticsRadarChart chartData={analyticsData!} />
</Section>
</>
);
}
export default AnalyticsPage;
+26 -7
View File
@@ -1,26 +1,45 @@
import ExpenseCard from "@/app/_components/expense/expense-card";
import Header from "@/components/header";
import { Icons } from "@/components/icons";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
import Link from "next/link";
import React from "react";
export default async function Page() {
const splits = await api.expense.getAll();
const sessionUser = await currentUser();
return (
<>
<Header text="Expenses">
<Button asChild>
<Button asChild size={"sm"}>
<Link href="/add">Add Expense</Link>
</Button>
</Header>
<Section>
{splits.map(({ expense }) => (
<div key={expense.id}>
<h2>{expense.description}</h2>
<p>{expense.amount}</p>
</div>
))}
{/* FILTER BAR */}
<div className="py-4 flex items-center gap-2">
<Button
size={"icon"}
variant={"outline"}
className="text-muted-foreground bg-card/25"
>
<Icons.filter className="size-6 " />
</Button>
<Input placeholder="Search..." />
</div>
<ul className="space-y-4">
{splits.map((expense) => (
<li key={expense.id}>
<ExpenseCard expense={expense} sessionUserId={sessionUser?.id!} />
</li>
))}
</ul>
</Section>
</>
);
+3 -1
View File
@@ -2,10 +2,12 @@ import AddFriendDrawer from "@/app/_components/friend/add-friend-drawer";
import FriendList from "@/app/_components/friend/friend-list";
import Header from "@/components/header";
import { api, HydrateClient } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
import React from "react";
export default async function Page() {
void api.friend.getAll.prefetch();
const user = await currentUser();
if (user) void api.friend.getAll.prefetch();
return (
<HydrateClient>
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import GroupPage from "@/app/_components/group/group-page";
import { api, HydrateClient } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
async function Page({
params,
}: {
params: Promise<{
id: string;
}>;
}) {
const { id } = await params;
void api.group.get.prefetch({ id });
const sessionUser = await currentUser();
return (
<HydrateClient>
<GroupPage sessionUserId={sessionUser?.id!} groupId={id} />
</HydrateClient>
);
}
export default Page;
+22 -4
View File
@@ -1,13 +1,31 @@
import CreateGroupDrawer from "@/app/_components/group/create-group-drawer";
import Header from "@/components/header";
import React from "react";
import GroupFormDrawer from "@/app/_components/group/group-form-drawer";
import Header from "@/components/header";
import Section from "@/components/section";
import { api } from "@/trpc/server";
import Link from "next/link";
import GroupCard from "@/app/_components/group/group-card";
export default function Page() {
export default async function Page() {
const groups = await api.group.getAll({
withMembers: true,
});
return (
<>
<Header text="Groups">
<CreateGroupDrawer />
<GroupFormDrawer />
</Header>
<Section>
<menu className="space-y-4">
{groups.map((group) => (
<li key={group.id}>
<Link href={`/group/${group.id}`}>
<GroupCard group={group} />
</Link>
</li>
))}
</menu>
</Section>
</>
);
}
+1 -5
View File
@@ -1,6 +1,4 @@
import Navbar from "@/components/navbar";
import { auth } from "@/server/auth";
import { redirect } from "next/navigation";
import React from "react";
export default async function Layout({
@@ -8,11 +6,9 @@ export default async function Layout({
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) return redirect("/api/auth/signin");
return (
<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 rounded-b-[3rem] bg-background h-full border border-card z-20 relative overflow-y-auto pb-8">
<main className="space-y-4 rounded-b-[3rem] bg-background/75 h-full border border-card z-20 relative overflow-y-auto pb-8">
{children}
</main>
<Navbar />
+59 -8
View File
@@ -1,20 +1,71 @@
import { appConfig } from "@/app.config";
import Header from "@/components/header";
import Section from "@/components/section";
import { auth } from "@/server/auth";
import UserDropdown from "../_components/user-dropdown";
import { ModeToggle } from "@/components/mode-toggle";
import { UserButton } from "@clerk/nextjs";
import { Icons } from "@/components/icons";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { getAmount } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
export default async function Home() {
const session = await auth();
return (
<>
<Header text={appConfig.name}>
<UserDropdown user={session?.user!} />
<Header
text={
<>
{/* <Icons.logo
className="size-32 scale-125 mr-2 absolute -left-4 -top-1/2 text-muted-foreground/5 -z-10
mask-r-from-30%
"
/> */}
<span>{appConfig.name}</span>
<ModeToggle className="ml-4" />
</>
}
>
<div className="flex gap-6 items-center ">
<UserButton />
</div>
</Header>
<Section>
<ModeToggle />
<Section className="">
<Card className="bg-card/5 relative">
<Badge
asChild
variant={"outline"}
className="absolute bg-card -top-2 right-2"
>
<Link href={"/analytics"}>Analytics</Link>
</Badge>
<CardHeader className="flex items-center">
<CardTitle className="size-full flex flex-col gap-1">
<p>
Your Balance{" "}
<span className="font-normal text-sm text-muted-foreground">
(You Owe)
</span>
</p>
<span className="text-xl text-destructive">
{getAmount(812.47)}
</span>
</CardTitle>
<Link
className="cursor-pointer h-max flex items-center bg-primary text-primary-foreground rounded-lg pl-4 w-24 justify-between"
href={"/add"}
>
<span>Add</span>
<Icons.addSquare className="size-8" />
</Link>
</CardHeader>
</Card>
<div className="size-full border rounded-xl"></div>
<div className="size-full border rounded-xl"></div>
<div className="size-full border rounded-xl"></div>
<div className="size-full border rounded-xl max-h-52"></div>
</Section>
</>
);
+7
View File
@@ -0,0 +1,7 @@
import React from "react";
function Page() {
return <div>SignIn page</div>;
}
export default Page;
@@ -0,0 +1,96 @@
import React from "react";
import { Combobox } from "@/components/combobox";
import { api } from "@/trpc/react";
import Avatar from "@/components/avatar";
import type { User } from "@/server/db/schema";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { useDebounce } from "use-debounce";
import { Icons } from "@/components/icons";
import { useExpenseStore } from "@/lib/store/expense-store";
export default function AddParticipants({
excludedIds,
}: {
initialValue?: string;
className?: string;
excludedIds: Array<string>;
}) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [search] = useDebounce(value, 1000);
const { data } = api.expense.searchParticipants.useQuery({
search,
excludedIds,
});
const addParticipants = useExpenseStore((state) => state.addParticipants);
const addGroupBadges = useExpenseStore((state) => state.addGroupBadges);
const select = (users: Array<User>) => {
addParticipants(users);
setOpen(false);
};
return (
<>
<div
className="px-4 py-2 border rounded-xl text-center"
onClick={() => setOpen(true)}
>
Add Friends or Groups
</div>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
onValueChange={setValue}
placeholder="Type a command or search..."
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{data?.groups?.length ? (
<CommandGroup heading="Groups">
{data?.groups.map(({ group }) => (
<CommandItem
key={group.id}
value={`${group.name}:::${group.id}`}
onSelect={() => {
if (group.members) select(group.members.map((m) => m.user));
addGroupBadges([group]);
}}
>
<Icons.group className="size-8" />
<span>{group.name}</span>
</CommandItem>
))}
</CommandGroup>
) : null}
{data?.friends?.length ? (
<CommandGroup heading="Friends">
{data?.friends.map(({ user, id }) => (
<CommandItem
key={id}
value={`${user.name}:::${user.id}`}
onSelect={() => {
select([user]);
}}
>
<Avatar className="size-8" src={user.image} fb={user.name} />
<span>{user.name}</span>
</CommandItem>
))}
</CommandGroup>
) : null}
</CommandList>
</CommandDialog>
</>
);
}
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import ExpenseForm from "./expense-form";
import Header from "@/components/header";
import { Button } from "@/components/ui/button";
import type { User } from "@/server/db/schema";
import { useExpenseStore } from "@/lib/store/expense-store";
import ExpenseSplit from "./expense-split";
import ExpenseParticipants from "./expense-participants";
import { Separator } from "@/components/ui/separator";
function ExpensePage({ sessionUser }: { sessionUser?: User }) {
const addParticipants = useExpenseStore((state) => state.addParticipants);
const setPayments = useExpenseStore((state) => state.setPayments);
React.useEffect(() => {
if (sessionUser) {
addParticipants([sessionUser]);
setPayments([{ amount: 0, userId: sessionUser.id }]);
}
}, []);
if (!sessionUser) return <>sessino user missing</>;
return (
<>
<Header text="Add Expense">
<Button type="submit" form="expense-form" size={"sm"}>
Create Expense
</Button>
</Header>
<ExpenseForm hideSubmit sessionUser={sessionUser} />
<ExpenseParticipants sessionUserId={sessionUser.id} />
<Separator />
<ExpenseSplit sessionUser={sessionUser} />
</>
);
}
export default ExpensePage;
@@ -0,0 +1,157 @@
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { cn, getAmount } from "@/lib/utils";
import type {
Expense,
ExpenseGroupBadge,
ExpenseSplit,
} from "@/server/db/schema";
import React from "react";
import { UserBadge } from "./expense-participants";
import { Icons } from "@/components/icons";
import ExpenseDetails from "./expense-details";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { calculateTotalValue } from "@/lib/utils/expense";
export const ExpenseSplitsList = ({
splits,
}: {
splits: Array<ExpenseSplit>;
}) => {
return (
<ul className="space-y-2">
{splits?.map((split, idx) => (
<li key={idx} className="text-sm flex items-center gap-1">
<UserBadge user={split.owedTo!} />
<span className="text-xs">owes</span>
<Icons.arrowRight className="size-4 " />
<UserBadge user={split.owedFrom!} />
<span className="font-semibold ml-auto ">
{getAmount(Number(split.amount))}
</span>
</li>
))}
</ul>
);
};
export const ExpenseGroupBadgesList = ({
groupBadges,
}: {
groupBadges: Array<ExpenseGroupBadge>;
}) => {
return (
<ul className="w-full flex items-center gap-2 flex-wrap">
{groupBadges.map(({ group }) => (
<li key={group?.id!}>
<Badge className="flex items-center gap-1" variant={"outline"}>
<Icons.group className="size-4" />
<span>{group!.name}</span>
</Badge>
</li>
))}
</ul>
);
};
function ExpenseCard({
expense,
sessionUserId,
}: {
expense: Expense;
sessionUserId: string;
}) {
const toatalBalance = calculateTotalValue(sessionUserId, expense.splits!);
const totalValue = toatalBalance.owed - toatalBalance.owes;
return (
<Drawer>
<DrawerTrigger className="w-full">
<Card className="bg-background shadow-none gap-2 ">
<CardHeader className="px-4">
<CardTitle className="flex items-center w-full">
<p className="w-full max-w-64 truncate text-start ">
{expense.description}
</p>
<span className="ml-auto">
{getAmount(Number(expense.amount))}
</span>
</CardTitle>
</CardHeader>
{expense.groupBadges?.length ? (
<div className="px-4 flex items-center w-full gap-4">
<span className="text-xs">Groups: </span>
<ExpenseGroupBadgesList groupBadges={expense.groupBadges} />
</div>
) : null}
<Separator className="my-2" />
<CardContent className="px-4 space-y-4 ">
<ExpenseSplitsList splits={expense?.splits?.slice(0, 2)!} />
</CardContent>
<div className="flex items-center gap-2">
{expense?.splits!.length > 2 && (
<>
<div className="bg-border w-20 h-px" />
<p className="text-xs text-muted-foreground whitespace-nowrap ">
{expense?.splits!.length - 2} more
</p>
</>
)}
<Separator className="my-2 shrink" />
</div>
<CardFooter className={cn("px-4 flex items-center")}>
<span className="text-sm">
You {totalValue < 0 ? "Owe" : "Get"}
</span>
<span
className={cn(
"ml-auto font-semibold",
totalValue < 0 ? "text-destructive" : "text-success"
)}
>
{getAmount(Number(totalValue))}
</span>
</CardFooter>
</Card>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<div className="flex items-center">
<DrawerTitle>{expense.description}</DrawerTitle>
<span className="ml-auto">{getAmount(Number(expense.amount))}</span>
</div>
</DrawerHeader>
<div className="p-4 space-y-4 ">
<ExpenseDetails expense={expense} />
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
export default ExpenseCard;
@@ -0,0 +1,14 @@
import type { Expense } from "@/server/db/schema";
import React from "react";
import { ExpenseGroupBadgesList, ExpenseSplitsList } from "./expense-card";
function ExpenseDetails({ expense }: { expense: Expense }) {
return (
<>
<ExpenseGroupBadgesList groupBadges={expense.groupBadges!} />
<ExpenseSplitsList splits={expense.splits!} />
</>
);
}
export default ExpenseDetails;
+17 -93
View File
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import type { Expense } from "@/server/db/schema";
import type { Expense, User } from "@/server/db/schema";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -15,28 +15,23 @@ import {
} from "@/components/ui/form";
import { expenseSchema } from "@/lib/validations/expense";
import { Textarea } from "@/components/ui/textarea";
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split";
import type { Session } from "next-auth";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { useExpenseStore } from "@/lib/store/expense-store";
import ExpenseParticipants from "./expense-participants";
import { Separator } from "@/components/ui/separator";
import { calculateDepts } from "@/lib/utils/expense";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
function ExpenseForm({
session,
initialExpense,
hideSubmit,
sessionUser,
}: {
session: Session;
initialExpense?: Expense;
hideSubmit?: boolean;
sessionUser: User;
}) {
const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema),
@@ -47,7 +42,7 @@ function ExpenseForm({
});
const participants = useExpenseStore((state) => state.participants);
const addParticipant = useExpenseStore((state) => state.addParticipant);
const addParticipants = useExpenseStore((state) => state.addParticipants);
const setAmount = useExpenseStore((state) => state.setAmount);
const splitType = useExpenseStore((state) => state.splitType);
const setPayments = useExpenseStore((state) => state.setPayments);
@@ -55,26 +50,28 @@ function ExpenseForm({
const recalculateSplits = useExpenseStore((state) => state.recalculateSplits);
const amount = form.watch("amount");
const resetExpenseStore = useExpenseStore((state) => state.resetExpenseStore);
const groupBadges = useExpenseStore((state) => state.groupBadges);
const router = useRouter();
const createExpense = api.expense.create.useMutation({
onSuccess(expense) {
onSuccess() {
form.reset();
resetExpenseStore();
addParticipant(session.user);
setPayments([{ amount: amount, userId: session.user.id }]);
addParticipants([sessionUser]);
setPayments([{ amount: amount, userId: sessionUser.id }]);
toast.message("Expense Created", {
position: "bottom-center",
action: {
children: "test",
onClick: () => {
router.push(`/expense/${expense.id}`);
router.push(`/expense`);
},
label: "View Expense",
},
});
},
});
function onSubmit(expense: z.infer<typeof expenseSchema>) {
if (createExpense.isPending) return;
if (participants.length <= 1)
@@ -91,17 +88,16 @@ function ExpenseForm({
payments
);
createExpense.mutate({ expense, debs });
createExpense.mutate({
expense,
debs,
groupBadges: groupBadges.map(({ id }) => id),
});
}
React.useEffect(() => {
addParticipant(session.user);
setPayments([{ amount, userId: session.user.id }]);
}, []);
const handleAmountChange = (value: number) => {
setAmount(value);
const firstUserId = payments[0]?.userId ?? session.user.id;
const firstUserId = payments[0]?.userId ?? sessionUser.id;
setPayments([{ amount: value, userId: firstUserId }]);
recalculateSplits();
};
@@ -119,9 +115,6 @@ function ExpenseForm({
</Button>
)}
<ExpenseParticipants session={session} />
<Separator />
<FormField
control={form.control}
name="description"
@@ -169,75 +162,6 @@ function ExpenseForm({
</FormItem>
)}
/>
<ExpenseSplit session={session} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0">
<TabsTrigger value="expense">Expense</TabsTrigger>
<TabsTrigger value="split">Split</TabsTrigger>
</TabsList>
<TabsContent value="expense" className="space-y-8 size-full">
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount</FormLabel>
{/* <MoneyInput
onChange={field.onChange}
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 />
</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 border-0 border-b rounded-none resize-none"
placeholder="About this expense"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ExpenseSplit amount={amount} session={session} />
</TabsContent>
<TabsContent value="split">
{/* <FormField
control={form.control}
name="friendId"
render={({ field }) => (
<FormItem>
<FormLabel>Select Friend</FormLabel>
<FormControl>
<FriendSelect
onSelect={field.onChange}
initialValue={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs> */}
</form>
</Form>
);
@@ -1,45 +1,73 @@
"use client";
import Avatar from "@/components/avatar";
import { Input } from "@/components/ui/input";
import { useExpenseStore } from "@/lib/store/expense-store";
import React from "react";
import FriendSelect from "../friend/friend-select";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import type { Session } from "next-auth";
import type { User } from "@/server/db/schema";
import AddParticipants from "./add-participants";
export default function ExpenseParticipants({ session }: { session: Session }) {
const [friends] = api.friend.getAll.useSuspenseQuery();
export const UserBadge = ({
user,
children,
}: {
user: User;
children?: React.ReactNode;
}) => {
return (
<div className="border rounded-full gap-1 flex items-center w-max ">
<Avatar src={user.image} fb={user.name} className="size-6" />
<span className="text-sm mr-1">{user.name}</span>
{children}
</div>
);
};
export default function ExpenseParticipants({
sessionUserId,
}: {
sessionUserId: string;
}) {
const participants = useExpenseStore((state) => state.participants);
const addParticipant = useExpenseStore((state) => state.addParticipant);
const removeParticipant = useExpenseStore((state) => state.removeParticipant);
const excludeIds = participants.map((user) => user.id!);
const groupsBadges = useExpenseStore((state) => state.groupBadges);
const removeGroupBadge = useExpenseStore((state) => state.removeGroupBadge);
const excludeIds = [
...participants.map((user) => user.id!),
...groupsBadges.map((g) => g.id),
];
return (
<div className="p-4 space-y-4">
<div className=" flex gap-2 w-full items-center flex-wrap">
<h5 className="w-18">You and</h5>
<FriendSelect
excludeIds={excludeIds}
onSelect={(userId) => {
addParticipant(
friends.find((friend) => friend.user.id === userId)!.user
);
}}
/>
{participants.map((user) => (
<ul
key={user.id}
className="border rounded-full gap-1 flex items-center w-max "
<ul className="flex gap-2 w-full items-center flex-wrap ">
{groupsBadges.map((group) => (
<UserBadge
key={group.id}
user={{
name: group.name,
image: "",
id: group.id,
}}
>
<Avatar src={user.image} fb={user.name} className="size-6" />
<span className="pr-4 text-sm">{user.name}</span>
{session.user.id !== user.id && (
<Button
className=" rounded-full aspect-square size-6 "
variant="destructive"
onClick={(e) => {
e.preventDefault();
removeGroupBadge([group.id!]);
}}
>
<XIcon className="size-4" />
</Button>
</UserBadge>
))}
</ul>
<ul className="flex gap-2 w-full items-center flex-wrap ">
{participants.map((user) => (
<UserBadge user={user} key={user.id}>
{sessionUserId !== user.id && (
<Button
className=" rounded-full aspect-square size-6"
className=" rounded-full aspect-square size-6 "
variant="destructive"
onClick={(e) => {
e.preventDefault();
@@ -49,9 +77,10 @@ export default function ExpenseParticipants({ session }: { session: Session }) {
<XIcon className="size-4" />
</Button>
)}
</ul>
</UserBadge>
))}
</div>
</ul>
<AddParticipants excludedIds={excludeIds} />
</div>
);
}
+5 -91
View File
@@ -1,106 +1,20 @@
"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 { Icons } from "@/components/icons";
import { useExpenseStore } from "@/lib/store/expense-store";
import PaidByInput from "./paid-by";
import { api } from "@/trpc/react";
import SplitTo from "./split-to";
export default function ExpenseSplit({ session }: { session: Session }) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const friendTarget = useExpenseStore((state) => state.friendTarget);
const setFriendTarget = useExpenseStore((state) => state.setFriendTarget);
const addParticipant = useExpenseStore((state) => state.addParticipant);
import SplitBetweenInput from "./split-between-input";
import type { User } from "@/server/db/schema";
export default function ExpenseSplit({ sessionUser }: { sessionUser: User }) {
return (
<div className="space-y-4 ">
<div className="flex items-center justify-center gap-2">
<span>Paid by</span>
<PaidByInput sessionUser={session.user} />
<PaidByInput sessionUser={sessionUser} />
</div>
<div className="flex items-center justify-center gap-2">
<span>and spliited</span>
<SplitTo />
<SplitBetweenInput />
</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>
<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>
);
}
@@ -10,10 +10,8 @@ import {
} 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";
+2 -6
View File
@@ -8,20 +8,16 @@ import {
} 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 { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split";
import { useExpenseStore } from "@/lib/store/expense-store";
import type { User } from "@/server/db/schema";
/* saved result is an array of objects with user and amount */
export default function PaidByInput({
sessionUser,
}: {
sessionUser: PublicUser;
}) {
export default function PaidByInput({ sessionUser }: { sessionUser: User }) {
const amount = useExpenseStore((state) => state.amount);
const payments = useExpenseStore((state) => state.payments);
const setPayments = useExpenseStore((state) => state.setPayments);
@@ -10,8 +10,11 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useExpenseStore } from "@/lib/store/expense-store";
function SplitTo() {
function SplitBetweenInput() {
const participants = useExpenseStore((state) => state.participants);
const splitType = useExpenseStore((state) => state.splitType);
return (
<Dialog>
<DialogTrigger className="flex items-center flex-wrap gap-2" asChild>
@@ -25,7 +28,7 @@ function SplitTo() {
>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<DialogTitle className="text-2xl">Split between</DialogTitle>
<DialogClose asChild>
<Button className="ml-auto">Go Back</Button>
</DialogClose>
@@ -36,4 +39,4 @@ function SplitTo() {
);
}
export default SplitTo;
export default SplitBetweenInput;
@@ -15,8 +15,8 @@ import { Input } from "@/components/ui/input";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import type { PublicUser } from "next-auth";
import FriendCard from "./friend-card";
import UserCard from "../user-card";
import type { User } from "@/server/db/schema";
const SearchResultCard = ({
addFriend,
@@ -25,10 +25,10 @@ const SearchResultCard = ({
}: {
addFriend: (userId: string) => void;
pending: boolean;
user: PublicUser;
user: User;
}) => {
return (
<FriendCard user={user}>
<UserCard user={user}>
<Button
size={"sm"}
className="ml-auto"
@@ -40,7 +40,7 @@ const SearchResultCard = ({
>
{pending ? "Pending" : "Add Friend"}
</Button>
</FriendCard>
</UserCard>
);
};
@@ -50,11 +50,7 @@ export default function AddFriendDrawer() {
const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000);
const {
data: searchResult,
isFetching,
refetch,
} = api.friend.search.useQuery(
const { data: searchResult, isFetching } = api.friend.search.useQuery(
{
search: searchValue,
},
@@ -70,6 +66,7 @@ export default function AddFriendDrawer() {
utils.friend.getAll.invalidate();
},
});
const handleAddFriend = (userId: string) => {
addFriend.mutate({
userId,
+5 -5
View File
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import FriendCard from "@/app/_components/friend/friend-card";
import UserCard from "@/app/_components/user-card";
import PendingRequestButton from "@/app/_components/friend/pending-request-button";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
@@ -16,19 +16,19 @@ function FriendList() {
{pendingFriends.length ? (
<Section heading="Requests">
{pendingFriends.map(({ user, requestedBy, id }) => (
<FriendCard key={user.id} user={user}>
<UserCard key={user.id} user={user}>
<PendingRequestButton
friendshipId={id}
requestedBy={requestedBy}
/>
</FriendCard>
</UserCard>
))}
</Section>
) : null}
<Section heading={`Friends (${friends?.length ?? 0})`}>
{friends?.length ? (
friends.map(({ user }) => (
<FriendCard key={user.id} user={user}>
<UserCard key={user.id} user={user}>
<Button
size={"sm"}
className="ml-auto"
@@ -37,7 +37,7 @@ function FriendList() {
>
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
</Button>
</FriendCard>
</UserCard>
))
) : (
<p className="text-muted-foreground text-sm">No friends yet</p>
@@ -4,13 +4,14 @@ import React from "react";
export default function FriendRequestButton({}: {}) {
return (
<div>
<Button
{/* <Button
variant={requestedBy === "me" ? "destructive" : "outline"}
className="ml-auto"
size="sm"
>
{requestedBy === "me" ? "Cancel" : "Accept"}
</Button>
</Button> */}
friendRequestButton
</div>
);
}
+1 -2
View File
@@ -1,8 +1,8 @@
"use client";
import Avatar from "@/components/avatar";
import { Combobox } from "@/components/combobox";
import { Icons } from "@/components/icons";
import { api } from "@/trpc/react";
import { Icons } from "@/components/icons";
import React from "react";
@@ -34,7 +34,6 @@ export default function FriendSelect({
initialValue={initialValue}
messageUi={{
select: "Select Friend",
empty: "No friends found",
placeholder: "Search friends",
}}
@@ -0,0 +1,129 @@
"use client";
import React from "react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { Input } from "@/components/ui/input";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import type { User } from "@/server/db/schema";
import UserCard from "../user-card";
const SearchResultCard = ({
addUser,
user,
}: {
addUser: (userId: string) => void;
user: User;
}) => {
return (
<UserCard user={user}>
<Button
size={"sm"}
className="ml-auto"
variant={"outline"}
onClick={() => {
addUser(user.id!);
}}
>
Add
</Button>
</UserCard>
);
};
export default function AddToGroupDrawer({
groupId,
className,
excludedIds,
}: {
groupId: string;
className?: string;
excludedIds: Array<string>;
}) {
const utils = api.useUtils();
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000);
const { data: searchResult, isFetching } = api.user.search.useQuery(
{
search: searchValue,
excludedIds,
},
{
enabled: !!searchValue,
}
);
const addFriend = api.group.addMember.useMutation({
onSuccess() {
toast.success("User added to group.");
setOpen(false);
setValue("");
utils.group.get.invalidate();
utils.group.getAll.invalidate();
},
});
const handleAddFriend = (userId: string) => {
addFriend.mutate({
groupId,
userId,
});
};
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"} className={className} variant={"outline"}>
Add People
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add a user to group</DrawerTitle>
</DrawerHeader>
<div className="px-4 space-y-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder="Search for a user"
/>
<ul className="space-y-2">
{isFetching ? (
<Skeleton className="h-6 w-full" />
) : searchResult?.length ? (
searchResult.map((user) => (
<li key={user.id}>
<SearchResultCard addUser={handleAddFriend} user={user} />
</li>
))
) : (
<p className="text-muted-foreground text-sm">
{searchValue.length
? "No results found"
: "Start typing to search for a user"}
</p>
)}
</ul>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import Avatar from "@/components/avatar";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { Group } from "@/server/db/schema";
import { useRouter } from "next/navigation";
import React from "react";
function GroupCard({ group }: { group: Group }) {
const router = useRouter();
return (
<Card>
<CardHeader className="flex items-center justify-between">
<div className=" flex flex-col gap-2">
<div className="flex items-center gap-4">
<CardTitle className="flex items-center gap-1">
<Icons.group className="size-4" />
<span>{group.name}</span>
</CardTitle>
</div>
<ul className="flex ">
{group?.members?.slice(0, 4)?.map(({ user }, idx) => (
<li
key={idx}
style={{
transform: `translateX(-${idx * 4}px)`,
}}
>
<Avatar src={user?.image} fb={user?.name} className="size-4" />
</li>
))}
{Number(group?.members?.length) > 4 && (
<li
style={{
transform: `translateX(-20px)`,
}}
className="size-4 bg-foreground text-background text-xs items-center justify-center rounded-full "
>
<span>+{Number(group?.members?.length) - 4}</span>
</li>
)}
</ul>
<CardDescription>
<p>{group.description}</p>
</CardDescription>
</div>
<Button
className=" h-max flex items-center bg-foreground text-background rounded-lg pl-4 w-24 justify-between"
onClick={(e) => {
e.preventDefault();
router.push(`/add?groupId=${group.id}`);
}}
>
<span>Add</span>
<Icons.addSquare className="size-8" />
</Button>
</CardHeader>
</Card>
);
}
export default GroupCard;
@@ -10,24 +10,28 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import GroupForm from "./group-form";
import type { Group } from "@/server/db/schema";
export default function CreateGroupDrawer() {
export default function GroupFormDrawer({
initialGroup,
}: {
initialGroup?: Group;
}) {
const [open, setOpen] = React.useState(false);
const existing = Boolean(initialGroup?.id?.length);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"}>Create Group</Button>
<Button size={"sm"}>
{existing ? "Update" : "Create a new"} Group
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create Group</DrawerTitle>
<DrawerTitle>{existing ? "Update" : "Create"} Group</DrawerTitle>
<GroupForm initialGroup={initialGroup} />
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
});
import React from "react";
import { Input } from "@/components/ui/input";
import { groupSchema } from "@/lib/validations/group";
import { api } from "@/trpc/react";
import { useRouter } from "next/navigation";
import type { Group } from "@/server/db/schema";
import { Textarea } from "@/components/ui/textarea";
function GroupForm({ initialGroup }: { initialGroup?: Group }) {
const router = useRouter();
const utils = api.useUtils();
const createGroup = api.group.create.useMutation({
onSuccess(group) {
router.push(`/group/${group?.id}`);
utils.invalidate();
},
});
const updateGroup = api.group.update.useMutation({
onSuccess() {
utils.group.get.invalidate({ id: initialGroup?.id });
},
});
const form = useForm<z.infer<typeof groupSchema>>({
resolver: zodResolver(groupSchema),
defaultValues: {
name: initialGroup?.name ?? "",
description: initialGroup?.description ?? "",
},
});
const existing = Boolean(initialGroup?.id?.length);
function onSubmit(group: z.infer<typeof groupSchema>) {
if (existing) {
updateGroup.mutate({ group, groupId: initialGroup?.id! });
} else {
createGroup.mutate({ group });
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
autoFocus={!existing}
placeholder="What is your groups name?"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea placeholder="What is your groups name?" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{existing ? "Update" : "Create"} Group
</Button>
</form>
</Form>
);
}
export default GroupForm;
+83
View File
@@ -0,0 +1,83 @@
"use client";
import React from "react";
import { api } from "@/trpc/react";
import AddToGroupDrawer from "@/app/_components/group/add-to-group-drawer";
import RemoveFromGroupButton from "@/app/_components/group/remove-from-group-button";
import UserCard from "@/app/_components/user-card";
import Header from "@/components/header";
import Section from "@/components/section";
import GroupFormDrawer from "./group-form-drawer";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons";
import ExpenseCard from "../expense/expense-card";
function GroupPage({
groupId,
sessionUserId,
}: {
groupId: string;
sessionUserId: string;
}) {
const [group] = api.group.get.useSuspenseQuery({ id: groupId });
if (!group) return <GroupFormDrawer />;
const groupExpenses = group?.groupBadges?.map(({ expense }) => expense);
return (
<>
<Header text={group.name}>
<GroupFormDrawer initialGroup={group} />
</Header>
<Section>
<p className="text-muted-foreground text-sm">{group.description}</p>
<div className="flex gap-4 w-full items-center">
<h4 className="text-lg font-semibold">
Members ({group.members.length})
</h4>
<AddToGroupDrawer
groupId={group.id}
className="ml-auto"
excludedIds={group?.members?.map((m) => m.user.id)}
/>
</div>
<ul className="space-y-2">
{group.members.map((member) => (
<li key={member.id}>
<UserCard className="border bg-background" user={member.user!}>
<span className="text-xs text-muted-foreground">
{member.role}
</span>
<RemoveFromGroupButton
userId={member.user.id}
groupId={group.id}
/>
</UserCard>
</li>
))}
</ul>
<div className="flex gap-4 w-full items-center">
<h4 className="text-lg font-semibold">Expenses</h4>
<Button asChild size={"sm"} className="ml-auto" variant={"outline"}>
<Link href={`/add?groupId=${group.id}`}>
<Icons.wallet />
<span>Add Expense</span>
</Link>
</Button>
</div>
<ul className="space-y-2">
{groupExpenses.map((expense) => (
<ExpenseCard
key={expense.id}
sessionUserId={sessionUserId}
expense={expense}
/>
))}
</ul>
</Section>
</>
);
}
export default GroupPage;
@@ -0,0 +1,45 @@
"use client";
import React from "react";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { toast } from "sonner";
function RemoveFromGroupButton({
groupId,
userId,
}: {
groupId: string;
userId: string;
}) {
const utils = api.useUtils();
const removeMember = api.group.removeMember.useMutation({
onSuccess() {
toast.success("User removed from group.");
utils.invalidate();
},
});
const handleClick = () => {
removeMember.mutate({
groupId,
userId,
});
};
return (
<Button
variant={"destructive"}
className="ml-auto text-sm p-1 px-2 size-max"
onClick={handleClick}
disabled={removeMember.isPending}
>
{!removeMember.isPending ? (
<Icons.trash className="size-4" />
) : (
<Icons.loading className="size-4" />
)}
<span>Remove</span>
</Button>
);
}
export default RemoveFromGroupButton;
@@ -1,21 +1,21 @@
import React from "react";
import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils";
import type { PublicUser } from "next-auth";
import React from "react";
import type { User } from "@/server/db/schema";
export default function FriendCard({
export default function UserCard({
user: { name, image },
children,
className,
}: {
user: PublicUser;
user: User;
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-2 p-2 hover:bg-accent/25 rounded-md",
"flex items-center gap-2 p-2 hover:bg-accent/25 rounded-xl",
className
)}
>
-50
View File
@@ -1,50 +0,0 @@
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>
);
}
-3
View File
@@ -1,3 +0,0 @@
import { handlers } from "@/server/auth";
export const { GET, POST } = handlers;
+43
View File
@@ -0,0 +1,43 @@
import { env } from "@/env";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import { eq } from "drizzle-orm";
export async function POST(req: Request) {
try {
const evt = await verifyWebhook(req, {
signingSecret: env.CLERK_WEBHOOK_SIGNING_SECRET,
});
const eventType = evt.type;
switch (eventType) {
case "user.created":
await db.insert(users).values({
id: evt.data.id,
name: evt.data.first_name ?? evt.data.last_name,
image: evt.data.image_url,
});
break;
case "user.updated":
await db
.update(users)
.set({
name: evt.data.first_name ?? evt.data.last_name,
image: evt.data.image_url,
})
.where(eq(users.id, evt.data.id));
break;
case "user.deleted":
await db.delete(users).where(eq(users.id, evt.data.id!));
break;
default:
console.log("Unknown event type:", eventType);
break;
}
return new Response("Webhook received", { status: 200 });
} catch (err) {
console.error("Error verifying webhook:", err);
return new Response("Error verifying webhook", { status: 400 });
}
}
+6 -13
View File
@@ -3,8 +3,8 @@ import "@/styles/globals.css";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
import Providers from "./providers";
// import PWAWrapper from "./_components/install-prompt";
export const metadata: Metadata = {
title: "Create T3 App",
@@ -23,17 +23,10 @@ export default function RootLayout({
return (
<html lang="en" className={`${geist.variable}`}>
<body>
<TRPCReactProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster position="top-center" />
</ThemeProvider>
</TRPCReactProvider>
<Providers>
{children}
<Toaster position="top-center" />
</Providers>
</body>
</html>
);
+26
View File
@@ -0,0 +1,26 @@
import { appConfig } from "@/app.config";
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: appConfig.name,
short_name: appConfig.shortName,
description: "Split your expenses with friends",
start_url: "/",
display: "standalone",
background_color: "#000000",
theme_color: "#ffffff",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
};
}
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
function Providers({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider
appearance={{
baseTheme: dark,
elements: {
userButtonAvatarBox: { height: "2rem", width: "2rem" },
},
}}
>
<TRPCReactProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</TRPCReactProvider>
</ClerkProvider>
);
}
export default Providers;
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { TrendingUp } from "lucide-react";
import {
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart as RadarChartComponent,
} from "recharts";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { getAmount } from "@/lib/utils";
import type { User } from "@/server/db/schema";
import Avatar from "../avatar";
type ChartData = {
user: User;
owesYou: number;
youOwe: number;
total: number;
};
const chartConfig = {
owesYou: {
label: "Owes You",
color: "var(--success)",
},
youOwe: {
label: "You Owe",
color: "var(--destrctive)",
},
total: {
label: "Total",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function AnalyticsRadarChart({
chartData,
}: {
chartData: Array<ChartData>;
}) {
// Transform data to match radar chart expectations
const radarData = chartData.map((data) => ({
subject: data.user.id,
owesYou: data.owesYou,
youOwe: data.youOwe,
total: data.total,
fullMark: Math.max(data.owesYou, data.youOwe) * 1.2, // Add some headroom
}));
return (
<>
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px] w-full"
>
<RadarChartComponent data={radarData} outerRadius="80%">
<PolarGrid />
<PolarAngleAxis
dataKey="subject"
tick={({ payload, x, y, textAnchor, ...rest }) => {
const user = chartData.find(
({ user }) => user.id === payload.value
)!.user;
return (
<text
{...rest}
x={x}
y={y}
textAnchor={textAnchor}
fontSize={12}
fontWeight={500}
className="fill-foreground"
>
<Avatar src={user.image} fb={user.name} className="size-4" />
{user.name}
</text>
);
}}
/>
{/* <Radar
name="Total"
dataKey="total"
stroke="var(--success)"
fill="var(--success)"
fillOpacity={0.6}
/> */}
<Radar
name="Owes You"
dataKey="owesYou"
stroke="var(--success)"
fill="var(--success)"
fillOpacity={0.1}
/>
<Radar
name="You Owe"
dataKey="youOwe"
stroke="var(--destructive)"
fill="var(--destructive)"
fillOpacity={0.6}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
</RadarChartComponent>
</ChartContainer>
</>
);
}
+10 -5
View File
@@ -5,16 +5,21 @@ export default function Header({
children,
glow = true,
}: {
text: string;
text: React.ReactNode;
children?: React.ReactNode;
glow?: boolean;
}) {
return (
<div className="w-full p-4 border-b flex justify-between items-center relative">
<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" />
<div className="w-full p-4 border-b flex justify-between items-center relative overflow-hidden h-16">
<div className="font-black text-2xl uppercase flex items-center">
{text}
</div>
{children}
{/* Glow effect */}
{glow && (
<div className="glow absolute right-0 top-0 w-20 h-8 blur-2xl -z-10 bg-gradient-to-bl from-primary to-transparent" />
)}
</div>
);
}
+224 -1
View File
@@ -4,6 +4,7 @@ type IconName =
| "logo"
| "home"
| "wallet"
| "brokenWallet"
| "add"
| "group"
| "friends"
@@ -12,7 +13,13 @@ type IconName =
| "trash"
| "user"
| "addSquare"
| "check";
| "check"
| "sun"
| "moon"
| "display"
| "arrowRight"
| "filter"
| "loading";
export const Icons: Record<IconName, IconComponent> = {
logo(props) {
@@ -81,6 +88,23 @@ export const Icons: Record<IconName, IconComponent> = {
</svg>
);
},
brokenWallet(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.18465 3.97712L9.16135 13.118C9.16869 13.1406 9.177 13.1626 9.18622 13.1839V15.2C9.18622 15.6142 9.52201 15.95 9.93622 15.95C10.3504 15.95 10.6862 15.6142 10.6862 15.2V3.5817C10.9817 3.45057 11.3028 3.37548 11.6418 3.32991C12.2366 3.24995 12.9858 3.24997 13.8842 3.25L18.4918 3.25C21.3606 3.25 23.6863 5.57564 23.6863 8.44445C23.6863 9.27618 23.2093 10.0342 22.4595 10.3941L21.9721 10.6281C20.8211 11.1806 20.8211 12.8194 21.9721 13.3719L22.4595 13.6059C23.2093 13.9658 23.6863 14.7238 23.6863 15.5556C23.6863 18.4244 21.3606 20.75 18.4918 20.75H9.93793C10.3514 20.7491 10.6862 20.4136 10.6862 20V18.8C10.6862 18.3858 10.3504 18.05 9.93622 18.05C9.52201 18.05 9.18622 18.3858 9.18622 18.8V20C9.18622 20.4134 9.5207 20.7487 9.9338 20.75L6.81991 20.75C5.07675 20.75 3.46745 19.8153 2.60378 18.3011L2.41643 17.9726C2.33486 17.8296 2.272 17.6767 2.22939 17.5177C2.03805 16.8036 2.27319 16.043 2.83417 15.5615L3.35914 15.1109C4.32795 14.2793 3.90377 12.6963 2.64897 12.4605L2.11766 12.3607C1.30023 12.2071 0.643309 11.5984 0.428041 10.795C-0.314462 8.02393 1.33001 5.17562 4.10107 4.43312L4.13954 4.42281C4.81066 4.24297 5.37063 4.09291 5.8359 4.01936C5.9532 4.00082 6.06938 3.98623 6.18465 3.97712Z"
fill="currentColor"
/>
</svg>
);
},
add(props) {
return (
<svg
@@ -266,6 +290,99 @@ export const Icons: Record<IconName, IconComponent> = {
</svg>
);
},
sun(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_197_4386)">
<path
d="M12.75 1C12.75 0.585786 12.4142 0.25 12 0.25C11.5858 0.25 11.25 0.585786 11.25 1V3C11.25 3.41421 11.5858 3.75 12 3.75C12.4142 3.75 12.75 3.41421 12.75 3V1Z"
fill="currentColor"
/>
<path
d="M4.75216 3.69149C4.45926 3.3986 3.98439 3.3986 3.6915 3.69149C3.3986 3.98439 3.3986 4.45926 3.6915 4.75216L5.10571 6.16637C5.3986 6.45926 5.87348 6.45926 6.16637 6.16637C6.45926 5.87348 6.45926 5.3986 6.16637 5.10571L4.75216 3.69149Z"
fill="currentColor"
/>
<path
d="M20.3085 4.75216C20.6014 4.45926 20.6014 3.98439 20.3085 3.6915C20.0156 3.3986 19.5407 3.3986 19.2478 3.69149L17.8336 5.10571C17.5407 5.3986 17.5407 5.87348 17.8336 6.16637C18.1265 6.45926 18.6014 6.45926 18.8943 6.16637L20.3085 4.75216Z"
fill="currentColor"
/>
<path
d="M12 5.25C8.27208 5.25 5.25 8.27208 5.25 12C5.25 15.7279 8.27208 18.75 12 18.75C15.7279 18.75 18.75 15.7279 18.75 12C18.75 8.27208 15.7279 5.25 12 5.25Z"
fill="currentColor"
/>
<path
d="M1 11.25C0.585786 11.25 0.25 11.5858 0.25 12C0.25 12.4142 0.585786 12.75 1 12.75H3C3.41421 12.75 3.75 12.4142 3.75 12C3.75 11.5858 3.41421 11.25 3 11.25H1Z"
fill="currentColor"
/>
<path
d="M21 11.25C20.5858 11.25 20.25 11.5858 20.25 12C20.25 12.4142 20.5858 12.75 21 12.75H23C23.4142 12.75 23.75 12.4142 23.75 12C23.75 11.5858 23.4142 11.25 23 11.25H21Z"
fill="currentColor"
/>
<path
d="M6.16637 18.8943C6.45926 18.6014 6.45926 18.1265 6.16637 17.8336C5.87348 17.5407 5.3986 17.5407 5.10571 17.8336L3.6915 19.2478C3.3986 19.5407 3.3986 20.0156 3.6915 20.3085C3.98439 20.6014 4.45926 20.6014 4.75216 20.3085L6.16637 18.8943Z"
fill="currentColor"
/>
<path
d="M18.8943 17.8336C18.6014 17.5407 18.1265 17.5407 17.8336 17.8336C17.5407 18.1265 17.5407 18.6014 17.8336 18.8943L19.2478 20.3085C19.5407 20.6014 20.0156 20.6014 20.3085 20.3085C20.6014 20.0156 20.6014 19.5407 20.3085 19.2478L18.8943 17.8336Z"
fill="currentColor"
/>
<path
d="M12.75 21C12.75 20.5858 12.4142 20.25 12 20.25C11.5858 20.25 11.25 20.5858 11.25 21V23C11.25 23.4142 11.5858 23.75 12 23.75C12.4142 23.75 12.75 23.4142 12.75 23V21Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_197_4386">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
},
moon(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.9191 12.0134C11.051 8.77773 11.3963 5.88815 11.6711 4.0347L11.6997 3.84335C11.7573 3.46024 11.8126 3.09144 11.8314 2.7912C11.8425 2.61306 11.8442 2.41736 11.8136 2.22926C11.7836 2.04435 11.7113 1.79715 11.5173 1.58811C11.0765 1.11318 10.4574 1.24503 10.1231 1.35182C9.72572 1.47873 9.22917 1.7308 8.62667 2.07865C3.48503 5.04718 1.72337 11.6218 4.6919 16.7634C7.66043 21.9051 14.235 23.6667 19.3767 20.6982C19.9792 20.3503 20.4457 20.0464 20.7543 19.7657C21.014 19.5295 21.4377 19.0593 21.2468 18.4401C21.1628 18.1676 20.9848 17.9814 20.8397 17.8629C20.6921 17.7424 20.5218 17.646 20.362 17.5665C20.0926 17.4327 19.7455 17.2962 19.385 17.1545L19.2049 17.0836C17.4624 16.3949 14.7873 15.2491 12.9191 12.0134Z"
fill="currentColor"
/>
</svg>
);
},
display(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="M15.0336 2.25C16.4053 2.25 17.4807 2.24999 18.3451 2.32061C19.2252 2.39252 19.9523 2.54138 20.6104 2.87671C21.6924 3.42798 22.572 4.30762 23.1233 5.38955C23.4586 6.04769 23.6075 6.77479 23.6794 7.65494C23.75 8.51924 23.75 9.59466 23.75 10.9663V11.0336C23.75 12.4052 23.75 13.4808 23.6794 14.3451C23.6075 15.2252 23.4586 15.9523 23.1233 16.6104C22.572 17.6924 21.6924 18.572 20.6104 19.1233C19.9523 19.4586 19.2252 19.6075 18.3451 19.6794C17.4808 19.75 16.4053 19.75 15.0337 19.75H12.75V21.584C13.3744 21.6276 13.9959 21.7259 14.6073 21.8787L16.1819 22.2724C16.5837 22.3729 16.8281 22.7801 16.7276 23.1819C16.6271 23.5837 16.2199 23.8281 15.8181 23.7276L14.2435 23.3339C13.507 23.1498 12.7535 23.0578 12 23.0578C11.2465 23.0578 10.493 23.1498 9.75655 23.3339L8.1819 23.7276C7.78006 23.8281 7.37285 23.5837 7.27239 23.1819C7.17193 22.7801 7.41625 22.3729 7.8181 22.2724L9.39274 21.8787C10.0041 21.7259 10.6256 21.6276 11.25 21.584V19.75H8.96637C7.59476 19.75 6.51924 19.75 5.65494 19.6794C4.77479 19.6075 4.04769 19.4586 3.38955 19.1233C2.30762 18.572 1.42798 17.6924 0.876713 16.6104C0.541379 15.9523 0.392522 15.2252 0.320612 14.3451C0.249992 13.4807 0.249996 12.4053 0.25 11.0336V10.9664C0.249996 9.59472 0.249992 8.51929 0.320612 7.65494C0.392522 6.7748 0.541379 6.04769 0.876713 5.38955C1.42798 4.30762 2.30762 3.42798 3.38955 2.87671C4.04769 2.54138 4.77479 2.39252 5.65494 2.32061C6.51929 2.24999 7.59472 2.25 8.96644 2.25H15.0336ZM11.25 16C11.25 16.4142 11.5858 16.75 12 16.75L18 16.75C18.4142 16.75 18.75 16.4142 18.75 16C18.75 15.5858 18.4142 15.25 18 15.25L12 15.25C11.5858 15.25 11.25 15.5858 11.25 16ZM8 16C8 16.5523 7.55229 17 7 17C6.44772 17 6 16.5523 6 16C6 15.4477 6.44772 15 7 15C7.55229 15 8 15.4477 8 16Z"
fill="currentColor"
/>
</svg>
);
},
check(props) {
return (
<svg
@@ -283,4 +400,110 @@ export const Icons: Record<IconName, IconComponent> = {
</svg>
);
},
arrowRight(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.528 6.46731C16.2338 6.17573 15.7589 6.17784 15.4673 6.47204C15.1757 6.76624 15.1778 7.24111 15.472 7.53269L17.235 9.28C17.9505 9.98914 18.4413 10.4772 18.7734 10.8907C18.8813 11.0251 18.9655 11.1434 19.0309 11.25L4 11.25C3.58579 11.25 3.25 11.5858 3.25 12C3.25 12.4142 3.58579 12.75 4 12.75L19.0309 12.75C18.9655 12.8566 18.8813 12.9749 18.7734 13.1093C18.4413 13.5228 17.9505 14.0109 17.235 14.72L15.472 16.4673C15.1778 16.7589 15.1757 17.2338 15.4673 17.528C15.7589 17.8222 16.2338 17.8243 16.528 17.5327L18.3227 15.7539C18.9987 15.084 19.5511 14.5364 19.9429 14.0485C20.3504 13.5412 20.6453 13.0263 20.7241 12.4082C20.7414 12.2726 20.75 12.1363 20.75 12C20.75 11.8637 20.7414 11.7274 20.7241 11.5918C20.6453 10.9737 20.3504 10.4588 19.9429 9.95146C19.5511 9.46358 18.9987 8.91604 18.3227 8.24609L16.528 6.46731Z"
fill="currentColor"
/>
</svg>
);
},
filter(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.75 7.99996C13.75 7.30961 14.3096 6.74996 15 6.74996C15.6904 6.74996 16.25 7.30961 16.25 7.99996C16.25 8.69032 15.6904 9.24996 15 9.24996C14.3096 9.24996 13.75 8.69032 13.75 7.99996Z"
fill="currentColor"
/>
<path
d="M7.75 16C7.75 15.3096 8.30964 14.75 9 14.75C9.69036 14.75 10.25 15.3096 10.25 16C10.25 16.6903 9.69036 17.25 9 17.25C8.30964 17.25 7.75 16.6903 7.75 16Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.4635 2.37373C15.3214 2.24999 13.8818 2.24999 12.0452 2.25H11.9548C10.1182 2.24999 8.67861 2.24999 7.53648 2.37373C6.37094 2.50001 5.42656 2.76232 4.62024 3.34815C4.13209 3.70281 3.70281 4.13209 3.34815 4.62024C2.76232 5.42656 2.50001 6.37094 2.37373 7.53648C2.24999 8.67861 2.24999 10.1182 2.25 11.9548V12.0452C2.24999 13.8818 2.24999 15.3214 2.37373 16.4635C2.50001 17.6291 2.76232 18.5734 3.34815 19.3798C3.70281 19.8679 4.13209 20.2972 4.62024 20.6518C5.42656 21.2377 6.37094 21.5 7.53648 21.6263C8.67859 21.75 10.1182 21.75 11.9547 21.75H12.0453C13.8818 21.75 15.3214 21.75 16.4635 21.6263C17.6291 21.5 18.5734 21.2377 19.3798 20.6518C19.8679 20.2972 20.2972 19.8679 20.6518 19.3798C21.2377 18.5734 21.5 17.6291 21.6263 16.4635C21.75 15.3214 21.75 13.8818 21.75 12.0453V11.9547C21.75 10.1182 21.75 8.67859 21.6263 7.53648C21.5 6.37094 21.2377 5.42656 20.6518 4.62024C20.2972 4.13209 19.8679 3.70281 19.3798 3.34815C18.5734 2.76232 17.6291 2.50001 16.4635 2.37373ZM15 5.24996C13.4812 5.24996 12.25 6.48118 12.25 7.99996C12.25 9.51874 13.4812 10.75 15 10.75C16.5188 10.75 17.75 9.51875 17.75 7.99996C17.75 6.48118 16.5188 5.24996 15 5.24996ZM9.75005 6C9.75005 5.58579 9.41427 5.25 9.00005 5.25C8.58584 5.25 8.25005 5.58579 8.25005 6L8.25005 12C8.25005 12.4142 8.58584 12.75 9.00005 12.75C9.41427 12.75 9.75005 12.4142 9.75005 12L9.75005 6ZM15.7501 12C15.7501 11.5858 15.4143 11.25 15.0001 11.25C14.5858 11.25 14.2501 11.5858 14.2501 12V18C14.2501 18.4142 14.5858 18.75 15.0001 18.75C15.4143 18.75 15.7501 18.4142 15.7501 18V12ZM9 13.25C7.48122 13.25 6.25 14.4812 6.25 16C6.25 17.5187 7.48122 18.75 9 18.75C10.5188 18.75 11.75 17.5187 11.75 16C11.75 14.4812 10.5188 13.25 9 13.25Z"
fill="currentColor"
/>
</svg>
);
},
loading(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" {...props}>
<circle
fill="currentColor"
stroke="currentColor"
strokeWidth="15"
r="15"
cx="40"
cy="100"
>
<animate
attributeName="opacity"
calcMode="spline"
dur="2"
values="1;0;1;"
keySplines=".5 0 .5 1;.5 0 .5 1"
repeatCount="indefinite"
begin="-.4"
></animate>
</circle>
<circle
fill="currentColor"
stroke="currentColor"
strokeWidth="15"
r="15"
cx="100"
cy="100"
>
<animate
attributeName="opacity"
calcMode="spline"
dur="2"
values="1;0;1;"
keySplines=".5 0 .5 1;.5 0 .5 1"
repeatCount="indefinite"
begin="-.2"
></animate>
</circle>
<circle
fill="currentColor"
stroke="currentColor"
strokeWidth="15"
r="15"
cx="160"
cy="100"
>
<animate
attributeName="opacity"
calcMode="spline"
dur="2"
values="1;0;1;"
keySplines=".5 0 .5 1;.5 0 .5 1"
repeatCount="indefinite"
begin="0"
></animate>
</circle>
</svg>
);
},
};
+36 -30
View File
@@ -1,40 +1,46 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { Icons } from "./icons";
export function ModeToggle() {
const { setTheme } = useTheme();
const themeIcons = {
light: Icons.sun,
dark: Icons.moon,
system: Icons.display,
};
export function ModeToggle({ className }: { className?: string }) {
const { setTheme, theme, themes } = useTheme();
const currentThemeIndex = theme ? themes.indexOf(theme) : 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
className={cn(
"px-0 py-0 rounded-full size-max w-max justify-start bg-background/50 gap-1",
className
)}
onClick={() =>
setTheme(themes[(currentThemeIndex + 1) % themes.length] as any)
}
>
{themes.map((selectTheme) => {
const Icon = themeIcons[selectTheme as keyof typeof themeIcons];
return (
<div
key={selectTheme}
className={cn(
"size-5 rounded-full flex items-center justify-center",
selectTheme === theme && "bg-foreground text-background"
)}
>
<Icon className="size-3.5 " />
</div>
);
})}
<span className="sr-only">Toggle theme</span>
</Button>
);
}
+4 -2
View File
@@ -15,7 +15,7 @@ function NavLink({ href, name, icon }: NavLink) {
asChild
variant={"ghost"}
className={cn(
"p-2 size-12 text-muted-foreground/75 flex flex-col gap-2 ",
"p-2 size-12 text-muted-foreground/75 flex flex-col gap-2 hover:bg-transparent dark:hover:bg-transparent ",
active && "text-foreground "
)}
>
@@ -38,8 +38,10 @@ function NavLink({ href, name, icon }: NavLink) {
</span>
</Link>
</Button>
{/* Glow effect */}
{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 className="size-16 left-1/2 -z-10 -translate-x-1/2 transform blur-3xl absolute -bottom-8 bg-gradient-to-t from-primary to-transparent" />
)}
</div>
);
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+1 -1
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card/25 text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
+353
View File
@@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+7 -7
View File
@@ -1,10 +1,10 @@
"use client"
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
@@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };
+53 -45
View File
@@ -2,51 +2,59 @@ import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
AUTH_DISCORD_ID: z.string(),
AUTH_DISCORD_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
// AUTH_SECRET:
// process.env.NODE_ENV === "production"
// ? z.string()
// : z.string().optional(),
// AUTH_DISCORD_ID: z.string(),
// AUTH_DISCORD_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
CLERK_SECRET_KEY: z.string(),
CLERK_WEBHOOK_SIGNING_SECRET: z.string(),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
// AUTH_SECRET: process.env.AUTH_SECRET,
// AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
// AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
CLERK_WEBHOOK_SIGNING_SECRET: process.env.CLERK_WEBHOOK_SIGNING_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});
+2
View File
@@ -5,3 +5,5 @@ type NavLink = {
icon?: string;
big?: boolean;
};
type GroupRoles = "admin" | "member";
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useExpenseStore } from "../store/expense-store";
export default function useExpenseParticipantsParams() {
const searchParams = useSearchParams();
const addParticipants = useExpenseStore((state) => state.addParticipants);
// groupId =nmiqj2qgbmi923rqpgu4ngod;
useEffect(() => {}, []);
}
+27 -22
View File
@@ -1,8 +1,7 @@
"use client";
import { create } from "zustand";
import { calculateSplits, type Payment } from "@/lib/utils/expense";
import type { Session } from "next-auth";
import type { PublicUser } from "next-auth";
import type { Group, User } from "@/server/db/schema";
export enum SplitTypeEnum {
Fixed = "fixed",
@@ -14,18 +13,21 @@ export const splitTypeKeys = Object.keys(SplitTypeEnum);
interface ExpenseStore {
amount: number;
session: Session | null;
participants: PublicUser[];
participants: User[];
groupBadges: Group[];
friendTarget?: string;
splitType: SplitType;
payments: Payment[];
splits: Record<string, string>;
setAmount: (amount: number) => void;
setSession: (session: Session) => void;
addParticipant: (user: PublicUser) => void;
addGroupBadges: (groupIds: Group[]) => void;
removeGroupBadge: (groupIds: string[]) => void;
addParticipants: (user: User[]) => void;
removeParticipant: (userId: string) => void;
setFriendTarget: (id?: string) => void;
@@ -43,9 +45,11 @@ const defaultValues: Pick<
| "splitType"
| "payments"
| "splits"
| "groupBadges"
> = {
amount: 0,
participants: [],
groupBadges: [],
friendTarget: undefined,
splitType: "Equal",
payments: [],
@@ -54,25 +58,14 @@ const defaultValues: Pick<
export const useExpenseStore = create<ExpenseStore>((set, get) => ({
...defaultValues,
session: null,
setAmount: (amount) => set({ amount }),
setSession: (session) =>
set(
{
session,
participants: [session.user],
payments: [{ amount: get().amount, userId: session.user.id }],
},
false
),
addParticipant: (newUser) => {
addParticipants: (newUsers) => {
const { participants } = get();
if (!participants.find((user) => user.id === newUser.id)) {
set({ participants: [...participants, newUser] });
}
const uniqueUsers = newUsers.filter(
(user) => !participants.find((u) => u.id === user.id)
);
set({ participants: [...participants, ...uniqueUsers] });
},
removeParticipant: (userId) => {
const filtered = get().participants.filter((user) => user.id !== userId);
set({ participants: filtered });
@@ -92,5 +85,17 @@ export const useExpenseStore = create<ExpenseStore>((set, get) => ({
set({ splits });
},
addGroupBadges: (newGroups) => {
const { groupBadges } = get();
const uniqueGroups = newGroups.filter(
(group) => !groupBadges.find((g) => g.id === group.id)
);
set({ groupBadges: [...groupBadges, ...uniqueGroups] });
},
removeGroupBadge: (groupIds) =>
set({
groupBadges: get().groupBadges.filter(({ id }) => !groupIds.includes(id)),
}),
resetExpenseStore: () => set(defaultValues),
}));
+22
View File
@@ -1,6 +1,7 @@
import type { SplitType } from "@/lib/store/expense-store";
import type { z } from "zod";
import { expenseSplitSchema } from "../validations/expense";
import type { ExpenseSplit } from "@/server/db/schema";
const debtSchema = expenseSplitSchema.pick({
owedFromId: true,
@@ -118,3 +119,24 @@ export function calculateDepts(
return debts;
}
export const calculateTotalValue: (
userId: string,
splits: Array<ExpenseSplit>
) => {
owed: number;
owes: number;
} = (userId, splits) => {
let owed = 0;
let owes = 0;
splits.forEach((split) => {
if (split.owedToId === userId) {
owes += Number(split.amount); // You are the debitor
} else if (split.owedFromId === userId) {
owed += Number(split.amount); // You are the creditor
}
});
return { owed, owes };
};
+2
View File
@@ -16,3 +16,5 @@ export function debounce<T extends (...args: any[]) => void>(
timeoutId = setTimeout(() => func(...args), delay);
};
}
export const getAmount = (amount: number) => `${amount.toFixed(2)}€`;
+4 -1
View File
@@ -1,7 +1,10 @@
import { z } from "zod";
export const groupSchema = z.object({
name: z.string().optional(),
name: z
.string()
.min(2, { message: "Group name must be at least 2 characters." }),
description: z.string().optional(),
});
export const groupMemberSchema = z.object({
+21
View File
@@ -0,0 +1,21 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher([
"/",
"/friend",
"/expense",
"/group",
"/add",
]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect();
});
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};
+8 -2
View File
@@ -1,6 +1,10 @@
import { expenseRouter } from "@/server/api/routers/expense";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { friendRouter } from "./routers/friend";
import {
expenseRouter,
friendRouter,
groupRouter,
userRouter,
} from "./routers";
/**
* This is the primary router for your server.
@@ -10,6 +14,8 @@ import { friendRouter } from "./routers/friend";
export const appRouter = createTRPCRouter({
expense: expenseRouter,
friend: friendRouter,
user: userRouter,
group: groupRouter,
});
// export type definition of API
+157 -11
View File
@@ -1,26 +1,163 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { expenses, expenseSplits, type ExpenseSplit } from "@/server/db/schema";
import {
expenseGroupBadges,
expenses,
expenseSplits,
friendships,
groupMembers,
type ExpenseSplit,
type User,
} from "@/server/db/schema";
import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
import { eq, or } from "drizzle-orm";
import { and, desc, eq, inArray, not, notInArray, or } from "drizzle-orm";
import { api } from "@/trpc/server";
import { getAllFriends } from "./friend";
function restructureExpenseSplits(expenseSplits: Array<ExpenseSplit>) {
const expensesMap = new Map();
expenseSplits.forEach((split) => {
const expenseId = split.expenseId;
const expense = split.expense;
if (!expensesMap.has(expenseId)) {
expensesMap.set(expenseId, {
...expense,
splits: [],
});
}
expensesMap.get(expenseId).splits.push(split);
});
return Array.from(expensesMap.values());
}
export const expenseRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.expenseSplits.findMany({
const splits = await ctx.db.query.expenseSplits.findMany({
where: or(
eq(expenseSplits.owedFromId, ctx.session.user.id),
eq(expenseSplits.owedToId, ctx.session.user.id)
eq(expenseSplits.owedFromId, ctx.auth.userId),
eq(expenseSplits.owedToId, ctx.auth.userId)
),
with: {
owedFrom: true,
owedTo: true,
expense: {
with: {
groupBadges: {
with: {
group: true,
},
},
},
},
},
orderBy: desc(expenseSplits.createdAt),
});
const expenses = restructureExpenseSplits(splits);
return expenses;
}),
searchParticipants: protectedProcedure
.input(z.object({ search: z.string(), excludedIds: z.array(z.string()) }))
.query(async ({ ctx, input }) => {
const friends = await getAllFriends(ctx, {
pending: true,
ids: input.excludedIds,
});
const groups = await ctx.db.query.groupMembers.findMany({
where: and(
eq(groupMembers.userId, ctx.auth.userId),
notInArray(groupMembers.groupId, input.excludedIds)
),
with: {
group: {
with: {
members: {
with: {
user: true,
},
},
},
},
},
});
return {
friends,
groups,
};
}),
getAnalytics: protectedProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.auth.userId;
const allSplits = await ctx.db.query.expenseSplits.findMany({
where: (split, { or }) =>
or(
eq(split.owedFromId, currentUserId),
eq(split.owedToId, currentUserId)
),
with: {
owedTo: true,
owedFrom: true,
expense: true,
},
});
// Calculate debts per user
const debtMap = new Map<
string,
{ owesYou: number; youOwe: number; user: User }
>();
for (const split of allSplits) {
// Skip if both parties are the same user
if (split.owedFromId === split.owedToId) continue;
const isOwedToYou = split.owedFromId === currentUserId;
const otherUser = isOwedToYou ? split.owedTo : split.owedFrom;
const amount = parseFloat(split?.amount!);
if (!debtMap.has(otherUser.id)) {
debtMap.set(otherUser.id, {
owesYou: 0,
youOwe: 0,
user: otherUser,
});
}
const otherUserDebt = debtMap.get(otherUser.id)!;
if (isOwedToYou) {
otherUserDebt.owesYou += amount;
} else {
otherUserDebt.youOwe += amount;
}
}
// Convert to array and sort by total debt (sum of absolute values)
const debtData = Array.from(debtMap.entries())
.map(([_, amounts]) => ({
user: amounts.user,
owesYou: amounts.owesYou,
youOwe: amounts.youOwe,
total: Math.abs(amounts.owesYou) + Math.abs(amounts.youOwe),
}))
.sort((a, b) => b.total - a.total)
.slice(0, 6); // Get top 5
return debtData;
}),
// mutations
create: protectedProcedure
.input(
z.object({
expense: expenseSchema,
groupBadges: z.array(z.string()),
debs: z.array(
expenseSplitSchema.pick({
owedFromId: true,
@@ -31,16 +168,25 @@ export const expenseRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
const e = {
createdById: ctx.auth.userId,
...input.expense,
amount: input.expense.amount.toString(),
};
console.log(e);
const [expense] = await ctx.db
.insert(expenses)
.values({
createdById: ctx.session.user.id,
...input.expense,
amount: input.expense.amount.toString(),
})
.values(e)
.returning({ id: expenses.id });
if (!expense?.id?.length) throw new Error("Expense cant get created");
if (input.groupBadges.length) {
await ctx.db.insert(expenseGroupBadges).values(
input.groupBadges.map((groupId) => ({
groupId,
expenseId: expense.id,
}))
);
}
await ctx.db
.insert(expenseSplits)
.values(
+54 -24
View File
@@ -1,37 +1,67 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
type TRPCContext,
} from "@/server/api/trpc";
import { friendships, users } from "@/server/db/schema";
import { and, eq, ilike, inArray, not, or } from "drizzle-orm";
import { and, eq, ilike, inArray, not, notInArray, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function getAllFriends(
ctx: TRPCContext,
exluded?: { pending?: boolean; ids?: Array<string> }
) {
const friends = await ctx.db.query.friendships.findMany({
where: and(
or(
eq(friendships.userOneId, ctx.auth.userId!),
eq(friendships.userTwoId, ctx.auth.userId!)
),
exluded?.pending ? eq(friendships.status, "accepted") : undefined,
exluded?.ids?.length
? or(
notInArray(friendships.userOneId, exluded.ids),
notInArray(friendships.userTwoId, exluded.ids)
)
: undefined
),
with: {
userOne: true,
userTwo: true,
},
});
return friends.map((f) => ({
id: f.id,
status: f.status,
requestedBy: ctx.auth.userId === f.userOneId ? "me" : "them",
user: ctx.auth.userId === f.userOneId ? f.userTwo : f.userOne,
}));
}
export const friendRouter = createTRPCRouter({
// queries
getAll: protectedProcedure.query(async ({ ctx }) => {
const friends = await ctx.db.query.friendships.findMany({
where: or(
eq(friendships.userOneId, ctx.session.user.id),
eq(friendships.userTwoId, ctx.session.user.id)
),
with: {
userOne: true,
userTwo: true,
},
});
return friends.map((f) => ({
id: f.id,
status: f.status,
requestedBy: ctx.session.user.id === f.userOneId ? "me" : "them",
user: ctx.session.user.id === f.userOneId ? f.userTwo : f.userOne,
}));
}),
getAll: protectedProcedure
.input(
z
.object({
excludePending: z.boolean().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
return await getAllFriends(ctx, {
pending: input?.excludePending ?? false,
});
}),
getPendingFriendRequests: protectedProcedure.query(
async ({ ctx }) =>
await ctx.db.query.friendships.findMany({
where: and(
or(
eq(friendships.userOneId, ctx.session.user.id),
eq(friendships.userTwoId, ctx.session.user.id)
eq(friendships.userOneId, ctx.auth.userId),
eq(friendships.userTwoId, ctx.auth.userId)
),
eq(friendships.status, "pending")
),
@@ -40,7 +70,7 @@ export const friendRouter = createTRPCRouter({
search: protectedProcedure
.input(z.object({ search: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const userId = ctx.auth.userId;
const friendIds = await ctx.db.query.friendships.findMany({
where: and(
or(
@@ -91,7 +121,7 @@ export const friendRouter = createTRPCRouter({
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(friendships).values({
userOneId: ctx.session.user.id,
userOneId: ctx.auth.userId,
userTwoId: input.userId,
status: "pending",
});
+127
View File
@@ -0,0 +1,127 @@
import { groupSchema } from "@/lib/validations/group";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { groupMembers, groups } from "@/server/db/schema";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export const groupRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
groupBadges: {
with: {
expense: {
with: {
groupBadges: {
with: {
group: true,
},
},
splits: {
with: {
owedFrom: true,
owedTo: true,
},
},
},
},
},
},
members: {
with: {
user: true,
},
},
},
});
if (group?.members?.find((member) => member.userId === ctx.auth.userId))
return group;
return null;
}),
getAll: protectedProcedure
.input(
z
.object({
withMembers: z.boolean().optional().default(false),
})
.optional()
)
.query(async ({ ctx, input }) => {
const membersShips = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.auth.userId),
with: {
group: input?.withMembers
? {
with: {
members: {
with: {
user: true,
},
},
},
}
: true,
},
});
return membersShips?.map(({ group }) => group) ?? [];
}),
// mutations
create: protectedProcedure
.input(z.object({ group: groupSchema }))
.mutation(async ({ ctx, input }) => {
const [group] = await ctx.db
.insert(groups)
.values({
...input.group,
createdById: ctx.auth.userId,
})
.returning({ id: groups.id });
if (!group?.id?.length) throw new Error("Group cant get created");
await ctx.db.insert(groupMembers).values({
groupId: group.id,
userId: ctx.auth.userId,
});
return group;
}),
update: protectedProcedure
.input(z.object({ group: groupSchema, groupId: z.string() }))
.mutation(async ({ ctx, input }) => {
console.log("Check if groupmember is admin!!");
return await ctx.db
.update(groups)
.set(input.group)
.where(eq(groups.id, input.groupId))
.returning({ id: groups.id });
}),
addMember: protectedProcedure
.input(z.object({ groupId: z.string(), userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
revalidatePath(`/group/${input.groupId}`);
}),
removeMember: protectedProcedure
.input(z.object({ groupId: z.string(), userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db
.delete(groupMembers)
.where(
and(
eq(groupMembers.groupId, input.groupId),
eq(groupMembers.userId, input.userId)
)
);
revalidatePath(`/group/${input.groupId}`);
}),
});
+4
View File
@@ -0,0 +1,4 @@
export { friendRouter } from "./friend";
export { expenseRouter } from "./expense";
export { userRouter } from "./user";
export { groupRouter } from "./group";
+32 -10
View File
@@ -1,14 +1,36 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { and, eq, ilike, inArray, not } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { users } from "@/server/db/schema";
import { z } from "zod";
export const userRouter = createTRPCRouter({
getAll: protectedProcedure.query(
async ({ ctx }) =>
await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
},
getSessionUser: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.auth.userId),
});
return user;
}),
search: protectedProcedure
.input(
z.object({
search: z.string(),
excludedIds: z.array(z.string()).optional(),
})
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.auth.userId;
const searchResult = await ctx.db.query.users.findMany({
where: and(
not(eq(users.id, userId)),
ilike(users.name, `%${input.search}%`),
input.excludedIds?.length
? not(inArray(users.id, input.excludedIds))
: undefined
),
});
console.log("search results", searchResult);
return searchResult;
}),
});
+42 -40
View File
@@ -11,8 +11,8 @@ import { TRPCError, initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { auth } from "@clerk/nextjs/server";
/**
* 1. CONTEXT
@@ -27,15 +27,17 @@ import { db } from "@/server/db";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
const clerkAuth = await auth();
return {
db,
session,
...opts,
};
return {
db,
auth: clerkAuth,
...opts,
};
};
export type TRPCContext = Awaited<ReturnType<typeof createTRPCContext>>;
/**
* 2. INITIALIZATION
*
@@ -44,17 +46,17 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
@@ -85,20 +87,20 @@ export const createTRPCRouter = t.router;
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
return result;
});
/**
@@ -119,15 +121,15 @@ export const publicProcedure = t.procedure.use(timingMiddleware);
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.auth?.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
auth: { ...ctx.auth, userId: ctx.auth.userId },
},
});
});
-69
View File
@@ -1,69 +0,0 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import type { DefaultSession, NextAuthConfig, User } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { db } from "@/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@/server/db/schema";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
type PublicUser = Pick<User, "id" | "name" | "image">;
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
providers: [
DiscordProvider,
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
},
} satisfies NextAuthConfig;
-10
View File
@@ -1,10 +0,0 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };
+3 -1
View File
@@ -9,10 +9,12 @@ import * as schema from "./schema";
* update.
*/
const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined;
conn: postgres.Sql | undefined;
};
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });
export type DbType = typeof db;
+75 -111
View File
@@ -1,8 +1,6 @@
import { relations, sql } from "drizzle-orm";
import { index, pgTableCreator, primaryKey } from "drizzle-orm/pg-core";
import type { User } from "next-auth";
import type { AdapterAccount } from "next-auth/adapters";
import { index, pgTableCreator } from "drizzle-orm/pg-core";
import { createId as createCuid2 } from "@paralleldrive/cuid2";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -11,17 +9,16 @@ import type { AdapterAccount } from "next-auth/adapters";
*/
export const createTable = pgTableCreator((name) => `betterwise_${name}`);
const createId = () => createCuid2();
export const expenses = createTable(
"expense",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
groupId: d.uuid(),
friendId: d.uuid(),
id: d.varchar().primaryKey().$defaultFn(createId),
groupId: d.varchar(),
friendId: d.varchar(),
createdById: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
amount: d.numeric({ scale: 2 }),
@@ -50,31 +47,30 @@ export const expenseRelations = relations(expenses, ({ many, one }) => ({
fields: [expenses.createdById],
references: [users.id],
}),
groupBadges: many(expenseGroupBadges),
}));
export type Expense = typeof expenses.$inferSelect & {
splits?: Array<ExpenseSplit>;
group?: Group;
createdBy?: User;
groupBadges?: Array<ExpenseGroupBadge>;
};
export const expenseSplits = createTable(
"expense_split",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
expenseId: d
.uuid()
.varchar()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
owedToId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
owedFromId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
amount: d.numeric({ scale: 2 }),
@@ -105,22 +101,20 @@ export const expenseSplitRelations = relations(expenseSplits, ({ one }) => ({
export type ExpenseSplit = typeof expenseSplits.$inferSelect & {
expense?: Expense;
paidBy?: User;
owedBy?: User;
owedTo?: User;
owedFrom?: User;
};
export const settlements = createTable(
"settlement",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
payerId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
receiverId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id),
amount: d.numeric().notNull(),
@@ -152,19 +146,52 @@ export type Settlement = typeof settlements.$inferSelect & {
receiver?: User;
};
export const expenseGroupBadges = createTable(
"expense_group_badge",
(d) => ({
id: d.varchar().primaryKey().$defaultFn(createId),
expenseId: d
.varchar()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
groupId: d
.varchar()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
}),
(t) => [index("expense_group_badge_expense_idx").on(t.expenseId)]
);
export const expenseGroupBadgeRelations = relations(
expenseGroupBadges,
({ one }) => ({
expense: one(expenses, {
fields: [expenseGroupBadges.expenseId],
references: [expenses.id],
}),
group: one(groups, {
fields: [expenseGroupBadges.groupId],
references: [groups.id],
}),
})
);
export type ExpenseGroupBadge = typeof expenseGroupBadges.$inferSelect & {
expense?: Expense;
group?: Group;
};
// Groups Table
export const groups = createTable(
"group",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
name: d.varchar({ length: 255 }).notNull(),
description: d.text(),
createdById: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
.references(() => users.id),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
@@ -179,29 +206,33 @@ export const groupRelations = relations(groups, ({ many, one }) => ({
references: [users.id],
}),
members: many(groupMembers),
groupBadges: many(expenseGroupBadges),
}));
export type Group = typeof groups.$inferSelect & {
createdBy?: User;
members?: Array<GroupMember>;
groupBadges?: Array<ExpenseGroupBadge>;
};
// Group Members Table (Tracks who is in a group)
export const groupMembers = createTable(
"group_member",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
groupId: d
.uuid()
.varchar()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: d
.varchar({ length: 10 })
.notNull()
.$type<GroupRoles>()
.default("admin"),
joinedAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
@@ -233,19 +264,17 @@ export type GroupMember = typeof groupMembers.$inferSelect & {
};
// Friendships Table (Tracks friend relationships)
export const friendships = createTable(
"friendship",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().primaryKey().$defaultFn(createId),
userOneId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
userTwoId: d
.uuid()
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
status: d
@@ -281,76 +310,11 @@ export type Friendship = typeof friendships.$inferSelect & {
};
export const users = createTable("user", (d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
id: d.varchar().notNull().primaryKey().unique(),
name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(),
emailVerified: d
.timestamp({
mode: "date",
withTimezone: true,
})
.default(sql`CURRENT_TIMESTAMP`),
image: d.varchar({ length: 255 }),
}));
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
}));
export type User = typeof users.$inferSelect;
export const accounts = createTable(
"account",
(d) => ({
userId: d
.uuid()
.notNull()
.references(() => users.id),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.varchar({ length: 255 }),
scope: d.varchar({ length: 255 }),
id_token: d.text(),
session_state: d.varchar({ length: 255 }),
}),
(t) => [
primaryKey({ columns: [t.provider, t.providerAccountId] }),
index("account_user_id_idx").on(t.userId),
]
);
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
userId: d
.uuid()
.notNull()
.references(() => users.id),
expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
}),
(t) => [index("t_user_id_idx").on(t.userId)]
);
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
(d) => ({
identifier: d.varchar({ length: 255 }).notNull(),
token: d.varchar({ length: 255 }).notNull(),
expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })]
);
// export const usersRelations = relations(users, ({ many }) => ({}));
+25
View File
@@ -0,0 +1,25 @@
import "dotenv/config";
import { db } from ".";
import { users } from "./schema";
const usersData = Array.from({ length: 10 }, (_, i) => ({
id: `fake-user-${i + 1}`,
name: `User ${i + 1}`,
image: `https://github.com/mr-shortman.png`,
}));
async function seed() {
try {
await db.insert(users).values(usersData);
console.log(`Seeded users (${usersData.length})`);
} catch (e) {
console.log(e);
}
}
(() => {
seed().then(() => {
console.log("Seeding complete");
process.exit(0);
});
})();
+4
View File
@@ -41,6 +41,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
--success: oklch(0.723 0.219 149.579);
--success-foreground: oklch(0.982 0.018 155.826);
}
.dark {
@@ -113,6 +115,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}
@layer base {
+7 -7
View File
@@ -13,18 +13,18 @@ import { createQueryClient } from "./query-client";
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient,
caller,
getQueryClient
);
View File
+38 -38
View File
@@ -1,43 +1,43 @@
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
".eslintrc.cjs",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
".eslintrc.cjs",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "public/sw.js", "public/workbox-*.js"]
}