Compare commits
16
Commits
cd8a8d746d
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4f00eaff | ||
|
|
4082107052 | ||
|
|
81b1c9d870 | ||
|
|
d914fe5df8 | ||
|
|
f994804a84 | ||
|
|
48ddb2d4a5 | ||
|
|
9c3187cabf | ||
|
|
a0b4b8d48c | ||
|
|
759fb86f76 | ||
|
|
4a862ec06a | ||
|
|
a945005e76 | ||
|
|
76f1685c71 | ||
|
|
62f1689c1a | ||
|
|
3f4cc126ec | ||
|
|
3fc08a3c10 | ||
|
|
a2492fefbd |
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { CapacitorConfig } from "@capacitor/cli";
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: "bettersplit.shrt.solutions",
|
||||
appName: "bettersplit",
|
||||
webDir: "out",
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@
|
||||
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",
|
||||
@@ -13,6 +14,11 @@ const withPWA = pwa({
|
||||
skipWaiting: true,
|
||||
});
|
||||
|
||||
export default withPWA({
|
||||
const nextConfig = {
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
});
|
||||
experimental: {
|
||||
turbo: {},
|
||||
},
|
||||
} satisfies NextConfig;
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
+11
-3
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"postbuild": "pnpm db:push",
|
||||
"export": "next export",
|
||||
"check": "biome check .",
|
||||
"check:unsafe": "biome check --write --unsafe .",
|
||||
@@ -13,16 +14,20 @@
|
||||
"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",
|
||||
"@capacitor/cli": "^7.2.0",
|
||||
"@capacitor/core": "^7.2.0",
|
||||
"@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",
|
||||
@@ -43,20 +48,23 @@
|
||||
"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-auth": "5.0.0-beta.25",
|
||||
"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",
|
||||
|
||||
Generated
+839
-785
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
-1
File diff suppressed because one or more lines are too long
@@ -1,10 +1,12 @@
|
||||
type AppConfig = {
|
||||
name: string;
|
||||
shortName: string;
|
||||
navigator: Array<NavLink>;
|
||||
};
|
||||
|
||||
export const appConfig: AppConfig = {
|
||||
name: "Bettesplit",
|
||||
shortName: "BS",
|
||||
navigator: [
|
||||
{
|
||||
name: "Home",
|
||||
|
||||
@@ -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!} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import AddFriendDrawer from "@/app/_components/friend/add-friend-drawer";
|
||||
import FriendList from "@/app/_components/friend/friend-list";
|
||||
import Header from "@/components/header";
|
||||
import { auth } from "@/server/auth";
|
||||
import { api, HydrateClient } from "@/trpc/server";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import React from "react";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth();
|
||||
if (session?.user) void api.friend.getAll.prefetch();
|
||||
const user = await currentUser();
|
||||
if (user) void api.friend.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,43 +1,71 @@
|
||||
"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
|
||||
);
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
|
||||
{participants.map((user) => (
|
||||
<ul
|
||||
key={user.id}
|
||||
className="border rounded-full gap-1 flex items-center w-max "
|
||||
>
|
||||
<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 "
|
||||
variant="destructive"
|
||||
@@ -49,9 +77,10 @@ export default function ExpenseParticipants({ session }: { session: Session }) {
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</ul>
|
||||
</UserBadge>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
<AddParticipants excludedIds={excludeIds} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
+13
-9
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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,86 +0,0 @@
|
||||
// "use client";
|
||||
// import React, { useEffect, useState } from "react";
|
||||
// export function usePwaInstallStatus() {
|
||||
// const [isStandalone, setIsStandalone] = useState(false);
|
||||
// const [isIOS, setIsIOS] = useState(false);
|
||||
// const [isAndroid, setIsAndroid] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// const userAgent =
|
||||
// navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
|
||||
// // iOS detection
|
||||
// const iOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
// setIsIOS(iOS);
|
||||
|
||||
// // Android detection
|
||||
// const android = /android/i.test(userAgent);
|
||||
// setIsAndroid(android);
|
||||
|
||||
// // PWA "standalone" mode detection (works for both iOS & Android)
|
||||
// const isInStandaloneMode =
|
||||
// window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// (navigator as any).standalone === true; // for iOS Safari
|
||||
|
||||
// setIsStandalone(isInStandaloneMode);
|
||||
// }, []);
|
||||
|
||||
// return { isIOS, isAndroid, isStandalone };
|
||||
// }
|
||||
|
||||
// function InstallPrompt() {
|
||||
// const { isIOS, isAndroid, isStandalone } = usePwaInstallStatus();
|
||||
// const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
|
||||
|
||||
// useEffect(() => {
|
||||
// const handler = (e: any) => {
|
||||
// e.preventDefault();
|
||||
// setDeferredPrompt(e);
|
||||
// };
|
||||
|
||||
// window.addEventListener("beforeinstallprompt", handler);
|
||||
|
||||
// return () => {
|
||||
// window.removeEventListener("beforeinstallprompt", handler);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
// const handleInstallClick = () => {
|
||||
// console.log("install clicked");
|
||||
|
||||
// if (deferredPrompt) {
|
||||
// deferredPrompt.prompt();
|
||||
// deferredPrompt.userChoice.then((choiceResult: any) => {
|
||||
// if (choiceResult.outcome === "accepted") {
|
||||
// console.log("User accepted the A2HS prompt");
|
||||
// } else {
|
||||
// console.log("User dismissed the A2HS prompt");
|
||||
// }
|
||||
// setDeferredPrompt(null);
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
// if (isStandalone) {
|
||||
// return null; // Already running as a PWA, no need to show install prompt
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="absolute w-full h-20 z-50 bg-red-500 left-0 flex items-center justify-center right-0 bottom-0 text-white">
|
||||
// {isIOS && <p>Tap "Share" and "Add to Home Screen" to install the app.</p>}
|
||||
// {isAndroid && <button onClick={handleInstallClick}>Install App</button>}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default function PWAWrapper({
|
||||
// children,
|
||||
// }: {
|
||||
// children: React.ReactNode;
|
||||
// }) {
|
||||
// return (
|
||||
// <>
|
||||
// <InstallPrompt />
|
||||
// {children}
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handlers } from "@/server/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+3
-13
@@ -3,8 +3,7 @@ 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 = {
|
||||
@@ -24,19 +23,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className={`${geist.variable}`}>
|
||||
<body>
|
||||
<TRPCReactProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{/* <PWAWrapper>
|
||||
</PWAWrapper> */}
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster position="top-center" />
|
||||
</ThemeProvider>
|
||||
</TRPCReactProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+4
-4
@@ -4,12 +4,12 @@ import type { MetadataRoute } from "next";
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: appConfig.name,
|
||||
short_name: "NextPWA",
|
||||
description: "A Progressive Web App built with Next.js",
|
||||
short_name: appConfig.shortName,
|
||||
description: "Split your expenses with friends",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#000000",
|
||||
background_color: "#000000",
|
||||
theme_color: "#ffffff",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192x192.png",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+17
-9
@@ -7,16 +7,18 @@ export const env = createEnv({
|
||||
* 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(),
|
||||
// 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(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,7 @@ export const env = createEnv({
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -33,9 +36,14 @@ export const env = createEnv({
|
||||
* 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,
|
||||
// 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,
|
||||
},
|
||||
|
||||
Vendored
+2
@@ -5,3 +5,5 @@ type NavLink = {
|
||||
icon?: string;
|
||||
big?: boolean;
|
||||
};
|
||||
|
||||
type GroupRoles = "admin" | "member";
|
||||
|
||||
@@ -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(() => {}, []);
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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)}€`;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)(.*)",
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 [expense] = await ctx.db
|
||||
.insert(expenses)
|
||||
.values({
|
||||
createdById: ctx.session.user.id,
|
||||
const e = {
|
||||
createdById: ctx.auth.userId,
|
||||
...input.expense,
|
||||
amount: input.expense.amount.toString(),
|
||||
})
|
||||
};
|
||||
console.log(e);
|
||||
const [expense] = await ctx.db
|
||||
.insert(expenses)
|
||||
.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(
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
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 const friendRouter = createTRPCRouter({
|
||||
// queries
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
export async function getAllFriends(
|
||||
ctx: TRPCContext,
|
||||
exluded?: { pending?: boolean; ids?: Array<string> }
|
||||
) {
|
||||
const friends = await ctx.db.query.friendships.findMany({
|
||||
where: or(
|
||||
eq(friendships.userOneId, ctx.session.user.id),
|
||||
eq(friendships.userTwoId, ctx.session.user.id)
|
||||
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,
|
||||
@@ -21,17 +35,33 @@ export const friendRouter = createTRPCRouter({
|
||||
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,
|
||||
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
|
||||
.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",
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export { friendRouter } from "./friend";
|
||||
export { expenseRouter } from "./expense";
|
||||
export { userRouter } from "./user";
|
||||
export { groupRouter } from "./group";
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
auth: clerkAuth,
|
||||
...opts,
|
||||
};
|
||||
};
|
||||
|
||||
export type TRPCContext = Awaited<ReturnType<typeof createTRPCContext>>;
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
@@ -121,13 +123,13 @@ export const publicProcedure = t.procedure.use(timingMiddleware);
|
||||
export const protectedProcedure = t.procedure
|
||||
.use(timingMiddleware)
|
||||
.use(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
if (!ctx.auth?.userId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
// infers the `session` as non-nullable
|
||||
session: { ...ctx.session, user: ctx.session.user },
|
||||
auth: { ...ctx.auth, userId: ctx.auth.userId },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,70 +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 = {
|
||||
trustHost: true,
|
||||
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;
|
||||
@@ -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 };
|
||||
@@ -16,3 +16,5 @@ 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
@@ -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 }) => ({}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -26,5 +26,5 @@ const caller = createCaller(createContext);
|
||||
|
||||
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
|
||||
caller,
|
||||
getQueryClient,
|
||||
getQueryClient
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user