Skip to content

Commit 71b9eb8

Browse files
committed
Initial commit
0 parents  commit 71b9eb8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

92 files changed

+18671
-0
lines changed

.eslintrc.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "next/core-web-vitals",
3+
"rules": {
4+
"no-unused-vars": ["warn"]
5+
}
6+
}

.gitignore

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.*
7+
.yarn/*
8+
!.yarn/patches
9+
!.yarn/plugins
10+
!.yarn/releases
11+
!.yarn/versions
12+
13+
# testing
14+
/coverage
15+
16+
# next.js
17+
/.next/
18+
/out/
19+
20+
# production
21+
/build
22+
23+
# misc
24+
.DS_Store
25+
*.pem
26+
27+
# debug
28+
npm-debug.log*
29+
yarn-debug.log*
30+
yarn-error.log*
31+
.pnpm-debug.log*
32+
33+
# env files (can opt-in for committing if needed)
34+
.env*
35+
36+
# vercel
37+
.vercel
38+
39+
# typescript
40+
*.tsbuildinfo
41+
next-env.d.ts

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"git.ignoreLimitWarning": true
3+
}

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Full Stack AI Splitwise Clone with Next JS, Convex, Tailwind, Inngest, Shadcn UI Tutorial 🔥🔥
2+
## https://youtu.be/Ce7O3p7-YDI
3+
4+
![splitr](https://github.com/user-attachments/assets/11e138c4-efcf-4a85-8586-f2993da118d8)
5+
6+
### Make sure to create a `.env` file with following variables -
7+
8+
```
9+
# Deployment used by `npx convex dev`
10+
CONVEX_DEPLOYMENT=
11+
12+
NEXT_PUBLIC_CONVEX_URL=
13+
14+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
15+
CLERK_SECRET_KEY=
16+
17+
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
18+
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
19+
20+
CLERK_JWT_ISSUER_DOMAIN=
21+
22+
RESEND_API_KEY=
23+
24+
GEMINI_API_KEY=
25+
```

app/(auth)/layout.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const AuthLayout = ({ children }) => {
2+
return <div className="flex justify-center pt-40">{children}</div>;
3+
};
4+
5+
export default AuthLayout;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SignIn } from "@clerk/nextjs";
2+
3+
export default function Page() {
4+
return <SignIn />;
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SignUp } from "@clerk/nextjs";
2+
3+
export default function Page() {
4+
return <SignUp />;
5+
}
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useForm } from "react-hook-form";
5+
import { zodResolver } from "@hookform/resolvers/zod";
6+
import * as z from "zod";
7+
import { api } from "@/convex/_generated/api";
8+
import { useConvexMutation, useConvexQuery } from "@/hooks/use-convex-query";
9+
import { toast } from "sonner";
10+
import {
11+
Dialog,
12+
DialogContent,
13+
DialogHeader,
14+
DialogTitle,
15+
DialogFooter,
16+
} from "@/components/ui/dialog";
17+
import { Button } from "@/components/ui/button";
18+
import { Input } from "@/components/ui/input";
19+
import { Label } from "@/components/ui/label";
20+
import { Textarea } from "@/components/ui/textarea";
21+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
22+
import { X, UserPlus } from "lucide-react";
23+
import { Badge } from "@/components/ui/badge";
24+
import {
25+
Command,
26+
CommandEmpty,
27+
CommandGroup,
28+
CommandInput,
29+
CommandItem,
30+
CommandList,
31+
} from "@/components/ui/command";
32+
import {
33+
Popover,
34+
PopoverContent,
35+
PopoverTrigger,
36+
} from "@/components/ui/popover";
37+
38+
const groupSchema = z.object({
39+
name: z.string().min(1, "Group name is required"),
40+
description: z.string().optional(),
41+
});
42+
43+
export function CreateGroupModal({ isOpen, onClose, onSuccess }) {
44+
const [selectedMembers, setSelectedMembers] = useState([]);
45+
const [searchQuery, setSearchQuery] = useState("");
46+
const [commandOpen, setCommandOpen] = useState(false);
47+
48+
const { data: currentUser } = useConvexQuery(api.users.getCurrentUser);
49+
const createGroup = useConvexMutation(api.contacts.createGroup);
50+
const { data: searchResults, isLoading: isSearching } = useConvexQuery(
51+
api.users.searchUsers,
52+
{ query: searchQuery }
53+
);
54+
55+
const {
56+
register,
57+
handleSubmit,
58+
formState: { errors, isSubmitting },
59+
reset,
60+
} = useForm({
61+
resolver: zodResolver(groupSchema),
62+
defaultValues: {
63+
name: "",
64+
description: "",
65+
},
66+
});
67+
68+
const addMember = (user) => {
69+
if (!selectedMembers.some((m) => m.id === user.id)) {
70+
setSelectedMembers([...selectedMembers, user]);
71+
}
72+
setCommandOpen(false);
73+
};
74+
75+
const removeMember = (userId) => {
76+
setSelectedMembers(selectedMembers.filter((m) => m.id !== userId));
77+
};
78+
79+
const onSubmit = async (data) => {
80+
try {
81+
// Extract member IDs
82+
const memberIds = selectedMembers.map((member) => member.id);
83+
84+
// Create the group
85+
const groupId = await createGroup.mutate({
86+
name: data.name,
87+
description: data.description,
88+
members: memberIds,
89+
});
90+
91+
// Success
92+
toast.success("Group created successfully!");
93+
reset();
94+
setSelectedMembers([]);
95+
onClose();
96+
97+
// Redirect to the new group page
98+
if (onSuccess) {
99+
onSuccess(groupId);
100+
}
101+
} catch (error) {
102+
toast.error("Failed to create group: " + error.message);
103+
}
104+
};
105+
106+
const handleClose = () => {
107+
reset();
108+
setSelectedMembers([]);
109+
onClose();
110+
};
111+
112+
return (
113+
<Dialog open={isOpen} onOpenChange={handleClose}>
114+
<DialogContent className="sm:max-w-md">
115+
<DialogHeader>
116+
<DialogTitle>Create New Group</DialogTitle>
117+
</DialogHeader>
118+
119+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
120+
<div className="space-y-2">
121+
<Label htmlFor="name">Group Name</Label>
122+
<Input
123+
id="name"
124+
placeholder="Enter group name"
125+
{...register("name")}
126+
/>
127+
{errors.name && (
128+
<p className="text-sm text-red-500">{errors.name.message}</p>
129+
)}
130+
</div>
131+
132+
<div className="space-y-2">
133+
<Label htmlFor="description">Description (Optional)</Label>
134+
<Textarea
135+
id="description"
136+
placeholder="Enter group description"
137+
{...register("description")}
138+
/>
139+
</div>
140+
141+
<div className="space-y-2">
142+
<Label>Members</Label>
143+
<div className="flex flex-wrap gap-2 mb-2">
144+
{/* Current user (always included) */}
145+
{currentUser && (
146+
<Badge variant="secondary" className="px-3 py-1">
147+
<Avatar className="h-5 w-5 mr-2">
148+
<AvatarImage src={currentUser.imageUrl} />
149+
<AvatarFallback>
150+
{currentUser.name?.charAt(0) || "?"}
151+
</AvatarFallback>
152+
</Avatar>
153+
<span>{currentUser.name} (You)</span>
154+
</Badge>
155+
)}
156+
157+
{/* Selected members */}
158+
{selectedMembers.map((member) => (
159+
<Badge
160+
key={member.id}
161+
variant="secondary"
162+
className="px-3 py-1"
163+
>
164+
<Avatar className="h-5 w-5 mr-2">
165+
<AvatarImage src={member.imageUrl} />
166+
<AvatarFallback>
167+
{member.name?.charAt(0) || "?"}
168+
</AvatarFallback>
169+
</Avatar>
170+
<span>{member.name}</span>
171+
<button
172+
type="button"
173+
onClick={() => removeMember(member.id)}
174+
className="ml-2 text-muted-foreground hover:text-foreground"
175+
>
176+
<X className="h-3 w-3" />
177+
</button>
178+
</Badge>
179+
))}
180+
181+
{/* Add member button with dropdown */}
182+
<Popover open={commandOpen} onOpenChange={setCommandOpen}>
183+
<PopoverTrigger asChild>
184+
<Button
185+
type="button"
186+
variant="outline"
187+
size="sm"
188+
className="h-8 gap-1 text-xs"
189+
>
190+
<UserPlus className="h-3.5 w-3.5" />
191+
Add member
192+
</Button>
193+
</PopoverTrigger>
194+
<PopoverContent className="p-0" align="start" side="bottom">
195+
<Command>
196+
<CommandInput
197+
placeholder="Search by name or email..."
198+
value={searchQuery}
199+
onValueChange={setSearchQuery}
200+
/>
201+
<CommandList>
202+
<CommandEmpty>
203+
{searchQuery.length < 2 ? (
204+
<p className="py-3 px-4 text-sm text-center text-muted-foreground">
205+
Type at least 2 characters to search
206+
</p>
207+
) : isSearching ? (
208+
<p className="py-3 px-4 text-sm text-center text-muted-foreground">
209+
Searching...
210+
</p>
211+
) : (
212+
<p className="py-3 px-4 text-sm text-center text-muted-foreground">
213+
No users found
214+
</p>
215+
)}
216+
</CommandEmpty>
217+
<CommandGroup heading="Users">
218+
{searchResults?.map((user) => (
219+
<CommandItem
220+
key={user.id}
221+
value={user.name + user.email}
222+
onSelect={() => addMember(user)}
223+
>
224+
<div className="flex items-center gap-2">
225+
<Avatar className="h-6 w-6">
226+
<AvatarImage src={user.imageUrl} />
227+
<AvatarFallback>
228+
{user.name?.charAt(0) || "?"}
229+
</AvatarFallback>
230+
</Avatar>
231+
<div className="flex flex-col">
232+
<span className="text-sm">{user.name}</span>
233+
<span className="text-xs text-muted-foreground">
234+
{user.email}
235+
</span>
236+
</div>
237+
</div>
238+
</CommandItem>
239+
))}
240+
</CommandGroup>
241+
</CommandList>
242+
</Command>
243+
</PopoverContent>
244+
</Popover>
245+
</div>
246+
{selectedMembers.length === 0 && (
247+
<p className="text-sm text-amber-600">
248+
Add at least one other person to the group
249+
</p>
250+
)}
251+
</div>
252+
253+
<DialogFooter>
254+
<Button type="button" variant="outline" onClick={handleClose}>
255+
Cancel
256+
</Button>
257+
<Button
258+
type="submit"
259+
disabled={isSubmitting || selectedMembers.length === 0}
260+
>
261+
{isSubmitting ? "Creating..." : "Create Group"}
262+
</Button>
263+
</DialogFooter>
264+
</form>
265+
</DialogContent>
266+
</Dialog>
267+
);
268+
}

0 commit comments

Comments
 (0)