diff --git a/src/app/groups/[groupId]/activity/activity-list.tsx b/src/app/groups/[groupId]/activity/activity-list.tsx index 04717172..7dfe538a 100644 --- a/src/app/groups/[groupId]/activity/activity-list.tsx +++ b/src/app/groups/[groupId]/activity/activity-list.tsx @@ -9,6 +9,7 @@ import dayjs, { type Dayjs } from 'dayjs' import { useTranslations } from 'next-intl' import { forwardRef, useEffect } from 'react' import { useInView } from 'react-intersection-observer' +import { useCurrentGroup } from '../current-group-context' const PAGE_SIZE = 20 @@ -82,11 +83,9 @@ const ActivitiesLoading = forwardRef<HTMLDivElement>((_, ref) => { }) ActivitiesLoading.displayName = 'ActivitiesLoading' -export function ActivityList({ groupId }: { groupId: string }) { +export function ActivityList() { const t = useTranslations('Activity') - - const { data: groupData, isLoading: groupIsLoading } = - trpc.groups.get.useQuery({ groupId }) + const { group, groupId } = useCurrentGroup() const { data: activitiesData, @@ -105,7 +104,7 @@ export function ActivityList({ groupId }: { groupId: string }) { if (inView && hasMore && !isLoading) fetchNextPage() }, [fetchNextPage, hasMore, inView, isLoading]) - if (isLoading || !activities || !groupData) return <ActivitiesLoading /> + if (isLoading || !activities || !group) return <ActivitiesLoading /> const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) @@ -131,7 +130,7 @@ export function ActivityList({ groupId }: { groupId: string }) { {groupActivities.map((activity) => { const participant = activity.participantId !== null - ? groupData.group.participants.find( + ? group.participants.find( (p) => p.id === activity.participantId, ) : undefined diff --git a/src/app/groups/[groupId]/activity/page.client.tsx b/src/app/groups/[groupId]/activity/page.client.tsx index 4f3318cd..3090bd3c 100644 --- a/src/app/groups/[groupId]/activity/page.client.tsx +++ b/src/app/groups/[groupId]/activity/page.client.tsx @@ -13,7 +13,7 @@ export const metadata: Metadata = { title: 'Activity', } -export function ActivityPageClient({ groupId }: { groupId: string }) { +export function ActivityPageClient() { const t = useTranslations('Activity') return ( @@ -24,7 +24,7 @@ export function ActivityPageClient({ groupId }: { groupId: string }) { <CardDescription>{t('description')}</CardDescription> </CardHeader> <CardContent className="flex flex-col space-y-4"> - <ActivityList groupId={groupId} /> + <ActivityList /> </CardContent> </Card> </> diff --git a/src/app/groups/[groupId]/activity/page.tsx b/src/app/groups/[groupId]/activity/page.tsx index 43408214..e80e4983 100644 --- a/src/app/groups/[groupId]/activity/page.tsx +++ b/src/app/groups/[groupId]/activity/page.tsx @@ -5,10 +5,6 @@ export const metadata: Metadata = { title: 'Activity', } -export default async function ActivityPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - return <ActivityPageClient groupId={groupId} /> +export default async function ActivityPage() { + return <ActivityPageClient /> } diff --git a/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx b/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx index 687f8879..461cfe3f 100644 --- a/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx +++ b/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx @@ -13,15 +13,12 @@ import { Skeleton } from '@/components/ui/skeleton' import { trpc } from '@/trpc/client' import { useTranslations } from 'next-intl' import { Fragment, useEffect } from 'react' +import { match } from 'ts-pattern' +import { useCurrentGroup } from '../current-group-context' -export default function BalancesAndReimbursements({ - groupId, -}: { - groupId: string -}) { +export default function BalancesAndReimbursements() { const utils = trpc.useUtils() - const { data: groupData, isLoading: groupIsLoading } = - trpc.groups.get.useQuery({ groupId }) + const { groupId, group } = useCurrentGroup() const { data: balancesData, isLoading: balancesAreLoading } = trpc.groups.balances.list.useQuery({ groupId, @@ -34,8 +31,7 @@ export default function BalancesAndReimbursements({ utils.groups.balances.invalidate() }, [utils]) - const isLoading = - balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group + const isLoading = balancesAreLoading || !balancesData || !group return ( <> @@ -46,14 +42,12 @@ export default function BalancesAndReimbursements({ </CardHeader> <CardContent> {isLoading ? ( - <BalancesLoading - participantCount={groupData?.group.participants.length} - /> + <BalancesLoading participantCount={group?.participants.length} /> ) : ( <BalancesList balances={balancesData.balances} - participants={groupData.group.participants} - currency={groupData.group.currency} + participants={group?.participants} + currency={group?.currency} /> )} </CardContent> @@ -66,14 +60,14 @@ export default function BalancesAndReimbursements({ <CardContent> {isLoading ? ( <ReimbursementsLoading - participantCount={groupData?.group.participants.length} + participantCount={group?.participants.length} /> ) : ( <ReimbursementList reimbursements={balancesData.reimbursements} - participants={groupData.group.participants} - currency={groupData.group.currency} - groupId={groupData.group.id} + participants={group?.participants} + currency={group?.currency} + groupId={groupId} /> )} </CardContent> @@ -109,6 +103,12 @@ const BalancesLoading = ({ }: { participantCount?: number }) => { + const barWidth = (index: number) => + match(index % 3) + .with(0, () => 'w-1/3') + .with(1, () => 'w-2/3') + .otherwise(() => 'w-full') + return ( <div className="grid grid-cols-2 py-1 gap-y-2"> {Array(participantCount) @@ -120,17 +120,13 @@ const BalancesLoading = ({ <Skeleton className="h-3 w-16" /> </div> <div className="self-start"> - <Skeleton - className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`} - /> + <Skeleton className={`h-7 ${barWidth(index)} rounded-l-none`} /> </div> </Fragment> ) : ( <Fragment key={index}> <div className="flex items-center justify-end"> - <Skeleton - className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`} - /> + <Skeleton className={`h-7 ${barWidth(index)} rounded-r-none`} /> </div> <div className="flex items-center pl-2"> <Skeleton className="h-3 w-16" /> diff --git a/src/app/groups/[groupId]/balances/page.tsx b/src/app/groups/[groupId]/balances/page.tsx index a9e7c813..456f40b2 100644 --- a/src/app/groups/[groupId]/balances/page.tsx +++ b/src/app/groups/[groupId]/balances/page.tsx @@ -1,19 +1,10 @@ -import { cached } from '@/app/cached-functions' import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements' import { Metadata } from 'next' -import { notFound } from 'next/navigation' export const metadata: Metadata = { title: 'Balances', } -export default async function GroupPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - const group = await cached.getGroup(groupId) - if (!group) notFound() - - return <BalancesAndReimbursements groupId={groupId} /> +export default async function GroupPage() { + return <BalancesAndReimbursements /> } diff --git a/src/app/groups/[groupId]/current-group-context.tsx b/src/app/groups/[groupId]/current-group-context.tsx new file mode 100644 index 00000000..b2a47991 --- /dev/null +++ b/src/app/groups/[groupId]/current-group-context.tsx @@ -0,0 +1,30 @@ +import { AppRouterOutput } from '@/trpc/routers/_app' +import { PropsWithChildren, createContext, useContext } from 'react' + +type Group = NonNullable<AppRouterOutput['groups']['get']['group']> + +type GroupContext = + | { isLoading: false; groupId: string; group: Group } + | { isLoading: true; groupId: string; group: undefined } + +const CurrentGroupContext = createContext<GroupContext | null>(null) + +export const useCurrentGroup = () => { + const context = useContext(CurrentGroupContext) + if (!context) + throw new Error( + 'Missing context. Should be called inside a CurrentGroupProvider.', + ) + return context +} + +export const CurrentGroupProvider = ({ + children, + ...props +}: PropsWithChildren<GroupContext>) => { + return ( + <CurrentGroupContext.Provider value={props}> + {children} + </CurrentGroupContext.Provider> + ) +} diff --git a/src/app/groups/[groupId]/edit/edit-group.tsx b/src/app/groups/[groupId]/edit/edit-group.tsx index 52cb4a1b..9189c895 100644 --- a/src/app/groups/[groupId]/edit/edit-group.tsx +++ b/src/app/groups/[groupId]/edit/edit-group.tsx @@ -2,9 +2,11 @@ import { GroupForm } from '@/components/group-form' import { trpc } from '@/trpc/client' +import { useCurrentGroup } from '../current-group-context' -export const EditGroup = ({ groupId }: { groupId: string }) => { - const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) +export const EditGroup = () => { + const { groupId } = useCurrentGroup() + const { data, isLoading } = trpc.groups.getDetails.useQuery({ groupId }) const { mutateAsync } = trpc.groups.update.useMutation() const utils = trpc.useUtils() diff --git a/src/app/groups/[groupId]/edit/page.tsx b/src/app/groups/[groupId]/edit/page.tsx index 66b83ed4..aac1d928 100644 --- a/src/app/groups/[groupId]/edit/page.tsx +++ b/src/app/groups/[groupId]/edit/page.tsx @@ -5,10 +5,6 @@ export const metadata: Metadata = { title: 'Settings', } -export default async function EditGroupPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - return <EditGroup groupId={groupId} /> +export default async function EditGroupPage() { + return <EditGroup /> } diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx index cd8edfd9..34c42dee 100644 --- a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -34,14 +34,11 @@ import { getImageData, usePresignedUpload } from 'next-s3-upload' import Image from 'next/image' import { useRouter } from 'next/navigation' import { PropsWithChildren, ReactNode, useState } from 'react' +import { useCurrentGroup } from '../current-group-context' const MAX_FILE_SIZE = 5 * 1024 ** 2 -export function CreateFromReceiptButton({ groupId }: { groupId: string }) { - return <CreateFromReceiptButton_ groupId={groupId} /> -} - -function CreateFromReceiptButton_({ groupId }: { groupId: string }) { +export function CreateFromReceiptButton() { const t = useTranslations('CreateFromReceipt') const isDesktop = useMediaQuery('(min-width: 640px)') @@ -70,15 +67,14 @@ function CreateFromReceiptButton_({ groupId }: { groupId: string }) { } description={<>{t('Dialog.description')}</>} > - <ReceiptDialogContent groupId={groupId} /> + <ReceiptDialogContent /> </DialogOrDrawer> ) } -function ReceiptDialogContent({ groupId }: { groupId: string }) { - const { data: groupData } = trpc.groups.get.useQuery({ groupId }) +function ReceiptDialogContent() { + const { group } = useCurrentGroup() const { data: categoriesData } = trpc.categories.list.useQuery() - const group = groupData?.group const categories = categoriesData?.categories const locale = useLocale() diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index 08f44363..b0bb6789 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -64,7 +64,7 @@ const enforceCurrencyPattern = (value: string) => .replace(/[^-\d.]/g, '') // remove all non-numeric characters const getDefaultSplittingOptions = ( - group: AppRouterOutput['groups']['get']['group'], + group: NonNullable<AppRouterOutput['groups']['get']['group']>, ) => { const defaultValue = { splitMode: 'EVENLY' as const, @@ -145,7 +145,7 @@ export function ExpenseForm({ onDelete, runtimeFeatureFlags, }: { - group: AppRouterOutput['groups']['get']['group'] + group: NonNullable<AppRouterOutput['groups']['get']['group']> categories: AppRouterOutput['categories']['list']['categories'] expense?: AppRouterOutput['groups']['expenses']['get']['expense'] onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void> @@ -250,7 +250,6 @@ export function ExpenseForm({ >(new Set()) const sExpense = isIncome ? 'Income' : 'Expense' - const sPaid = isIncome ? 'received' : 'paid' useEffect(() => { setManuallyEditedParticipants(new Set()) diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 66ef409f..bd498a1e 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -11,6 +11,7 @@ import Link from 'next/link' import { forwardRef, useEffect, useMemo, useState } from 'react' import { useInView } from 'react-intersection-observer' import { useDebounce } from 'use-debounce' +import { useCurrentGroup } from '../current-group-context' const PAGE_SIZE = 20 @@ -56,12 +57,12 @@ function getGroupedExpensesByDate(expenses: ExpensesType) { }, {}) } -export function ExpenseList({ groupId }: { groupId: string }) { - const { data: groupData } = trpc.groups.get.useQuery({ groupId }) +export function ExpenseList() { + const { groupId, group } = useCurrentGroup() const [searchText, setSearchText] = useState('') const [debouncedSearchText] = useDebounce(searchText, 300) - const participants = groupData?.group.participants + const participants = group?.participants useEffect(() => { if (!participants) return @@ -103,6 +104,7 @@ const ExpenseListForSearch = ({ searchText: string }) => { const utils = trpc.useUtils() + const { group } = useCurrentGroup() useEffect(() => { // Until we use tRPC more widely and can invalidate the cache on expense @@ -124,11 +126,7 @@ const ExpenseListForSearch = ({ const expenses = data?.pages.flatMap((page) => page.expenses) const hasMore = data?.pages.at(-1)?.hasMore ?? false - const { data: groupData, isLoading: groupIsLoading } = - trpc.groups.get.useQuery({ groupId }) - - const isLoading = - expensesAreLoading || !expenses || groupIsLoading || !groupData + const isLoading = expensesAreLoading || !expenses || !group useEffect(() => { if (inView && hasMore && !isLoading) fetchNextPage() @@ -172,7 +170,7 @@ const ExpenseListForSearch = ({ <ExpenseCard key={expense.id} expense={expense} - currency={groupData.group.currency} + currency={group.currency} groupId={groupId} /> ))} diff --git a/src/app/groups/[groupId]/expenses/page.client.tsx b/src/app/groups/[groupId]/expenses/page.client.tsx index 94827b12..2a9888e3 100644 --- a/src/app/groups/[groupId]/expenses/page.client.tsx +++ b/src/app/groups/[groupId]/expenses/page.client.tsx @@ -15,6 +15,7 @@ import { Download, Plus } from 'lucide-react' import { Metadata } from 'next' import { useTranslations } from 'next-intl' import Link from 'next/link' +import { useCurrentGroup } from '../current-group-context' export const revalidate = 3600 @@ -23,13 +24,12 @@ export const metadata: Metadata = { } export default function GroupExpensesPageClient({ - groupId, enableReceiptExtract, }: { - groupId: string enableReceiptExtract: boolean }) { const t = useTranslations('Expenses') + const { groupId } = useCurrentGroup() return ( <> @@ -50,9 +50,7 @@ export default function GroupExpensesPageClient({ <Download className="w-4 h-4" /> </Link> </Button> - {enableReceiptExtract && ( - <CreateFromReceiptButton groupId={groupId} /> - )} + {enableReceiptExtract && <CreateFromReceiptButton />} <Button asChild size="icon"> <Link href={`/groups/${groupId}/expenses/create`} @@ -65,7 +63,7 @@ export default function GroupExpensesPageClient({ </div> <CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative"> - <ExpenseList groupId={groupId} /> + <ExpenseList /> </CardContent> </Card> diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index 0d0f9359..19f2c7f8 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -8,14 +8,9 @@ export const metadata: Metadata = { title: 'Expenses', } -export default async function GroupExpensesPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { +export default async function GroupExpensesPage() { return ( <GroupExpensesPageClient - groupId={groupId} enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT} /> ) diff --git a/src/app/groups/[groupId]/group-header.tsx b/src/app/groups/[groupId]/group-header.tsx index 154ba7f5..49c741c1 100644 --- a/src/app/groups/[groupId]/group-header.tsx +++ b/src/app/groups/[groupId]/group-header.tsx @@ -3,27 +3,27 @@ import { GroupTabs } from '@/app/groups/[groupId]/group-tabs' import { ShareButton } from '@/app/groups/[groupId]/share-button' import { Skeleton } from '@/components/ui/skeleton' -import { trpc } from '@/trpc/client' import Link from 'next/link' +import { useCurrentGroup } from './current-group-context' -export const GroupHeader = ({ groupId }: { groupId: string }) => { - const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) +export const GroupHeader = () => { + const { isLoading, groupId, group } = useCurrentGroup() return ( <div className="flex flex-col justify-between gap-3"> <h1 className="font-bold text-2xl"> <Link href={`/groups/${groupId}`}> - {isLoading || !data ? ( + {isLoading ? ( <Skeleton className="mt-1.5 mb-1.5 h-5 w-32" /> ) : ( - <div className="flex">{data.group.name}</div> + <div className="flex">{group.name}</div> )} </Link> </h1> <div className="flex gap-2 justify-between"> <GroupTabs groupId={groupId} /> - {data?.group && <ShareButton group={data.group} />} + {group && <ShareButton group={group} />} </div> </div> ) diff --git a/src/app/groups/[groupId]/information/group-information.tsx b/src/app/groups/[groupId]/information/group-information.tsx index fd3d88a2..73cdadb9 100644 --- a/src/app/groups/[groupId]/information/group-information.tsx +++ b/src/app/groups/[groupId]/information/group-information.tsx @@ -9,14 +9,14 @@ import { CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { trpc } from '@/trpc/client' import { Pencil } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' +import { useCurrentGroup } from '../current-group-context' export default function GroupInformation({ groupId }: { groupId: string }) { const t = useTranslations('Information') - const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) + const { isLoading, group } = useCurrentGroup() return ( <> @@ -35,13 +35,13 @@ export default function GroupInformation({ groupId }: { groupId: string }) { </CardDescription> </CardHeader> <CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces"> - {isLoading || !data ? ( + {isLoading ? ( <div className="py-1 flex flex-col gap-2"> <Skeleton className="h-3 w-3/4" /> <Skeleton className="h-3 w-1/2" /> </div> - ) : data.group.information ? ( - <p className="text-foreground">{data.group.information}</p> + ) : group.information ? ( + <p className="text-foreground">{group.information}</p> ) : ( <p className="text-muted-foreground text-sm">{t('empty')}</p> )} diff --git a/src/app/groups/[groupId]/layout.client.tsx b/src/app/groups/[groupId]/layout.client.tsx new file mode 100644 index 00000000..e04f222b --- /dev/null +++ b/src/app/groups/[groupId]/layout.client.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useToast } from '@/components/ui/use-toast' +import { trpc } from '@/trpc/client' +import { useTranslations } from 'next-intl' +import { PropsWithChildren, useEffect } from 'react' +import { CurrentGroupProvider } from './current-group-context' +import { GroupHeader } from './group-header' +import { SaveGroupLocally } from './save-recent-group' + +export function GroupLayoutClient({ + groupId, + children, +}: PropsWithChildren<{ groupId: string }>) { + const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) + const t = useTranslations('Groups.NotFound') + const { toast } = useToast() + + useEffect(() => { + if (data && !data.group) { + toast({ + description: t('text'), + variant: 'destructive', + }) + } + }, [data]) + + const props = + isLoading || !data?.group + ? { isLoading: true as const, groupId, group: undefined } + : { isLoading: false as const, groupId, group: data.group } + + if (isLoading) { + return ( + <CurrentGroupProvider {...props}> + <GroupHeader /> + {children} + </CurrentGroupProvider> + ) + } + + return ( + <CurrentGroupProvider {...props}> + <GroupHeader /> + {children} + <SaveGroupLocally /> + </CurrentGroupProvider> + ) +} diff --git a/src/app/groups/[groupId]/layout.tsx b/src/app/groups/[groupId]/layout.tsx index f8206d8f..e9afd6c9 100644 --- a/src/app/groups/[groupId]/layout.tsx +++ b/src/app/groups/[groupId]/layout.tsx @@ -1,9 +1,7 @@ import { cached } from '@/app/cached-functions' -import { GroupHeader } from '@/app/groups/[groupId]/group-header' -import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group' import { Metadata } from 'next' -import { notFound } from 'next/navigation' import { PropsWithChildren } from 'react' +import { GroupLayoutClient } from './layout.client' type Props = { params: { @@ -24,20 +22,9 @@ export async function generateMetadata({ } } -export default async function GroupLayout({ +export default function GroupLayout({ children, params: { groupId }, }: PropsWithChildren<Props>) { - const group = await cached.getGroup(groupId) - if (!group) notFound() - - return ( - <> - <GroupHeader groupId={groupId} /> - - {children} - - <SaveGroupLocally group={{ id: group.id, name: group.name }} /> - </> - ) + return <GroupLayoutClient groupId={groupId}>{children}</GroupLayoutClient> } diff --git a/src/app/groups/[groupId]/save-recent-group.tsx b/src/app/groups/[groupId]/save-recent-group.tsx index 084681aa..27aa3e96 100644 --- a/src/app/groups/[groupId]/save-recent-group.tsx +++ b/src/app/groups/[groupId]/save-recent-group.tsx @@ -1,17 +1,13 @@ 'use client' -import { - RecentGroup, - saveRecentGroup, -} from '@/app/groups/recent-groups-helpers' +import { saveRecentGroup } from '@/app/groups/recent-groups-helpers' import { useEffect } from 'react' +import { useCurrentGroup } from './current-group-context' -type Props = { - group: RecentGroup -} +export function SaveGroupLocally() { + const { group } = useCurrentGroup() -export function SaveGroupLocally({ group }: Props) { useEffect(() => { - saveRecentGroup(group) + if (group) saveRecentGroup({ id: group.id, name: group.name }) }, [group]) return null diff --git a/src/app/groups/[groupId]/stats/page.client.tsx b/src/app/groups/[groupId]/stats/page.client.tsx index 17da5597..9256ecc1 100644 --- a/src/app/groups/[groupId]/stats/page.client.tsx +++ b/src/app/groups/[groupId]/stats/page.client.tsx @@ -8,7 +8,7 @@ import { } from '@/components/ui/card' import { useTranslations } from 'next-intl' -export function TotalsPageClient({ groupId }: { groupId: string }) { +export function TotalsPageClient() { const t = useTranslations('Stats') return ( @@ -19,7 +19,7 @@ export function TotalsPageClient({ groupId }: { groupId: string }) { <CardDescription>{t('Totals.description')}</CardDescription> </CardHeader> <CardContent className="flex flex-col space-y-4"> - <Totals groupId={groupId} /> + <Totals /> </CardContent> </Card> </> diff --git a/src/app/groups/[groupId]/stats/page.tsx b/src/app/groups/[groupId]/stats/page.tsx index 38c1d009..5bafb677 100644 --- a/src/app/groups/[groupId]/stats/page.tsx +++ b/src/app/groups/[groupId]/stats/page.tsx @@ -5,10 +5,6 @@ export const metadata: Metadata = { title: 'Totals', } -export default async function TotalsPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - return <TotalsPageClient groupId={groupId} /> +export default async function TotalsPage() { + return <TotalsPageClient /> } diff --git a/src/app/groups/[groupId]/stats/totals.tsx b/src/app/groups/[groupId]/stats/totals.tsx index 911be839..d1ea4e3e 100644 --- a/src/app/groups/[groupId]/stats/totals.tsx +++ b/src/app/groups/[groupId]/stats/totals.tsx @@ -5,16 +5,17 @@ import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-sp import { Skeleton } from '@/components/ui/skeleton' import { useActiveUser } from '@/lib/hooks' import { trpc } from '@/trpc/client' +import { useCurrentGroup } from '../current-group-context' -export function Totals({ groupId }: { groupId: string }) { +export function Totals() { + const { groupId, group } = useCurrentGroup() const activeUser = useActiveUser(groupId) const participantId = activeUser && activeUser !== 'None' ? activeUser : undefined const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId }) - const { data: groupData } = trpc.groups.get.useQuery({ groupId }) - if (!data || !groupData) + if (!data || !group) return ( <div className="flex flex-col gap-7"> {[0, 1, 2].map((index) => ( @@ -31,7 +32,6 @@ export function Totals({ groupId }: { groupId: string }) { totalParticipantShare, totalParticipantSpendings, } = data - const { group } = groupData return ( <> diff --git a/src/app/groups/actions.ts b/src/app/groups/actions.ts deleted file mode 100644 index 9e8e6050..00000000 --- a/src/app/groups/actions.ts +++ /dev/null @@ -1,7 +0,0 @@ -'use server' -import { getGroups } from '@/lib/api' - -export async function getGroupsAction(groupIds: string[]) { - 'use server' - return getGroups(groupIds) -} diff --git a/src/app/groups/add-group-by-url-button-actions.ts b/src/app/groups/add-group-by-url-button-actions.ts deleted file mode 100644 index b6c51474..00000000 --- a/src/app/groups/add-group-by-url-button-actions.ts +++ /dev/null @@ -1,8 +0,0 @@ -'use server' - -import { getGroup } from '@/lib/api' - -export async function getGroupInfoAction(groupId: string) { - 'use server' - return getGroup(groupId) -} diff --git a/src/app/groups/add-group-by-url-button.tsx b/src/app/groups/add-group-by-url-button.tsx index a4053d71..da76ca43 100644 --- a/src/app/groups/add-group-by-url-button.tsx +++ b/src/app/groups/add-group-by-url-button.tsx @@ -1,4 +1,3 @@ -import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions' import { saveRecentGroup } from '@/app/groups/recent-groups-helpers' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -8,6 +7,7 @@ import { PopoverTrigger, } from '@/components/ui/popover' import { useMediaQuery } from '@/lib/hooks' +import { trpc } from '@/trpc/client' import { Loader2, Plus } from 'lucide-react' import { useTranslations } from 'next-intl' import { useState } from 'react' @@ -23,14 +23,12 @@ export function AddGroupByUrlButton({ reload }: Props) { const [error, setError] = useState(false) const [open, setOpen] = useState(false) const [pending, setPending] = useState(false) + const utils = trpc.useUtils() return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> - <Button variant="secondary"> - {/* <Plus className="w-4 h-4 mr-2" /> */} - {t('button')} - </Button> + <Button variant="secondary">{t('button')}</Button> </PopoverTrigger> <PopoverContent align={isDesktop ? 'end' : 'start'} @@ -47,15 +45,17 @@ export function AddGroupByUrlButton({ reload }: Props) { new RegExp(`${window.location.origin}/groups/([^/]+)`), ) ?? [] setPending(true) - const group = groupId ? await getGroupInfoAction(groupId) : null - setPending(false) - if (!group) { - setError(true) - } else { + const { group } = await utils.groups.get.fetch({ + groupId: groupId, + }) + if (group) { saveRecentGroup({ id: group.id, name: group.name }) reload() setUrl('') setOpen(false) + } else { + setError(true) + setPending(false) } }} > diff --git a/src/app/groups/recent-group-list-card.tsx b/src/app/groups/recent-group-list-card.tsx index 30692102..8984e783 100644 --- a/src/app/groups/recent-group-list-card.tsx +++ b/src/app/groups/recent-group-list-card.tsx @@ -1,12 +1,7 @@ -'use client' -import { RecentGroupsState } from '@/app/groups/recent-group-list' import { RecentGroup, archiveGroup, deleteRecentGroup, - getArchivedGroups, - getStarredGroups, - saveRecentGroup, starGroup, unarchiveGroup, unstarGroup, @@ -19,46 +14,32 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' -import { ToastAction } from '@/components/ui/toast' import { useToast } from '@/components/ui/use-toast' +import { AppRouterOutput } from '@/trpc/routers/_app' import { StarFilledIcon } from '@radix-ui/react-icons' import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { SetStateAction } from 'react' export function RecentGroupListCard({ group, - state, - setState, + groupDetail, + isStarred, + isArchived, + refreshGroupsFromStorage, }: { group: RecentGroup - state: RecentGroupsState - setState: (state: SetStateAction<RecentGroupsState>) => void + groupDetail?: AppRouterOutput['groups']['list']['groups'][number] + isStarred: boolean + isArchived: boolean + refreshGroupsFromStorage: () => void }) { const router = useRouter() const locale = useLocale() const toast = useToast() const t = useTranslations('Groups') - const details = - state.status === 'complete' - ? state.groupsDetails.find((d) => d.id === group.id) - : null - - if (state.status === 'pending') return null - - const refreshGroupsFromStorage = () => - setState({ - ...state, - starredGroups: getStarredGroups(), - archivedGroups: getArchivedGroups(), - }) - - const isStarred = state.starredGroups.includes(group.id) - const isArchived = state.archivedGroups.includes(group.id) - return ( <li key={group.id}> <Button @@ -116,27 +97,11 @@ export function RecentGroupListCard({ onClick={(event) => { event.stopPropagation() deleteRecentGroup(group) - setState({ - ...state, - groups: state.groups.filter((g) => g.id !== group.id), - }) + refreshGroupsFromStorage() + toast.toast({ title: t('RecentRemovedToast.title'), description: t('RecentRemovedToast.description'), - action: ( - <ToastAction - altText={t('RecentRemovedToast.undoAlt')} - onClick={() => { - saveRecentGroup(group) - setState({ - ...state, - groups: state.groups, - }) - }} - > - {t('RecentRemovedToast.undo')} - </ToastAction> - ), }) }} > @@ -161,18 +126,21 @@ export function RecentGroupListCard({ </span> </div> <div className="text-muted-foreground font-normal text-xs"> - {details ? ( + {groupDetail ? ( <div className="w-full flex items-center justify-between"> <div className="flex items-center"> <Users className="w-3 h-3 inline mr-1" /> - <span>{details._count.participants}</span> + <span>{groupDetail._count.participants}</span> </div> <div className="flex items-center"> <Calendar className="w-3 h-3 inline mx-1" /> <span> - {new Date(details.createdAt).toLocaleDateString(locale, { - dateStyle: 'medium', - })} + {new Date(groupDetail.createdAt).toLocaleDateString( + locale, + { + dateStyle: 'medium', + }, + )} </span> </div> </div> diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx index ad01d283..3d6465e6 100644 --- a/src/app/groups/recent-group-list.tsx +++ b/src/app/groups/recent-group-list.tsx @@ -1,5 +1,4 @@ 'use client' -import { getGroupsAction } from '@/app/groups/actions' import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button' import { RecentGroups, @@ -9,10 +8,12 @@ import { } from '@/app/groups/recent-groups-helpers' import { Button } from '@/components/ui/button' import { getGroups } from '@/lib/api' +import { trpc } from '@/trpc/client' +import { AppRouterOutput } from '@/trpc/routers/_app' import { Loader2 } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' -import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react' +import { PropsWithChildren, useEffect, useState } from 'react' import { RecentGroupListCard } from './recent-group-list-card' export type RecentGroupsState = @@ -31,16 +32,22 @@ export type RecentGroupsState = archivedGroups: string[] } -function sortGroups( - state: RecentGroupsState & { status: 'complete' | 'partial' }, -) { +function sortGroups({ + groups, + starredGroups, + archivedGroups, +}: { + groups: RecentGroups + starredGroups: string[] + archivedGroups: string[] +}) { const starredGroupInfo = [] const groupInfo = [] const archivedGroupInfo = [] - for (const group of state.groups) { - if (state.starredGroups.includes(group.id)) { + for (const group of groups) { + if (starredGroups.includes(group.id)) { starredGroupInfo.push(group) - } else if (state.archivedGroups.includes(group.id)) { + } else if (archivedGroups.includes(group.id)) { archivedGroupInfo.push(group) } else { groupInfo.push(group) @@ -54,7 +61,6 @@ function sortGroups( } export function RecentGroupList() { - const t = useTranslations('Groups') const [state, setState] = useState<RecentGroupsState>({ status: 'pending' }) function loadGroups() { @@ -67,24 +73,43 @@ export function RecentGroupList() { starredGroups, archivedGroups, }) - getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => { - setState({ - status: 'complete', - groups: groupsInStorage, - groupsDetails, - starredGroups, - archivedGroups, - }) - }) } useEffect(() => { loadGroups() }, []) - if (state.status === 'pending') { + if (state.status === 'pending') return null + + return ( + <RecentGroupList_ + groups={state.groups} + starredGroups={state.starredGroups} + archivedGroups={state.archivedGroups} + refreshGroupsFromStorage={() => loadGroups()} + /> + ) +} + +function RecentGroupList_({ + groups, + starredGroups, + archivedGroups, + refreshGroupsFromStorage, +}: { + groups: RecentGroups + starredGroups: string[] + archivedGroups: string[] + refreshGroupsFromStorage: () => void +}) { + const t = useTranslations('Groups') + const { data, isLoading } = trpc.groups.list.useQuery({ + groupIds: groups.map((group) => group.id), + }) + + if (isLoading || !data) { return ( - <GroupsPage reload={loadGroups}> + <GroupsPage reload={refreshGroupsFromStorage}> <p> <Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '} {t('loadingRecent')} @@ -93,9 +118,9 @@ export function RecentGroupList() { ) } - if (state.groups.length === 0) { + if (data.groups.length === 0) { return ( - <GroupsPage reload={loadGroups}> + <GroupsPage reload={refreshGroupsFromStorage}> <div className="text-sm space-y-2"> <p>{t('NoRecent.description')}</p> <p> @@ -109,17 +134,23 @@ export function RecentGroupList() { ) } - const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state) + const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups({ + groups, + starredGroups, + archivedGroups, + }) return ( - <GroupsPage reload={loadGroups}> + <GroupsPage reload={refreshGroupsFromStorage}> {starredGroupInfo.length > 0 && ( <> <h2 className="mb-2">{t('starred')}</h2> <GroupList groups={starredGroupInfo} - state={state} - setState={setState} + groupDetails={data.groups} + archivedGroups={archivedGroups} + starredGroups={starredGroups} + refreshGroupsFromStorage={refreshGroupsFromStorage} /> </> )} @@ -127,7 +158,13 @@ export function RecentGroupList() { {groupInfo.length > 0 && ( <> <h2 className="mt-6 mb-2">{t('recent')}</h2> - <GroupList groups={groupInfo} state={state} setState={setState} /> + <GroupList + groups={groupInfo} + groupDetails={data.groups} + archivedGroups={archivedGroups} + starredGroups={starredGroups} + refreshGroupsFromStorage={refreshGroupsFromStorage} + /> </> )} @@ -137,8 +174,10 @@ export function RecentGroupList() { <div className="opacity-50"> <GroupList groups={archivedGroupInfo} - state={state} - setState={setState} + groupDetails={data.groups} + archivedGroups={archivedGroups} + starredGroups={starredGroups} + refreshGroupsFromStorage={refreshGroupsFromStorage} /> </div> </> @@ -149,12 +188,16 @@ export function RecentGroupList() { function GroupList({ groups, - state, - setState, + groupDetails, + starredGroups, + archivedGroups, + refreshGroupsFromStorage, }: { groups: RecentGroups - state: RecentGroupsState - setState: (state: SetStateAction<RecentGroupsState>) => void + groupDetails?: AppRouterOutput['groups']['list']['groups'] + starredGroups: string[] + archivedGroups: string[] + refreshGroupsFromStorage: () => void }) { return ( <ul className="grid gap-2 sm:grid-cols-2"> @@ -162,8 +205,12 @@ function GroupList({ <RecentGroupListCard key={group.id} group={group} - state={state} - setState={setState} + groupDetail={groupDetails?.find( + (groupDetail) => groupDetail.id === group.id, + )} + isStarred={starredGroups.includes(group.id)} + isArchived={archivedGroups.includes(group.id)} + refreshGroupsFromStorage={refreshGroupsFromStorage} /> ))} </ul> diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index 0c9fc506..7d9065f1 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -21,6 +21,8 @@ function getQueryClient() { return (clientQueryClientSingleton ??= makeQueryClient()) } +export const trpcClient = getQueryClient() + function getUrl() { const base = (() => { if (typeof window !== 'undefined') return '' diff --git a/src/trpc/routers/groups/get.procedure.ts b/src/trpc/routers/groups/get.procedure.ts index 02841ffd..331a6fc0 100644 --- a/src/trpc/routers/groups/get.procedure.ts +++ b/src/trpc/routers/groups/get.procedure.ts @@ -1,19 +1,10 @@ -import { getGroup, getGroupExpensesParticipants } from '@/lib/api' +import { getGroup } from '@/lib/api' import { baseProcedure } from '@/trpc/init' -import { TRPCError } from '@trpc/server' import { z } from 'zod' export const getGroupProcedure = baseProcedure .input(z.object({ groupId: z.string().min(1) })) .query(async ({ input: { groupId } }) => { const group = await getGroup(groupId) - if (!group) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Group not found.', - }) - } - - const participantsWithExpenses = await getGroupExpensesParticipants(groupId) - return { group, participantsWithExpenses } + return { group } }) diff --git a/src/trpc/routers/groups/getDetails.procedure.ts b/src/trpc/routers/groups/getDetails.procedure.ts new file mode 100644 index 00000000..831b6a85 --- /dev/null +++ b/src/trpc/routers/groups/getDetails.procedure.ts @@ -0,0 +1,19 @@ +import { getGroup, getGroupExpensesParticipants } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const getGroupDetailsProcedure = baseProcedure + .input(z.object({ groupId: z.string().min(1) })) + .query(async ({ input: { groupId } }) => { + const group = await getGroup(groupId) + if (!group) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Group not found.', + }) + } + + const participantsWithExpenses = await getGroupExpensesParticipants(groupId) + return { group, participantsWithExpenses } + }) diff --git a/src/trpc/routers/groups/index.ts b/src/trpc/routers/groups/index.ts index c4f02d8e..13222883 100644 --- a/src/trpc/routers/groups/index.ts +++ b/src/trpc/routers/groups/index.ts @@ -6,6 +6,8 @@ import { groupExpensesRouter } from '@/trpc/routers/groups/expenses' import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure' import { groupStatsRouter } from '@/trpc/routers/groups/stats' import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure' +import { getGroupDetailsProcedure } from './getDetails.procedure' +import { listGroupsProcedure } from './list.procedure' export const groupsRouter = createTRPCRouter({ expenses: groupExpensesRouter, @@ -14,6 +16,8 @@ export const groupsRouter = createTRPCRouter({ activities: activitiesRouter, get: getGroupProcedure, + getDetails: getGroupDetailsProcedure, + list: listGroupsProcedure, create: createGroupProcedure, update: updateGroupProcedure, }) diff --git a/src/trpc/routers/groups/list.procedure.ts b/src/trpc/routers/groups/list.procedure.ts new file mode 100644 index 00000000..557288aa --- /dev/null +++ b/src/trpc/routers/groups/list.procedure.ts @@ -0,0 +1,14 @@ +import { getGroups } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +export const listGroupsProcedure = baseProcedure + .input( + z.object({ + groupIds: z.array(z.string().min(1)), + }), + ) + .query(async ({ input: { groupIds } }) => { + const groups = await getGroups(groupIds) + return { groups } + })