aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/groups/[groupId]/expenses/active-user-modal.tsx134
-rw-r--r--src/app/groups/[groupId]/expenses/expense-list.tsx14
-rw-r--r--src/app/groups/[groupId]/expenses/page.tsx80
-rw-r--r--src/components/ui/dialog.tsx122
-rw-r--r--src/components/ui/drawer.tsx118
-rw-r--r--src/components/ui/radio-group.tsx44
-rw-r--r--src/lib/hooks.ts42
7 files changed, 513 insertions, 41 deletions
diff --git a/src/app/groups/[groupId]/expenses/active-user-modal.tsx b/src/app/groups/[groupId]/expenses/active-user-modal.tsx
new file mode 100644
index 0000000..5d9d005
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/active-user-modal.tsx
@@ -0,0 +1,134 @@
+'use client'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from '@/components/ui/drawer'
+import { Label } from '@/components/ui/label'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { getGroup } from '@/lib/api'
+import { useMediaQuery } from '@/lib/hooks'
+import { cn } from '@/lib/utils'
+import { ComponentProps, useEffect, useState } from 'react'
+
+type Props = {
+ group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
+}
+
+export function ActiveUserModal({ group }: Props) {
+ const [open, setOpen] = useState(false)
+ const isDesktop = useMediaQuery('(min-width: 768px)')
+
+ useEffect(() => {
+ const tempUser = localStorage.getItem(`newGroup-activeUser`)
+ const activeUser = localStorage.getItem(`${group.id}-activeUser`)
+ if (!tempUser && !activeUser) {
+ setOpen(true)
+ }
+ }, [group])
+
+ function updateOpen(open: boolean) {
+ if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
+ localStorage.setItem(`${group.id}-activeUser`, 'None')
+ }
+ setOpen(open)
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog open={open} onOpenChange={updateOpen}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>Who are you?</DialogTitle>
+ <DialogDescription>
+ Tell us which participant you are to let us customize how the
+ information is displayed.
+ </DialogDescription>
+ </DialogHeader>
+ <ActiveUserForm group={group} close={() => setOpen(false)} />
+ <DialogFooter className="sm:justify-center">
+ <p className="text-sm text-center text-muted-foreground">
+ This setting can be changed later in the group settings.
+ </p>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={updateOpen}>
+ <DrawerContent>
+ <DrawerHeader className="text-left">
+ <DrawerTitle>Who are you?</DrawerTitle>
+ <DrawerDescription>
+ Tell us which participant you are to let us customize how the
+ information is displayed.
+ </DrawerDescription>
+ </DrawerHeader>
+ <ActiveUserForm
+ className="px-4"
+ group={group}
+ close={() => setOpen(false)}
+ />
+ <DrawerFooter className="pt-2">
+ <p className="text-sm text-center text-muted-foreground">
+ This setting can be changed later in the group settings.
+ </p>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
+
+function ActiveUserForm({
+ group,
+ close,
+ className,
+}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
+ const [selected, setSelected] = useState('None')
+
+ return (
+ <form
+ className={cn('grid items-start gap-4', className)}
+ onSubmit={(event) => {
+ event.preventDefault()
+ localStorage.setItem(`${group.id}-activeUser`, selected)
+ close()
+ }}
+ >
+ <RadioGroup defaultValue="none" onValueChange={setSelected}>
+ <div className="flex flex-col gap-4 my-4">
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="none" id="none" />
+ <Label htmlFor="none" className="italic font-normal flex-1">
+ I don’t want to select anyone
+ </Label>
+ </div>
+ {group.participants.map((participant) => (
+ <div key={participant.id} className="flex items-center space-x-2">
+ <RadioGroupItem value={participant.id} id={participant.id} />
+ <Label htmlFor={participant.id} className="flex-1">
+ {participant.name}
+ </Label>
+ </div>
+ ))}
+ </div>
+ </RadioGroup>
+ <Button type="submit">Save changes</Button>
+ </form>
+ )
+}
diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx
index ef4903c..4578300 100644
--- a/src/app/groups/[groupId]/expenses/expense-list.tsx
+++ b/src/app/groups/[groupId]/expenses/expense-list.tsx
@@ -27,11 +27,15 @@ export function ExpenseList({
if (activeUser || newUser) {
localStorage.removeItem('newGroup-activeUser')
localStorage.removeItem(`${groupId}-newUser`)
- const userId = participants.find(
- (p) => p.name === (activeUser || newUser),
- )?.id
- if (userId) {
- localStorage.setItem(`${groupId}-activeUser`, userId)
+ if (activeUser === 'None') {
+ localStorage.setItem(`${groupId}-activeUser`, 'None')
+ } else {
+ const userId = participants.find(
+ (p) => p.name === (activeUser || newUser),
+ )?.id
+ if (userId) {
+ localStorage.setItem(`${groupId}-activeUser`, userId)
+ }
}
}
}, [groupId, participants])
diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx
index 5285cb8..2a1ac5f 100644
--- a/src/app/groups/[groupId]/expenses/page.tsx
+++ b/src/app/groups/[groupId]/expenses/page.tsx
@@ -1,3 +1,4 @@
+import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
@@ -24,45 +25,52 @@ export default async function GroupExpensesPage({
}: {
params: { groupId: string }
}) {
+ const group = await getGroup(groupId)
+ if (!group) notFound()
+
return (
- <Card className="mb-4">
- <div className="flex flex-1">
- <CardHeader className="flex-1">
- <CardTitle>Expenses</CardTitle>
- <CardDescription>
- Here are the expenses that you created for your group.
- </CardDescription>
- </CardHeader>
- <CardHeader>
- <Button asChild size="icon">
- <Link href={`/groups/${groupId}/expenses/create`}>
- <Plus />
- </Link>
- </Button>
- </CardHeader>
- </div>
+ <>
+ <Card className="mb-4">
+ <div className="flex flex-1">
+ <CardHeader className="flex-1">
+ <CardTitle>Expenses</CardTitle>
+ <CardDescription>
+ Here are the expenses that you created for your group.
+ </CardDescription>
+ </CardHeader>
+ <CardHeader>
+ <Button asChild size="icon">
+ <Link href={`/groups/${groupId}/expenses/create`}>
+ <Plus />
+ </Link>
+ </Button>
+ </CardHeader>
+ </div>
- <CardContent className="p-0">
- <Suspense
- fallback={[0, 1, 2].map((i) => (
- <div
- key={i}
- className="border-t flex justify-between items-center px-6 py-4 text-sm"
- >
- <div className="flex flex-col gap-2">
- <Skeleton className="h-4 w-16 rounded-full" />
- <Skeleton className="h-4 w-32 rounded-full" />
+ <CardContent className="p-0">
+ <Suspense
+ fallback={[0, 1, 2].map((i) => (
+ <div
+ key={i}
+ className="border-t flex justify-between items-center px-6 py-4 text-sm"
+ >
+ <div className="flex flex-col gap-2">
+ <Skeleton className="h-4 w-16 rounded-full" />
+ <Skeleton className="h-4 w-32 rounded-full" />
+ </div>
+ <div>
+ <Skeleton className="h-4 w-16 rounded-full" />
+ </div>
</div>
- <div>
- <Skeleton className="h-4 w-16 rounded-full" />
- </div>
- </div>
- ))}
- >
- <Expenses groupId={groupId} />
- </Suspense>
- </CardContent>
- </Card>
+ ))}
+ >
+ <Expenses groupId={groupId} />
+ </Suspense>
+ </CardContent>
+ </Card>
+
+ <ActiveUserModal group={group} />
+ </>
)
}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..01ff19c
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ className
+ )}
+ {...props}
+ />
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+ <X className="h-4 w-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className
+ )}
+ {...props}
+ />
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className
+ )}
+ {...props}
+ />
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..6a0ef53
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,118 @@
+"use client"
+
+import * as React from "react"
+import { Drawer as DrawerPrimitive } from "vaul"
+
+import { cn } from "@/lib/utils"
+
+const Drawer = ({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
+ <DrawerPrimitive.Root
+ shouldScaleBackground={shouldScaleBackground}
+ {...props}
+ />
+)
+Drawer.displayName = "Drawer"
+
+const DrawerTrigger = DrawerPrimitive.Trigger
+
+const DrawerPortal = DrawerPrimitive.Portal
+
+const DrawerClose = DrawerPrimitive.Close
+
+const DrawerOverlay = React.forwardRef<
+ React.ElementRef<typeof DrawerPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DrawerPrimitive.Overlay
+ ref={ref}
+ className={cn("fixed inset-0 z-50 bg-black/80", className)}
+ {...props}
+ />
+))
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
+
+const DrawerContent = React.forwardRef<
+ React.ElementRef<typeof DrawerPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DrawerPortal>
+ <DrawerOverlay />
+ <DrawerPrimitive.Content
+ ref={ref}
+ className={cn(
+ "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
+ className
+ )}
+ {...props}
+ >
+ <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
+ {children}
+ </DrawerPrimitive.Content>
+ </DrawerPortal>
+))
+DrawerContent.displayName = "DrawerContent"
+
+const DrawerHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
+ {...props}
+ />
+)
+DrawerHeader.displayName = "DrawerHeader"
+
+const DrawerFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+ {...props}
+ />
+)
+DrawerFooter.displayName = "DrawerFooter"
+
+const DrawerTitle = React.forwardRef<
+ React.ElementRef<typeof DrawerPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DrawerPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName
+
+const DrawerDescription = React.forwardRef<
+ React.ElementRef<typeof DrawerPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DrawerPrimitive.Description
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+}
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..e9bde17
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => {
+ return (
+ <RadioGroupPrimitive.Root
+ className={cn("grid gap-2", className)}
+ {...props}
+ ref={ref}
+ />
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => {
+ return (
+ <RadioGroupPrimitive.Item
+ ref={ref}
+ className={cn(
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+ <Circle className="h-2.5 w-2.5 fill-current text-current" />
+ </RadioGroupPrimitive.Indicator>
+ </RadioGroupPrimitive.Item>
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts
new file mode 100644
index 0000000..02e2543
--- /dev/null
+++ b/src/lib/hooks.ts
@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react'
+
+export function useMediaQuery(query: string): boolean {
+ const getMatches = (query: string): boolean => {
+ // Prevents SSR issues
+ if (typeof window !== 'undefined') {
+ return window.matchMedia(query).matches
+ }
+ return false
+ }
+
+ const [matches, setMatches] = useState<boolean>(getMatches(query))
+
+ function handleChange() {
+ setMatches(getMatches(query))
+ }
+
+ useEffect(() => {
+ const matchMedia = window.matchMedia(query)
+
+ // Triggered at the first client-side load and if query changes
+ handleChange()
+
+ // Listen matchMedia
+ if (matchMedia.addListener) {
+ matchMedia.addListener(handleChange)
+ } else {
+ matchMedia.addEventListener('change', handleChange)
+ }
+
+ return () => {
+ if (matchMedia.removeListener) {
+ matchMedia.removeListener(handleChange)
+ } else {
+ matchMedia.removeEventListener('change', handleChange)
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [query])
+
+ return matches
+}