this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: voucher

+290 -179
+149 -125
src/app/[id]/page.tsx
··· 1 1 "use client"; 2 2 3 3 import { 4 - Card, 5 - CardAction, 6 - CardContent, 7 - CardFooter, 8 - CardHeader, 9 - CardTitle, 10 - } from "@/components/ui/card"; 11 - import { Avatar, AvatarImage } from "@/components/ui/avatar"; 12 - import { Button } from "@/components/ui/button"; 13 - import { 14 4 ArrowLeft01Icon, 15 5 Copy01Icon, 16 6 Delete01Icon, ··· 21 11 } from "@hugeicons/core-free-icons"; 22 12 import { HugeiconsIcon } from "@hugeicons/react"; 23 13 import Link from "next/link"; 14 + import { use, useEffect, useState } from "react"; 15 + import { Avatar, AvatarImage } from "@/components/ui/avatar"; 16 + import { Button } from "@/components/ui/button"; 17 + import { 18 + Card, 19 + CardAction, 20 + CardContent, 21 + CardFooter, 22 + CardHeader, 23 + CardTitle, 24 + } from "@/components/ui/card"; 24 25 import { 25 26 Dialog, 27 + DialogClose, 26 28 DialogContent, 27 29 DialogHeader, 28 30 DialogTitle, 29 31 DialogTrigger, 30 32 } from "@/components/ui/dialog"; 33 + import { 34 + Field, 35 + FieldDescription, 36 + FieldGroup, 37 + FieldLabel, 38 + } from "@/components/ui/field"; 39 + import { Input } from "@/components/ui/input"; 40 + import { 41 + InputGroup, 42 + InputGroupAddon, 43 + InputGroupButton, 44 + InputGroupInput, 45 + } from "@/components/ui/input-group"; 31 46 import { 32 47 Item, 33 48 ItemActions, ··· 37 52 ItemTitle, 38 53 } from "@/components/ui/item"; 39 54 import { Progress } from "@/components/ui/progress"; 40 - import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; 41 - import { Input } from "@/components/ui/input"; 42 55 import { Slider } from "@/components/ui/slider"; 56 + import type { group, voucher } from "@/db/schema"; 57 + import { authClient } from "@/lib/auth-client"; 58 + import { getGroup } from "@/lib/group"; 43 59 import { 44 - InputGroup, 45 - InputGroupAddon, 46 - InputGroupButton, 47 - InputGroupInput, 48 - } from "@/components/ui/input-group"; 60 + createVoucher, 61 + deleteVoucher, 62 + getVouchers, 63 + updateVoucher, 64 + } from "@/lib/voucher"; 65 + 66 + export type Group = typeof group.$inferSelect; 67 + export type Voucher = typeof voucher.$inferSelect; 68 + 69 + export default function Page({ params }: { params: Promise<{ id: number }> }) { 70 + const { data: session } = authClient.useSession(); 71 + const user = session?.user; 72 + 73 + const { id } = use(params); 74 + const [group, setGroup] = useState<Group | null>(null); 75 + const [vouchers, setVouchers] = useState<Voucher[]>([]); 76 + 77 + useEffect(() => { 78 + getGroup(id).then(async (group) => { 79 + if (!group) return; 80 + setGroup(group); 81 + setVouchers(await getVouchers(group.id)); 82 + }); 83 + }, [id]); 84 + 85 + if (!user || !group) return null; 49 86 50 - export default function Page() { 51 87 const me = 1; 52 88 53 89 const data = { ··· 68 104 }, 69 105 ], 70 106 }; 71 - 72 - const vouchers = [ 73 - { 74 - id: 1, 75 - emoji: "🍕", 76 - name: "pizza Night", 77 - limit: 5, 78 - used: 2, 79 - }, 80 - ]; 81 107 82 108 return ( 83 109 <> ··· 166 192 </Dialog> 167 193 </div> 168 194 </header> 169 - <Card className="ring-0"> 195 + <Card> 170 196 <CardHeader> 171 - <CardTitle> 172 - {data.emoji} {data.title} 173 - </CardTitle> 197 + <CardTitle>{group.name}</CardTitle> 174 198 <CardAction> 175 199 <Dialog> 176 200 <DialogTrigger ··· 209 233 ))} 210 234 </DialogContent> 211 235 </Dialog> 212 - <Dialog> 213 - <DialogTrigger 214 - render={ 215 - <Button variant="outline"> 216 - <HugeiconsIcon icon={PlusSignIcon} /> 217 - </Button> 218 - } 219 - /> 220 - <DialogContent> 221 - <DialogHeader> 222 - <DialogTitle>New Voucher</DialogTitle> 223 - </DialogHeader> 224 - <form> 225 - <FieldGroup> 226 - <div className="flex gap-4"> 227 - <Field> 228 - <FieldLabel htmlFor="form-emoji">Emoji</FieldLabel> 229 - <Input 230 - id="form-emoji" 231 - placeholder="Enter the emoji" 232 - required 233 - /> 234 - </Field> 235 - <Field> 236 - <FieldLabel htmlFor="form-name">Name</FieldLabel> 237 - <Input 238 - id="form-name" 239 - placeholder="Enter the name" 240 - required 241 - /> 242 - </Field> 243 - </div> 244 - <Field> 245 - <FieldLabel htmlFor="form-limit">Limit</FieldLabel> 246 - <Slider 247 - id="form-limit" 248 - defaultValue={[1]} 249 - min={1} 250 - max={100} 251 - step={1} 252 - /> 253 - </Field> 254 - <Field orientation="horizontal"> 255 - <Button type="submit" className="flex-1"> 256 - Create 257 - </Button> 258 - </Field> 259 - </FieldGroup> 260 - </form> 261 - </DialogContent> 262 - </Dialog> 263 236 </CardAction> 264 237 </CardHeader> 265 238 <CardContent className="grid gap-6"> 266 239 {vouchers.map((voucher) => ( 267 240 <Card key={voucher.id}> 268 241 <CardHeader> 269 - <Button variant="secondary" size="icon"> 270 - {voucher.emoji} 271 - </Button> 272 242 <CardTitle>{voucher.name}</CardTitle> 273 243 <CardAction> 274 - <Button> 275 - {voucher.used}/{voucher.limit} 276 - </Button> 244 + <Button>{voucher.limit}</Button> 277 245 </CardAction> 278 246 </CardHeader> 279 247 <CardContent> 280 - <Progress value={(voucher.used / voucher.limit) * 100} /> 248 + <Progress value={(1 / voucher.limit) * 100} /> 281 249 </CardContent> 282 250 <CardFooter> 283 251 {(me === data.host && ( ··· 286 254 render={<Button className="w-full">Edit</Button>} 287 255 /> 288 256 <DialogContent> 289 - <DialogHeader> 290 - <DialogTitle>Edit Voucher</DialogTitle> 291 - </DialogHeader> 292 - <form> 257 + <form 258 + onSubmit={(e) => { 259 + e.preventDefault(); 260 + const formData = new FormData(e.currentTarget); 261 + const name = formData.get("name") as string; 262 + const limit = formData.get("limit") as string; 263 + updateVoucher(voucher.id, name, Number(limit)).then( 264 + (res) => { 265 + if (!res) return; 266 + setVouchers((prevVouchers) => 267 + prevVouchers.map((v) => 268 + v.id === voucher.id ? res : v, 269 + ), 270 + ); 271 + }, 272 + ); 273 + }} 274 + > 293 275 <FieldGroup> 294 - <div className="flex gap-4"> 295 - <Field> 296 - <FieldLabel htmlFor="form-emoji"> 297 - Emoji 298 - </FieldLabel> 299 - <Input 300 - id="form-emoji" 301 - placeholder="Enter the emoji" 302 - required 303 - /> 304 - </Field> 305 - <Field> 306 - <FieldLabel htmlFor="form-name">Name</FieldLabel> 307 - <Input 308 - id="form-name" 309 - placeholder="Enter the name" 310 - required 311 - /> 312 - </Field> 313 - </div> 276 + <Field> 277 + <FieldLabel htmlFor="name">Name</FieldLabel> 278 + <Input 279 + id="name" 280 + name="name" 281 + defaultValue={voucher.name} 282 + required 283 + /> 284 + </Field> 314 285 <Field> 315 - <FieldLabel htmlFor="form-limit">Limit</FieldLabel> 286 + <FieldLabel htmlFor="limit">Limit</FieldLabel> 316 287 <Slider 317 - id="form-limit" 288 + id="limit" 289 + name="limit" 318 290 defaultValue={[voucher.limit]} 319 - min={1} 320 - max={100} 321 - step={1} 291 + max={20} 322 292 /> 323 293 </Field> 324 294 <Field orientation="horizontal"> 325 - <Button variant="outline" size="icon"> 326 - <HugeiconsIcon icon={Delete01Icon} /> 327 - </Button> 328 - <Button type="submit" className="flex-1"> 329 - Create 330 - </Button> 295 + <DialogClose 296 + render={ 297 + <Button 298 + variant="destructive" 299 + onClick={(e) => { 300 + e.preventDefault(); 301 + deleteVoucher(voucher.id).then((res) => { 302 + if (!res) return; 303 + setVouchers((prevVouchers) => 304 + prevVouchers.filter( 305 + (v) => v.id !== voucher.id, 306 + ), 307 + ); 308 + }); 309 + }} 310 + > 311 + <HugeiconsIcon icon={Delete01Icon} /> 312 + </Button> 313 + } 314 + /> 315 + <DialogClose 316 + render={ 317 + <Button type="submit" className="flex-1"> 318 + Update 319 + </Button> 320 + } 321 + /> 331 322 </Field> 332 323 </FieldGroup> 333 324 </form> ··· 337 328 </CardFooter> 338 329 </Card> 339 330 ))} 331 + <Dialog> 332 + <DialogTrigger render={<Button>Create Voucher</Button>} /> 333 + <DialogContent> 334 + <form 335 + onSubmit={(e) => { 336 + e.preventDefault(); 337 + const formData = new FormData(e.currentTarget); 338 + const name = formData.get("name") as string; 339 + const limit = formData.get("limit") as string; 340 + createVoucher(name, Number(limit), group.id).then((res) => { 341 + if (!res) return; 342 + setVouchers((prevVouchers) => [...prevVouchers, res]); 343 + }); 344 + }} 345 + > 346 + <FieldGroup> 347 + <Field> 348 + <FieldLabel htmlFor="name">Name</FieldLabel> 349 + <Input id="name" name="name" required /> 350 + </Field> 351 + <Field> 352 + <FieldLabel htmlFor="limit">Limit</FieldLabel> 353 + <Slider id="limit" name="limit" max={20} /> 354 + </Field> 355 + <Field> 356 + <DialogClose 357 + render={<Button type="submit">Create</Button>} 358 + /> 359 + </Field> 360 + </FieldGroup> 361 + </form> 362 + </DialogContent> 363 + </Dialog> 340 364 </CardContent> 341 365 </Card> 342 366 </>
+2 -2
src/app/layout.tsx
··· 6 6 const figtree = Figtree({ subsets: ["latin"], variable: "--font-sans" }); 7 7 8 8 export const metadata: Metadata = { 9 - title: "Create Next App", 10 - description: "Generated by create next app", 9 + title: "Vouch", 10 + description: "Share your promises and keep each other accountable", 11 11 }; 12 12 13 13 export default function RootLayout({
+32 -38
src/app/page.tsx
··· 36 36 const [groups, setGroups] = useState<Group[]>([]); 37 37 38 38 useEffect(() => { 39 - const id = user?.id; 40 - if (!id) return; 41 - getGroups(id).then(setGroups); 42 - }, [user]); 39 + getGroups().then(setGroups); 40 + }, []); 43 41 44 42 if (!user) return null; 45 43 ··· 95 93 required 96 94 /> 97 95 </Field> 98 - <div className="flex justify-end gap-2"> 99 - <Field className="flex-1"> 100 - <DialogClose 101 - render={ 102 - <Button 103 - variant="destructive" 104 - onClick={() => { 105 - deleteGroup(group.id).then((res) => { 106 - if (!res) return; 107 - setGroups((prevGroups) => 108 - prevGroups.filter( 109 - (g) => g.id !== group.id, 110 - ), 111 - ); 112 - }); 113 - }} 114 - > 115 - <HugeiconsIcon icon={Delete01Icon} /> 116 - </Button> 117 - } 118 - /> 119 - </Field> 120 - <Field> 121 - <DialogClose 122 - render={<Button type="submit">Update</Button>} 123 - /> 124 - </Field> 125 - </div> 96 + <Field orientation="horizontal"> 97 + <DialogClose 98 + render={ 99 + <Button 100 + variant="destructive" 101 + onClick={(e) => { 102 + e.preventDefault(); 103 + deleteGroup(group.id).then((res) => { 104 + if (!res) return; 105 + setGroups((prevGroups) => 106 + prevGroups.filter( 107 + (g) => g.id !== group.id, 108 + ), 109 + ); 110 + }); 111 + }} 112 + > 113 + <HugeiconsIcon icon={Delete01Icon} /> 114 + </Button> 115 + } 116 + /> 117 + <DialogClose 118 + render={ 119 + <Button type="submit" className="flex-1"> 120 + Update 121 + </Button> 122 + } 123 + /> 124 + </Field> 126 125 </FieldGroup> 127 126 </form> 128 127 </DialogContent> ··· 149 148 <FieldGroup> 150 149 <Field> 151 150 <FieldLabel htmlFor="name">Name</FieldLabel> 152 - <Input 153 - id="name" 154 - name="name" 155 - placeholder="Something cool" 156 - required 157 - /> 151 + <Input id="name" name="name" required /> 158 152 </Field> 159 153 <Field> 160 154 <DialogClose render={<Button type="submit">Create</Button>} />
+10 -1
src/db/schema.ts
··· 1 1 import { integer, pgTable, text, varchar } from "drizzle-orm/pg-core"; 2 2 import { user } from "./auth-schema"; 3 3 4 + export * from "./auth-schema"; 5 + 4 6 export const group = pgTable("group", { 5 7 id: integer().primaryKey().generatedAlwaysAsIdentity(), 6 8 userId: text() ··· 9 11 name: varchar().notNull(), 10 12 }); 11 13 12 - export * from "./auth-schema"; 14 + export const voucher = pgTable("voucher", { 15 + id: integer().primaryKey().generatedAlwaysAsIdentity(), 16 + groupId: integer() 17 + .notNull() 18 + .references(() => group.id), 19 + name: varchar().notNull(), 20 + limit: integer().notNull(), 21 + });
+29 -13
src/lib/group.ts
··· 6 6 import { getUserId } from "@/lib/auth"; 7 7 import { db } from "@/lib/db"; 8 8 9 - export async function getGroups(id: string) { 10 - return await db.query.group.findMany({ 11 - where: eq(group.userId, id), 12 - }); 13 - } 14 - 15 9 export async function createGroup(name: string) { 16 10 const userId = await getUserId(); 17 11 if (!userId) return null; 12 + 18 13 const [res] = await db.insert(group).values({ name, userId }).returning(); 19 14 return res; 20 15 } 21 16 22 - export async function deleteGroup(id: number) { 17 + export async function getGroups() { 18 + const userId = await getUserId(); 19 + if (!userId) return []; 20 + 21 + return await db.select().from(group).where(eq(group.userId, userId)); 22 + } 23 + 24 + export async function getGroup(id: number) { 25 + const groups = await getGroups(); 26 + // biome-ignore lint/suspicious/noDoubleEquals: <i need to compare number and string> 27 + return groups.find((g) => g.id == id) || null; 28 + } 29 + 30 + export async function isGroupOwner(id: number) { 23 31 const userId = await getUserId(); 24 32 if (!userId) return false; 25 - const res = await db 26 - .delete(group) 27 - .where(and(eq(group.id, id), eq(group.userId, userId))); 33 + 34 + const groupData = await getGroup(id); 35 + if (!groupData) return false; 36 + 37 + return groupData.userId === userId; 38 + } 39 + 40 + export async function deleteGroup(id: number) { 41 + if (!(await isGroupOwner(id))) return false; 42 + const res = await db.delete(group).where(and(eq(group.id, id))); 28 43 return res.rowCount > 0; 29 44 } 30 45 31 46 export async function updateGroup(id: number, name: string) { 32 - const userId = await getUserId(); 33 - if (!userId) return null; 47 + if (!(await isGroupOwner(id))) return null; 48 + 34 49 const [res] = await db 35 50 .update(group) 36 51 .set({ name }) 37 - .where(and(eq(group.id, id), eq(group.userId, userId))) 52 + .where(and(eq(group.id, id))) 38 53 .returning(); 54 + 39 55 return res; 40 56 }
+68
src/lib/voucher.ts
··· 1 + "use server"; 2 + 3 + import { and, eq, inArray } from "drizzle-orm"; 4 + import { voucher } from "@/db/schema"; 5 + import { db } from "@/lib/db"; 6 + import { getGroups, isGroupOwner } from "@/lib/group"; 7 + 8 + export async function createVoucher( 9 + name: string, 10 + limit: number, 11 + groupId: number, 12 + ) { 13 + if (!(await isGroupOwner(groupId))) return null; 14 + 15 + const [res] = await db 16 + .insert(voucher) 17 + .values({ name, limit, groupId }) 18 + .returning(); 19 + 20 + return res; 21 + } 22 + 23 + async function getAllVouchers() { 24 + const groups = await getGroups(); 25 + const groupIds = groups.map((g) => g.id); 26 + 27 + const res = await db 28 + .select() 29 + .from(voucher) 30 + .where(inArray(voucher.groupId, groupIds)); 31 + 32 + return res; 33 + } 34 + 35 + export async function getVouchers(groupId: number) { 36 + const vouchers = await getAllVouchers(); 37 + return vouchers.filter((v) => v.groupId === groupId); 38 + } 39 + 40 + export async function getVoucher(id: number) { 41 + const vouchers = await getAllVouchers(); 42 + return vouchers.find((v) => v.id === id) || null; 43 + } 44 + 45 + async function isVoucherOwner(id: number) { 46 + const voucherData = await getVoucher(id); 47 + if (!voucherData) return false; 48 + 49 + return await isGroupOwner(voucherData.groupId); 50 + } 51 + 52 + export async function deleteVoucher(id: number) { 53 + if (!(await isVoucherOwner(id))) return false; 54 + const res = await db.delete(voucher).where(and(eq(voucher.id, id))); 55 + return res.rowCount > 0; 56 + } 57 + 58 + export async function updateVoucher(id: number, name: string, limit: number) { 59 + if (!(await isVoucherOwner(id))) return null; 60 + 61 + const [res] = await db 62 + .update(voucher) 63 + .set({ name, limit }) 64 + .where(and(eq(voucher.id, id))) 65 + .returning(); 66 + 67 + return res; 68 + }