diff --git a/lib/hooks/useInViewObserver.ts b/lib/hooks/useInViewObserver.ts new file mode 100644 index 00000000..3a941ac2 --- /dev/null +++ b/lib/hooks/useInViewObserver.ts @@ -0,0 +1,42 @@ +import React from "react"; + +const isInteractionObserverSupported = typeof window !== "undefined" && "IntersectionObserver" in window; + +export const useInViewObserver = (onInViewCallback: () => void) => { + const [node, setRef] = React.useState(null); + + const onInViewCallbackRef = React.useRef(onInViewCallback); + onInViewCallbackRef.current = onInViewCallback; + + React.useEffect(() => { + if (!isInteractionObserverSupported) { + // Skip interaction check if not supported in browser + return; + } + + let observer: IntersectionObserver; + if (node && node.parentElement) { + observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + onInViewCallbackRef.current(); + } + }, + { + root: document.body, + } + ); + observer.observe(node); + } + + return () => { + if (observer) { + observer.disconnect(); + } + }; + }, [node]); + + return { + ref: setRef, + }; +}; diff --git a/pages/bookings/[status].tsx b/pages/bookings/[status].tsx index d672e0f0..bc7fd930 100644 --- a/pages/bookings/[status].tsx +++ b/pages/bookings/[status].tsx @@ -1,18 +1,25 @@ import { CalendarIcon } from "@heroicons/react/outline"; import { useRouter } from "next/router"; +import { Fragment } from "react"; -import { QueryCell } from "@lib/QueryCell"; +import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import { useLocale } from "@lib/hooks/useLocale"; import { inferQueryInput, trpc } from "@lib/trpc"; import BookingsShell from "@components/BookingsShell"; import EmptyScreen from "@components/EmptyScreen"; +import Loader from "@components/Loader"; import Shell from "@components/Shell"; import BookingListItem from "@components/booking/BookingListItem"; +import { Alert } from "@components/ui/Alert"; +import Button from "@components/ui/Button"; type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"]; export default function Bookings() { + const router = useRouter(); + const status = router.query?.status as BookingListingStatus; + const { t } = useLocale(); const descriptionByStatus: Record = { @@ -21,44 +28,63 @@ export default function Bookings() { cancelled: t("cancelled_bookings"), }; - const router = useRouter(); - const status = router.query?.status as BookingListingStatus; - - const query = trpc.useQuery(["viewer.bookings", { status }], { + const query = trpc.useInfiniteQuery(["viewer.bookings", { status, limit: 10 }], { // first render has status `undefined` enabled: !!status, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); + + const buttonInView = useInViewObserver(() => { + if (!query.isFetching && query.hasNextPage && query.status === "success") { + query.fetchNextPage(); + } }); return ( -
+
-
- ( -
+
+ {query.status === "error" && ( + + )} + {query.status === "loading" || (query.status === "idle" && )} + {query.status === "success" && query.data.pages[0].bookings.length > 0 && ( + <> +
- {data.map((booking) => ( - + {query.data.pages.map((page, index) => ( + + {page.bookings.map((booking) => ( + + ))} + ))}
- )} - empty={() => ( - - )} - /> +
+ +
+ + )} + {query.status === "success" && query.data.pages[0].bookings.length === 0 && ( + + )}
diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json index 6f0338f1..06dffa92 100644 --- a/public/static/locales/en/common.json +++ b/public/static/locales/en/common.json @@ -1,4 +1,6 @@ { + "no_more_results": "No more results", + "load_more_results": "Load more results", "integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}", "confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}", "new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}", diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index fdb35b92..24c946a1 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -226,8 +226,14 @@ const loggedInViewerRouter = createProtectedRouter() .query("bookings", { input: z.object({ status: z.enum(["upcoming", "past", "cancelled"]), + limit: z.number().min(1).max(100).nullish(), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type }), async resolve({ ctx, input }) { + // using offset actually because cursor pagination requires a unique column + // for orderBy, but we don't use a unique column in our orderBy + const take = input.limit ?? 10; + const skip = input.cursor ?? 0; const { prisma, user } = ctx; const bookingListingByStatus = input.status; const bookingListingFilters: Record = { @@ -237,8 +243,8 @@ const loggedInViewerRouter = createProtectedRouter() }; const bookingListingOrderby: Record = { upcoming: { startTime: "desc" }, - past: { startTime: "asc" }, - cancelled: { startTime: "asc" }, + past: { startTime: "desc" }, + cancelled: { startTime: "desc" }, }; const passedBookingsFilter = bookingListingFilters[bookingListingByStatus]; const orderBy = bookingListingOrderby[bookingListingByStatus]; @@ -281,6 +287,8 @@ const loggedInViewerRouter = createProtectedRouter() status: true, }, orderBy, + take: take + 1, + skip, }); const bookings = bookingsQuery.reverse().map((booking) => { @@ -291,7 +299,18 @@ const loggedInViewerRouter = createProtectedRouter() }; }); - return bookings; + let nextCursor: typeof skip | null = skip; + if (bookings.length > take) { + bookings.shift(); + nextCursor += bookings.length; + } else { + nextCursor = null; + } + + return { + bookings, + nextCursor, + }; }, }) .query("integrations", {