this repo has no description
0
fork

Configure Feed

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

feat: activity tab

+340 -193
+251 -183
src/components/group.tsx
··· 7 7 Delete01Icon, 8 8 Edit01Icon, 9 9 Ticket02Icon, 10 + TicketStarIcon, 11 + TransactionHistoryIcon, 10 12 UserMultipleIcon, 11 13 } from "@hugeicons/core-free-icons"; 12 14 import { HugeiconsIcon } from "@hugeicons/react"; 15 + import type { User } from "better-auth"; 13 16 import Link from "next/link"; 14 17 import { useState } from "react"; 15 18 import CustomAvatar from "@/components/avatar"; ··· 55 58 ItemMedia, 56 59 ItemTitle, 57 60 } from "@/components/ui/item"; 61 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 58 62 import type { voucher } from "@/db/schema"; 59 63 import type { GroupWithMembers } from "@/lib/group"; 60 64 import { deleteMember } from "@/lib/member"; ··· 66 70 } from "@/lib/voucher"; 67 71 68 72 type Voucher = typeof voucher.$inferSelect; 69 - type User = { 70 - id: string; 71 - name: string; 72 - image: string; 73 - email: string; 74 - }; 75 73 76 74 export function Group({ 77 75 user, ··· 83 81 vouchers: Voucher[]; 84 82 }) { 85 83 const [vouchers, setVouchers] = useState(initialVouchers); 84 + 86 85 const [users, setUsers] = useState([ 87 86 group.owner, 88 87 ...group.members.map((m) => m.user), 89 88 ]); 90 89 90 + const [redemptions, setRedemptions] = useState( 91 + group.members 92 + .flatMap((member) => 93 + member.redemptions.map((redemption) => ({ 94 + ...redemption, 95 + user: member.user, 96 + })), 97 + ) 98 + .sort((a, b) => b.date.getTime() - a.date.getTime()), 99 + ); 100 + 91 101 return ( 92 102 <Card> 93 103 <CardHeader> ··· 177 187 </CardAction> 178 188 </CardHeader> 179 189 <CardContent> 180 - <ItemGroup> 181 - {vouchers.map((voucher) => ( 182 - <Item key={voucher.id}> 183 - <ItemContent> 184 - <ItemTitle>{voucher.name}</ItemTitle> 185 - <ItemDescription>{voucher.description}</ItemDescription> 186 - </ItemContent> 187 - <ItemActions> 188 - {(group.owner.id === user.id && ( 189 - <Dialog> 190 - <DialogTrigger 191 - render={ 192 - <Button> 193 - <HugeiconsIcon icon={Edit01Icon} /> 194 - Edit 195 - </Button> 196 - } 197 - /> 198 - <DialogContent> 199 - <form 200 - onSubmit={(e) => { 201 - e.preventDefault(); 202 - const formData = new FormData(e.currentTarget); 203 - const name = formData.get("name") as string; 204 - const description = formData.get( 205 - "description", 206 - ) as string; 207 - updateVoucher(voucher.id, name, description).then( 208 - (res) => { 209 - if (!res) return; 210 - setVouchers((prevVouchers) => 211 - prevVouchers.map((v) => 212 - v.id === voucher.id ? res : v, 213 - ), 214 - ); 215 - }, 216 - ); 217 - }} 218 - > 219 - <FieldSet> 220 - <FieldLegend>Edit the voucher</FieldLegend> 221 - <FieldDescription> 222 - Are you happy now ? 223 - </FieldDescription> 224 - <FieldGroup> 225 - <Field> 226 - <FieldLabel htmlFor="name">Name</FieldLabel> 227 - <Input 228 - id="name" 229 - name="name" 230 - defaultValue={voucher.name} 231 - required 232 - /> 233 - </Field> 234 - <Field> 235 - <FieldLabel htmlFor="description"> 236 - Description 237 - </FieldLabel> 238 - <Input 239 - id="description" 240 - name="description" 241 - defaultValue={voucher.description || ""} 242 - required 243 - /> 244 - </Field> 245 - <Field> 246 - <ButtonGroup> 247 - <DialogClose 248 - render={ 249 - <Button 250 - variant="destructive" 251 - onClick={(e) => { 252 - e.preventDefault(); 253 - deleteVoucher(voucher.id).then( 254 - (res) => { 255 - if (!res) return; 256 - setVouchers((prevVouchers) => 257 - prevVouchers.filter( 258 - (v) => v.id !== voucher.id, 259 - ), 260 - ); 261 - }, 262 - ); 263 - }} 264 - > 265 - <HugeiconsIcon icon={Delete01Icon} /> 266 - Delete 267 - </Button> 268 - } 269 - /> 270 - <DialogClose 271 - render={ 272 - <Button type="submit" className="flex-1"> 273 - <HugeiconsIcon icon={Bookmark03Icon} /> 274 - Update 275 - </Button> 276 - } 277 - /> 278 - </ButtonGroup> 279 - </Field> 280 - </FieldGroup> 281 - </FieldSet> 282 - </form> 283 - </DialogContent> 284 - </Dialog> 285 - )) || ( 286 - <Button onClick={() => redeemVoucher(voucher.id)}> 287 - <HugeiconsIcon icon={Ticket02Icon} /> 288 - Redeem 289 - </Button> 290 - )} 291 - </ItemActions> 292 - </Item> 293 - ))} 294 - {group.owner.id === user.id && ( 295 - <Dialog> 296 - <DialogTrigger 297 - render={ 298 - <Button> 299 - <HugeiconsIcon icon={Bookmark03Icon} /> 300 - Create Voucher 301 - </Button> 302 - } 303 - /> 304 - <DialogContent> 305 - <form 306 - onSubmit={(e) => { 307 - e.preventDefault(); 308 - const formData = new FormData(e.currentTarget); 309 - const name = formData.get("name") as string; 310 - const description = formData.get("description") as string; 311 - createVoucher(group.id, name, description).then((res) => { 312 - if (!res) return; 313 - setVouchers((prevVouchers) => [...prevVouchers, res]); 314 - }); 315 - }} 316 - > 317 - <FieldSet> 318 - <FieldLegend>New voucher</FieldLegend> 319 - <FieldDescription> 320 - What's the name and description of your new voucher? You 321 - can always change this later. 322 - </FieldDescription> 323 - <FieldGroup> 324 - <Field> 325 - <FieldLabel htmlFor="name">Name</FieldLabel> 326 - <Input 327 - id="name" 328 - name="name" 329 - placeholder="My voucher" 330 - required 331 - /> 332 - </Field> 333 - <Field> 334 - <FieldLabel htmlFor="description"> 335 - Description 336 - </FieldLabel> 337 - <Input 338 - id="description" 339 - name="description" 340 - placeholder="This voucher can be used to redeem 1 free coffee." 341 - required 342 - /> 343 - </Field> 344 - <Field> 345 - <DialogClose 190 + <Tabs> 191 + <TabsList className="w-full"> 192 + <TabsTrigger value="vouchers"> 193 + <HugeiconsIcon icon={TicketStarIcon} /> 194 + Vouchers 195 + </TabsTrigger> 196 + <TabsTrigger value="redemption"> 197 + <HugeiconsIcon icon={TransactionHistoryIcon} /> 198 + Redemption 199 + </TabsTrigger> 200 + </TabsList> 201 + <TabsContent value="vouchers"> 202 + <ItemGroup> 203 + {vouchers.map((voucher) => ( 204 + <Item key={voucher.id}> 205 + <ItemContent> 206 + <ItemTitle>{voucher.name}</ItemTitle> 207 + <ItemDescription>{voucher.description}</ItemDescription> 208 + </ItemContent> 209 + <ItemActions> 210 + {(group.owner.id === user.id && ( 211 + <Dialog> 212 + <DialogTrigger 346 213 render={ 347 - <Button type="submit"> 348 - <HugeiconsIcon icon={Bookmark03Icon} /> 349 - Create 214 + <Button> 215 + <HugeiconsIcon icon={Edit01Icon} /> 216 + Edit 350 217 </Button> 351 218 } 352 219 /> 353 - </Field> 354 - </FieldGroup> 355 - </FieldSet> 356 - </form> 357 - </DialogContent> 358 - </Dialog> 359 - )} 360 - </ItemGroup> 220 + <DialogContent> 221 + <form 222 + onSubmit={(e) => { 223 + e.preventDefault(); 224 + const formData = new FormData(e.currentTarget); 225 + const name = formData.get("name") as string; 226 + const description = formData.get( 227 + "description", 228 + ) as string; 229 + updateVoucher(voucher.id, name, description).then( 230 + (res) => { 231 + if (!res) return; 232 + setVouchers((prevVouchers) => 233 + prevVouchers.map((v) => 234 + v.id === voucher.id ? res : v, 235 + ), 236 + ); 237 + }, 238 + ); 239 + }} 240 + > 241 + <FieldSet> 242 + <FieldLegend>Edit the voucher</FieldLegend> 243 + <FieldDescription> 244 + Are you happy now ? 245 + </FieldDescription> 246 + <FieldGroup> 247 + <Field> 248 + <FieldLabel htmlFor="name">Name</FieldLabel> 249 + <Input 250 + id="name" 251 + name="name" 252 + defaultValue={voucher.name} 253 + required 254 + /> 255 + </Field> 256 + <Field> 257 + <FieldLabel htmlFor="description"> 258 + Description 259 + </FieldLabel> 260 + <Input 261 + id="description" 262 + name="description" 263 + defaultValue={voucher.description || ""} 264 + required 265 + /> 266 + </Field> 267 + <Field> 268 + <ButtonGroup> 269 + <DialogClose 270 + render={ 271 + <Button 272 + variant="destructive" 273 + onClick={(e) => { 274 + e.preventDefault(); 275 + deleteVoucher(voucher.id).then( 276 + (res) => { 277 + if (!res) return; 278 + setVouchers((prevVouchers) => 279 + prevVouchers.filter( 280 + (v) => v.id !== voucher.id, 281 + ), 282 + ); 283 + }, 284 + ); 285 + }} 286 + > 287 + <HugeiconsIcon icon={Delete01Icon} /> 288 + Delete 289 + </Button> 290 + } 291 + /> 292 + <DialogClose 293 + render={ 294 + <Button 295 + type="submit" 296 + className="flex-1" 297 + > 298 + <HugeiconsIcon 299 + icon={Bookmark03Icon} 300 + /> 301 + Update 302 + </Button> 303 + } 304 + /> 305 + </ButtonGroup> 306 + </Field> 307 + </FieldGroup> 308 + </FieldSet> 309 + </form> 310 + </DialogContent> 311 + </Dialog> 312 + )) || ( 313 + <Button 314 + onClick={(e) => { 315 + e.preventDefault(); 316 + redeemVoucher(voucher.id).then(async (res) => { 317 + if (!res) return; 318 + setRedemptions((prevRedemptions) => [ 319 + { 320 + ...res, 321 + user, 322 + voucher, 323 + } as (typeof prevRedemptions)[number], // just for date 324 + ...prevRedemptions, 325 + ]); 326 + }); 327 + }} 328 + > 329 + <HugeiconsIcon icon={Ticket02Icon} /> 330 + Redeem 331 + </Button> 332 + )} 333 + </ItemActions> 334 + </Item> 335 + ))} 336 + {group.owner.id === user.id && ( 337 + <Dialog> 338 + <DialogTrigger 339 + render={ 340 + <Button> 341 + <HugeiconsIcon icon={Bookmark03Icon} /> 342 + Create Voucher 343 + </Button> 344 + } 345 + /> 346 + <DialogContent> 347 + <form 348 + onSubmit={(e) => { 349 + e.preventDefault(); 350 + const formData = new FormData(e.currentTarget); 351 + const name = formData.get("name") as string; 352 + const description = formData.get( 353 + "description", 354 + ) as string; 355 + createVoucher(group.id, name, description).then( 356 + (res) => { 357 + if (!res) return; 358 + setVouchers((prevVouchers) => [ 359 + ...prevVouchers, 360 + res, 361 + ]); 362 + }, 363 + ); 364 + }} 365 + > 366 + <FieldSet> 367 + <FieldLegend>New voucher</FieldLegend> 368 + <FieldDescription> 369 + What's the name and description of your new voucher? 370 + You can always change this later. 371 + </FieldDescription> 372 + <FieldGroup> 373 + <Field> 374 + <FieldLabel htmlFor="name">Name</FieldLabel> 375 + <Input 376 + id="name" 377 + name="name" 378 + placeholder="My voucher" 379 + required 380 + /> 381 + </Field> 382 + <Field> 383 + <FieldLabel htmlFor="description"> 384 + Description 385 + </FieldLabel> 386 + <Input 387 + id="description" 388 + name="description" 389 + placeholder="This voucher can be used to redeem 1 free coffee." 390 + required 391 + /> 392 + </Field> 393 + <Field> 394 + <DialogClose 395 + render={ 396 + <Button type="submit"> 397 + <HugeiconsIcon icon={Bookmark03Icon} /> 398 + Create 399 + </Button> 400 + } 401 + /> 402 + </Field> 403 + </FieldGroup> 404 + </FieldSet> 405 + </form> 406 + </DialogContent> 407 + </Dialog> 408 + )} 409 + </ItemGroup> 410 + </TabsContent> 411 + <TabsContent value="redemption"> 412 + <ItemGroup> 413 + {redemptions.map((redemption) => ( 414 + <Item key={redemption.id}> 415 + <ItemContent> 416 + <ItemTitle> 417 + <strong>{redemption.user.name}</strong> redeemed{" "} 418 + <strong>{redemption.voucher.name}</strong> 419 + </ItemTitle> 420 + <ItemDescription> 421 + {redemption.date.toLocaleString()} 422 + </ItemDescription> 423 + </ItemContent> 424 + </Item> 425 + ))} 426 + </ItemGroup> 427 + </TabsContent> 428 + </Tabs> 361 429 </CardContent> 362 430 </Card> 363 431 );
+1 -5
src/components/groups.tsx
··· 7 7 PackageOpenIcon, 8 8 } from "@hugeicons/core-free-icons"; 9 9 import { HugeiconsIcon } from "@hugeicons/react"; 10 + import type { User } from "better-auth"; 10 11 import Link from "next/link"; 11 12 import { useState } from "react"; 12 13 import CustomAvatar from "@/components/avatar"; ··· 47 48 import { createGroup, deleteGroup, updateGroup } from "@/lib/group"; 48 49 49 50 type Group = typeof group.$inferSelect; 50 - type User = { 51 - id: string; 52 - name: string; 53 - image: string; 54 - }; 55 51 56 52 export function Groups({ 57 53 user,
+1 -4
src/components/settings.tsx
··· 6 6 Logout01Icon, 7 7 } from "@hugeicons/core-free-icons"; 8 8 import { HugeiconsIcon } from "@hugeicons/react"; 9 + import type { User } from "better-auth"; 9 10 import Link from "next/link"; 10 11 import { Button } from "@/components/ui/button"; 11 12 import { ButtonGroup } from "@/components/ui/button-group"; ··· 32 33 import { updateSetting } from "@/lib/setting"; 33 34 34 35 type Setting = typeof setting.$inferSelect; 35 - type User = { 36 - name: string; 37 - email: string; 38 - }; 39 36 40 37 export function Settings({ user, setting }: { user: User; setting: Setting }) { 41 38 const handleSave = async (e: React.SubmitEvent<HTMLFormElement>) => {
+82
src/components/ui/tabs.tsx
··· 1 + "use client"; 2 + 3 + import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; 4 + import { cva, type VariantProps } from "class-variance-authority"; 5 + 6 + import { cn } from "@/lib/utils"; 7 + 8 + function Tabs({ 9 + className, 10 + orientation = "horizontal", 11 + ...props 12 + }: TabsPrimitive.Root.Props) { 13 + return ( 14 + <TabsPrimitive.Root 15 + data-slot="tabs" 16 + data-orientation={orientation} 17 + className={cn( 18 + "gap-2 group/tabs flex data-horizontal:flex-col", 19 + className, 20 + )} 21 + {...props} 22 + /> 23 + ); 24 + } 25 + 26 + const tabsListVariants = cva( 27 + "rounded-4xl p-[3px] group-data-horizontal/tabs:h-9 group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", 28 + { 29 + variants: { 30 + variant: { 31 + default: "bg-muted", 32 + line: "gap-1 bg-transparent", 33 + }, 34 + }, 35 + defaultVariants: { 36 + variant: "default", 37 + }, 38 + }, 39 + ); 40 + 41 + function TabsList({ 42 + className, 43 + variant = "default", 44 + ...props 45 + }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) { 46 + return ( 47 + <TabsPrimitive.List 48 + data-slot="tabs-list" 49 + data-variant={variant} 50 + className={cn(tabsListVariants({ variant }), className)} 51 + {...props} 52 + /> 53 + ); 54 + } 55 + 56 + function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { 57 + return ( 58 + <TabsPrimitive.Tab 59 + data-slot="tabs-trigger" 60 + className={cn( 61 + "gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", 62 + "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", 63 + "data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground", 64 + "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", 65 + className, 66 + )} 67 + {...props} 68 + /> 69 + ); 70 + } 71 + 72 + function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { 73 + return ( 74 + <TabsPrimitive.Panel 75 + data-slot="tabs-content" 76 + className={cn("text-sm flex-1 outline-none", className)} 77 + {...props} 78 + /> 79 + ); 80 + } 81 + 82 + export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+5 -1
src/lib/group.ts
··· 44 44 members: { 45 45 with: { 46 46 user: true, 47 - redemptions: true, 47 + redemptions: { 48 + with: { 49 + voucher: true, 50 + }, 51 + }, 48 52 }, 49 53 }, 50 54 },