add tRPC (#614)
* add trpc * trpc specific * fix deps * lint fix * upgrade prisma * nativeTypes * nope, not needed * fix app propviders * Revert "upgrade prisma" This reverts commit e6f2d2542a01ec82c80aa2fe367ae12c68ded1a5. * rev * up trpc * simplify * wip - bookings page with trpc * bookings using trpc * fix `Shell` props * call it viewerRouter instead * cleanuop * ssg helper * fix lint * fix types * skip * add `useRedirectToLoginIfUnauthenticated` * exhaustive-deps * fix callbackUrl * rewrite `/availability` using trpc Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
0938f6f4b2
commit
34300650e4
18 changed files with 483 additions and 216 deletions
|
@ -1,4 +1,3 @@
|
|||
// TODO: replace headlessui with radix-ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
|
@ -10,28 +9,69 @@ import {
|
|||
LogoutIcon,
|
||||
PuzzleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { User } from "@prisma/client";
|
||||
import { signOut, useSession } from "next-auth/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
import React, { Fragment, ReactNode, useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
import Loader from "./Loader";
|
||||
import Logo from "./Logo";
|
||||
|
||||
export default function Shell(props) {
|
||||
function useMeQuery() {
|
||||
const [session] = useSession();
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
// refetch max once per 5s
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// refetch if sesion changes
|
||||
meQuery.refetch();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session]);
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated() {
|
||||
const [session, loading] = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !session) {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [loading, session, router]);
|
||||
}
|
||||
|
||||
export default function Shell(props: {
|
||||
title?: string;
|
||||
heading: ReactNode;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
useRedirectToLoginIfUnauthenticated();
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const query = useMeQuery();
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
|
@ -72,16 +112,19 @@ export default function Shell(props) {
|
|||
});
|
||||
}, [telemetry]);
|
||||
|
||||
if (!loading && !session) {
|
||||
if (query.status !== "loading" && !query.data) {
|
||||
router.replace("/auth/login");
|
||||
}
|
||||
|
||||
const pageTitle = typeof props.heading === "string" ? props.heading : props.title;
|
||||
if (query.status === "loading") {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return session ? (
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={pageTitle}
|
||||
title={pageTitle ?? "Cal.com"}
|
||||
description={props.subtitle}
|
||||
nextSeoProps={{
|
||||
nofollow: true,
|
||||
|
@ -155,7 +198,7 @@ export default function Shell(props) {
|
|||
</Link>
|
||||
</button>
|
||||
<div className="mt-1">
|
||||
<UserDropdown small bottom session={session} />
|
||||
<UserDropdown small bottom />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -206,19 +249,12 @@ export default function Shell(props) {
|
|||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/me")
|
||||
.then((res) => res.json())
|
||||
.then((responseBody) => {
|
||||
setUser(responseBody.user);
|
||||
});
|
||||
}, []);
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
return (
|
||||
<Menu as="div" className="w-full relative inline-block text-left">
|
||||
|
@ -230,8 +266,8 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
|
|||
<span className="flex w-full justify-between items-center">
|
||||
<span className="flex min-w-0 items-center justify-between space-x-3">
|
||||
<Avatar
|
||||
imageSrc={user?.avatar}
|
||||
displayName={user?.name}
|
||||
imageSrc={user.avatar}
|
||||
alt={user.username}
|
||||
className={classNames(
|
||||
small ? "w-8 h-8" : "w-10 h-10",
|
||||
"bg-gray-300 rounded-full flex-shrink-0"
|
||||
|
@ -239,9 +275,9 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
|
|||
/>
|
||||
{!small && (
|
||||
<span className="flex-1 flex flex-col min-w-0">
|
||||
<span className="text-gray-900 text-sm font-medium truncate">{user?.name}</span>
|
||||
<span className="text-gray-900 text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="text-neutral-500 font-normal text-sm truncate">
|
||||
/{user?.username}
|
||||
/{user.username}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Maybe } from "@trpc/server";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
export type AvatarProps = {
|
||||
className?: string;
|
||||
size: number;
|
||||
imageSrc?: string;
|
||||
size?: number;
|
||||
imageSrc?: Maybe<string>;
|
||||
title?: string;
|
||||
alt: string;
|
||||
gravatarFallbackMd5?: string;
|
||||
};
|
||||
|
||||
export default function Avatar({ imageSrc, gravatarFallbackMd5, size, alt, title, ...props }: AvatarProps) {
|
||||
const className = classNames("rounded-full", props.className, `h-${size} w-${size}`);
|
||||
export default function Avatar(props: AvatarProps) {
|
||||
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
|
||||
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
|
||||
const avatar = (
|
||||
<AvatarPrimitive.Root>
|
||||
<AvatarPrimitive.Image
|
||||
src={imageSrc}
|
||||
src={imageSrc ?? undefined}
|
||||
alt={alt}
|
||||
className={classNames("rounded-full", `h-auto w-${size}`, props.className)}
|
||||
/>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
describe("cancel", () => {
|
||||
describe.skip("cancel", () => {
|
||||
describe("Admin user can cancel events", () => {
|
||||
before(() => {
|
||||
cy.visit("/bookings");
|
||||
cy.login("pro@example.com", "pro");
|
||||
});
|
||||
it.skip("can cancel bookings", () => {
|
||||
it("can cancel bookings", () => {
|
||||
cy.visit("/bookings");
|
||||
cy.get("[data-testid=bookings]").children().should("have.length.at.least", 1);
|
||||
cy.get("[data-testid=cancel]").click();
|
||||
|
|
|
@ -1,37 +1,56 @@
|
|||
import { IdProvider } from "@radix-ui/react-id";
|
||||
import { httpBatchLink } from "@trpc/client/links/httpBatchLink";
|
||||
import { loggerLink } from "@trpc/client/links/loggerLink";
|
||||
import { withTRPC } from "@trpc/next";
|
||||
import { Provider } from "next-auth/client";
|
||||
import { AppProps } from "next/dist/shared/lib/router/router";
|
||||
import React from "react";
|
||||
import { HydrateProps, QueryClient, QueryClientProvider } from "react-query";
|
||||
import { Hydrate } from "react-query/hydration";
|
||||
|
||||
import DynamicIntercomProvider from "@ee/lib/intercom/providerDynamic";
|
||||
|
||||
import { Session } from "@lib/auth";
|
||||
import { createTelemetryClient, TelemetryProvider } from "@lib/telemetry";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
type AppProviderProps = {
|
||||
pageProps: {
|
||||
session?: Session;
|
||||
dehydratedState?: HydrateProps;
|
||||
};
|
||||
};
|
||||
|
||||
const AppProviders: React.FC<AppProviderProps> = ({ pageProps, children }) => {
|
||||
const AppProviders = (props: AppProps) => {
|
||||
return (
|
||||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IdProvider>
|
||||
<DynamicIntercomProvider>
|
||||
<Hydrate state={pageProps.dehydratedState}>
|
||||
<Provider session={pageProps.session}>{children}</Provider>
|
||||
</Hydrate>
|
||||
</DynamicIntercomProvider>
|
||||
</IdProvider>
|
||||
</QueryClientProvider>
|
||||
<IdProvider>
|
||||
<DynamicIntercomProvider>
|
||||
<Provider session={props.pageProps.session}>{props.children}</Provider>
|
||||
</DynamicIntercomProvider>
|
||||
</IdProvider>
|
||||
</TelemetryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppProviders;
|
||||
export default withTRPC({
|
||||
config() {
|
||||
/**
|
||||
* If you want to use SSR, you need to use the server's full URL
|
||||
* @link https://trpc.io/docs/ssr
|
||||
*/
|
||||
return {
|
||||
/**
|
||||
* @link https://trpc.io/docs/links
|
||||
*/
|
||||
links: [
|
||||
// adds pretty logs to your console in development and logs errors in production
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
httpBatchLink({
|
||||
url: `/api/trpc`,
|
||||
}),
|
||||
],
|
||||
/**
|
||||
* @link https://react-query.tanstack.com/reference/QueryClient
|
||||
*/
|
||||
// queryClientConfig: { defaultOptions: { queries: { staleTime: 6000 } } },
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/ssr
|
||||
*/
|
||||
ssr: false,
|
||||
})(AppProviders);
|
||||
|
|
28
lib/trpc.ts
Normal file
28
lib/trpc.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
// ℹ️ Type-only import:
|
||||
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
|
||||
import type { AppRouter } from "@server/routers/_app";
|
||||
import { createReactQueryHooks } from "@trpc/react";
|
||||
import type { inferProcedureOutput, inferProcedureInput } from "@trpc/server";
|
||||
|
||||
/**
|
||||
* A set of strongly-typed React hooks from your `AppRouter` type signature with `createReactQueryHooks`.
|
||||
* @link https://trpc.io/docs/react#3-create-trpc-hooks
|
||||
*/
|
||||
export const trpc = createReactQueryHooks<AppRouter>();
|
||||
|
||||
// export const transformer = superjson;
|
||||
/**
|
||||
* This is a helper method to infer the output of a query resolver
|
||||
* @example type HelloOutput = inferQueryOutput<'hello'>
|
||||
*/
|
||||
export type inferQueryOutput<TRouteKey extends keyof AppRouter["_def"]["queries"]> = inferProcedureOutput<
|
||||
AppRouter["_def"]["queries"][TRouteKey]
|
||||
>;
|
||||
|
||||
export type inferQueryInput<TRouteKey extends keyof AppRouter["_def"]["queries"]> = inferProcedureInput<
|
||||
AppRouter["_def"]["queries"][TRouteKey]
|
||||
>;
|
||||
|
||||
export type inferMutationInput<TRouteKey extends keyof AppRouter["_def"]["mutations"]> = inferProcedureInput<
|
||||
AppRouter["_def"]["mutations"][TRouteKey]
|
||||
>;
|
|
@ -37,6 +37,10 @@
|
|||
"@stripe/react-stripe-js": "^1.4.1",
|
||||
"@stripe/stripe-js": "^1.16.0",
|
||||
"@tailwindcss/forms": "^0.3.3",
|
||||
"@trpc/client": "^9.8.0",
|
||||
"@trpc/next": "^9.8.0",
|
||||
"@trpc/react": "^9.8.0",
|
||||
"@trpc/server": "^9.8.0",
|
||||
"@types/stripe": "^8.0.417",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"async": "^3.2.1",
|
||||
|
@ -68,7 +72,7 @@
|
|||
"react-intl": "^5.20.7",
|
||||
"react-multi-email": "^0.5.3",
|
||||
"react-phone-number-input": "^3.1.25",
|
||||
"react-query": "^3.21.0",
|
||||
"react-query": "^3.23.1",
|
||||
"react-select": "^4.3.1",
|
||||
"react-timezone-select": "^1.0.7",
|
||||
"react-use-intercom": "1.4.0",
|
||||
|
@ -76,7 +80,8 @@
|
|||
"stripe": "^8.168.0",
|
||||
"tsdav": "1.0.6",
|
||||
"tslog": "^3.2.1",
|
||||
"uuid": "^8.3.2"
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "2.0.4",
|
||||
|
|
|
@ -13,9 +13,10 @@ export type AppProps = NextAppProps & {
|
|||
err?: Error;
|
||||
};
|
||||
|
||||
function MyApp({ Component, pageProps, err }: AppProps) {
|
||||
function MyApp(props: AppProps) {
|
||||
const { Component, pageProps, err } = props;
|
||||
return (
|
||||
<AppProviders pageProps={pageProps}>
|
||||
<AppProviders {...props}>
|
||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||
<Component {...pageProps} err={err} />
|
||||
</AppProviders>
|
||||
|
|
35
pages/api/trpc/[trpc].ts
Normal file
35
pages/api/trpc/[trpc].ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* This file contains tRPC's HTTP response handler
|
||||
*/
|
||||
import { createContext } from "@server/createContext";
|
||||
import { appRouter } from "@server/routers/_app";
|
||||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
/**
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
createContext,
|
||||
/**
|
||||
* @link https://trpc.io/docs/error-handling
|
||||
*/
|
||||
onError({ error }) {
|
||||
if (error.code === "INTERNAL_SERVER_ERROR") {
|
||||
// send to bug reporting
|
||||
console.error("Something went wrong", error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Enable query batching
|
||||
*/
|
||||
batching: {
|
||||
enabled: true,
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/caching#api-response-caching
|
||||
*/
|
||||
// responseMeta() {
|
||||
// // ...
|
||||
// },
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { getCsrfToken, signIn } from "next-auth/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
|
||||
|
@ -26,11 +26,7 @@ export default function Login({ csrfToken }) {
|
|||
const [secondFactorRequired, setSecondFactorRequired] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.query?.callbackUrl) {
|
||||
window.history.replaceState(null, document.title, "?callbackUrl=/");
|
||||
}
|
||||
}, [router.query]);
|
||||
const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/";
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
@ -43,14 +39,20 @@ export default function Login({ csrfToken }) {
|
|||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await signIn("credentials", { redirect: false, email, password, totpCode: code });
|
||||
const response = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email,
|
||||
password,
|
||||
totpCode: code,
|
||||
callbackUrl,
|
||||
});
|
||||
if (!response) {
|
||||
console.error("Received empty response from next auth");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.error) {
|
||||
window.location.reload();
|
||||
router.replace(callbackUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,14 +4,15 @@ import Link from "next/link";
|
|||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Modal from "@components/Modal";
|
||||
import Shell from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
|
||||
export default function Availability(props) {
|
||||
export default function Availability() {
|
||||
const queryMe = trpc.useQuery(["viewer.me"]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
const router = useRouter();
|
||||
|
@ -31,9 +32,13 @@ export default function Availability(props) {
|
|||
const bufferHoursRef = useRef<HTMLInputElement>();
|
||||
const bufferMinsRef = useRef<HTMLInputElement>();
|
||||
|
||||
if (loading) {
|
||||
if (queryMe.status === "loading") {
|
||||
return <Loader />;
|
||||
}
|
||||
if (queryMe.status !== "success") {
|
||||
return <Alert severity="error" title="Something went wrong" />;
|
||||
}
|
||||
const user = queryMe.data;
|
||||
|
||||
function toggleAddModal() {
|
||||
setShowAddModal(!showAddModal);
|
||||
|
@ -126,8 +131,8 @@ export default function Availability(props) {
|
|||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>
|
||||
Currently, your day is set to start at {convertMinsToHrsMins(props.user.startTime)} and end
|
||||
at {convertMinsToHrsMins(props.user.endTime)}.
|
||||
Currently, your day is set to start at {convertMinsToHrsMins(user.startTime)} and end at{" "}
|
||||
{convertMinsToHrsMins(user.endTime)}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
|
@ -199,7 +204,7 @@ export default function Availability(props) {
|
|||
id="hours"
|
||||
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="9"
|
||||
defaultValue={convertMinsToHrsMins(props.user.startTime).split(":")[0]}
|
||||
defaultValue={convertMinsToHrsMins(user.startTime).split(":")[0]}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
|
@ -214,7 +219,7 @@ export default function Availability(props) {
|
|||
id="minutes"
|
||||
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="30"
|
||||
defaultValue={convertMinsToHrsMins(props.user.startTime).split(":")[1]}
|
||||
defaultValue={convertMinsToHrsMins(user.startTime).split(":")[1]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -231,7 +236,7 @@ export default function Availability(props) {
|
|||
id="hours"
|
||||
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="17"
|
||||
defaultValue={convertMinsToHrsMins(props.user.endTime).split(":")[0]}
|
||||
defaultValue={convertMinsToHrsMins(user.endTime).split(":")[0]}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
|
@ -246,7 +251,7 @@ export default function Availability(props) {
|
|||
id="minutes"
|
||||
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="30"
|
||||
defaultValue={convertMinsToHrsMins(props.user.endTime).split(":")[1]}
|
||||
defaultValue={convertMinsToHrsMins(user.endTime).split(":")[1]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -263,7 +268,7 @@ export default function Availability(props) {
|
|||
id="hours"
|
||||
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="0"
|
||||
defaultValue={convertMinsToHrsMins(props.user.bufferTime).split(":")[0]}
|
||||
defaultValue={convertMinsToHrsMins(user.bufferTime).split(":")[0]}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
|
@ -278,7 +283,7 @@ export default function Availability(props) {
|
|||
id="minutes"
|
||||
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="10"
|
||||
defaultValue={convertMinsToHrsMins(props.user.bufferTime).split(":")[1]}
|
||||
defaultValue={convertMinsToHrsMins(user.bufferTime).split(":")[1]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -305,40 +310,3 @@ export default function Availability(props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getSession(context);
|
||||
if (!session) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
},
|
||||
});
|
||||
|
||||
const types = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
props: { session, user, types },
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,35 +1,28 @@
|
|||
// TODO: replace headlessui with radix-ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { ClockIcon, CalendarIcon, XIcon, CheckIcon, BanIcon } from "@heroicons/react/outline";
|
||||
import { BanIcon, CalendarIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { BookingStatus, User } from "@prisma/client";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useSession } from "next-auth/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import classNames from "@lib/classNames";
|
||||
import prisma from "@lib/prisma";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import { Button } from "@components/ui/Button";
|
||||
|
||||
export default function Bookings({ bookings }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [session, loading] = useSession();
|
||||
|
||||
const isEmpty = Object.keys(bookings).length === 0;
|
||||
export default function Bookings() {
|
||||
const query = trpc.useQuery(["viewer.bookings"]);
|
||||
const bookings = query.data;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
async function confirmBookingHandler(booking, confirm: boolean) {
|
||||
async function confirmBookingHandler(booking: { id: number }, confirm: boolean) {
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
|
||||
|
@ -43,12 +36,16 @@ export default function Bookings({ bookings }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell heading="Bookings" subtitle="See upcoming and past events booked through your event type links.">
|
||||
<div className="-mx-4 sm:mx-auto flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
{isEmpty ? (
|
||||
<Shell heading="Bookings" subtitle="See upcoming and past events booked through your event type links.">
|
||||
<div className="-mx-4 sm:mx-auto flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title="Something went wrong" message={query.error.message} />
|
||||
)}
|
||||
{query.status === "loading" && <Loader />}
|
||||
{bookings &&
|
||||
(bookings.length === 0 ? (
|
||||
<EmptyScreen
|
||||
Icon={CalendarIcon}
|
||||
headline="No upcoming bookings, yet"
|
||||
|
@ -282,75 +279,10 @@ export default function Bookings({ bookings }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getSession(context);
|
||||
|
||||
if (!session) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const user: User = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const b = await prisma.booking.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: session.user.id,
|
||||
},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
description: true,
|
||||
attendees: true,
|
||||
confirmed: true,
|
||||
rejected: true,
|
||||
id: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventType: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
status: true,
|
||||
},
|
||||
orderBy: {
|
||||
startTime: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = b.reverse().map((booking) => {
|
||||
return { ...booking, startTime: booking.startTime.toISOString(), endTime: booking.endTime.toISOString() };
|
||||
});
|
||||
|
||||
return { props: { session, bookings } };
|
||||
}
|
||||
|
|
68
server/createContext.ts
Normal file
68
server/createContext.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import * as trpc from "@trpc/server";
|
||||
import { Maybe } from "@trpc/server";
|
||||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
|
||||
import { getSession, Session } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
async function getUserFromSession(session: Maybe<Session>) {
|
||||
if (!session?.user?.id) {
|
||||
return null;
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
theme: true,
|
||||
createdDate: true,
|
||||
hideBranding: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
// some hacks to make sure `username` and `email` are never inferred as `null`
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const { email, username } = user;
|
||||
if (!username || !email) {
|
||||
return null;
|
||||
}
|
||||
const avatar = user.avatar || defaultAvatarSrc({ email });
|
||||
return {
|
||||
...user,
|
||||
avatar,
|
||||
email,
|
||||
username,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates context for an incoming request
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
export const createContext = async ({ req, res }: trpcNext.CreateNextContextOptions) => {
|
||||
// for API-response caching see https://trpc.io/docs/caching
|
||||
const session = await getSession({ req });
|
||||
|
||||
return {
|
||||
prisma,
|
||||
session,
|
||||
user: await getUserFromSession(session),
|
||||
};
|
||||
};
|
||||
|
||||
export type Context = trpc.inferAsyncReturnType<typeof createContext>;
|
10
server/createRouter.ts
Normal file
10
server/createRouter.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import * as trpc from "@trpc/server";
|
||||
|
||||
import { Context } from "./createContext";
|
||||
|
||||
/**
|
||||
* Helper function to create a router with context
|
||||
*/
|
||||
export function createRouter() {
|
||||
return trpc.router<Context>();
|
||||
}
|
26
server/routers/_app.ts
Normal file
26
server/routers/_app.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* This file contains the root router of your tRPC-backend
|
||||
*/
|
||||
import { createRouter } from "../createRouter";
|
||||
import { viewerRouter } from "./viewer";
|
||||
|
||||
/**
|
||||
* Create your application's root router
|
||||
* If you want to use SSG, you need export this
|
||||
* @link https://trpc.io/docs/ssg
|
||||
* @link https://trpc.io/docs/router
|
||||
*/
|
||||
export const appRouter = createRouter()
|
||||
/**
|
||||
* Add data transformers
|
||||
* @link https://trpc.io/docs/data-transformers
|
||||
*/
|
||||
// .transformer(superjson)
|
||||
/**
|
||||
* Optionally do custom error (type safe!) formatting
|
||||
* @link https://trpc.io/docs/error-formatting
|
||||
*/
|
||||
// .formatError(({ shape, error }) => { })
|
||||
.merge("viewer.", viewerRouter);
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
81
server/routers/viewer.tsx
Normal file
81
server/routers/viewer.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createRouter } from "../createRouter";
|
||||
|
||||
// routes only available to authenticated users
|
||||
export const viewerRouter = createRouter()
|
||||
// check that user is authenticated
|
||||
.middleware(({ ctx, next }) => {
|
||||
const { user } = ctx;
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
// session value is known to be non-null now
|
||||
user,
|
||||
},
|
||||
});
|
||||
})
|
||||
.query("me", {
|
||||
resolve({ ctx }) {
|
||||
return ctx.user;
|
||||
},
|
||||
})
|
||||
.query("bookings", {
|
||||
async resolve({ ctx }) {
|
||||
const { prisma, user } = ctx;
|
||||
const bookingsQuery = await prisma.booking.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
description: true,
|
||||
attendees: true,
|
||||
confirmed: true,
|
||||
rejected: true,
|
||||
id: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventType: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
status: true,
|
||||
},
|
||||
orderBy: {
|
||||
startTime: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = bookingsQuery.reverse().map((booking) => {
|
||||
return {
|
||||
...booking,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return bookings;
|
||||
},
|
||||
});
|
14
server/ssg.ts
Normal file
14
server/ssg.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { createSSGHelpers } from "@trpc/react/ssg";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { appRouter } from "./routers/_app";
|
||||
|
||||
export const ssg = createSSGHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
prisma,
|
||||
session: null,
|
||||
user: null,
|
||||
},
|
||||
});
|
|
@ -6,11 +6,13 @@
|
|||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@server/*": ["server/*"],
|
||||
"@ee/*": ["ee/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
|
|
64
yarn.lock
64
yarn.lock
|
@ -282,7 +282,7 @@
|
|||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.10.5", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7":
|
||||
"@babel/runtime@^7.10.5", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0":
|
||||
version "7.15.4"
|
||||
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz"
|
||||
dependencies:
|
||||
|
@ -1006,19 +1006,12 @@
|
|||
"@radix-ui/react-primitive" "0.1.0"
|
||||
"@radix-ui/react-use-callback-ref" "0.1.0"
|
||||
|
||||
"@radix-ui/react-id@0.1.0":
|
||||
"@radix-ui/react-id@0.1.0", "@radix-ui/react-id@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.0.tgz"
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-id@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-0.1.0.tgz#d01067520fb8f4b09da3f914bfe6cb0f88c26721"
|
||||
integrity sha512-SubMSz7rAtl6w8qZ9YBRbDe9GjW36JugBsc6aYqng8tFydvNtkuBMj86zN/x5QiomMo+r8ylBVvuWzRkS0WbBA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-label@0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-0.1.0.tgz"
|
||||
|
@ -1281,6 +1274,41 @@
|
|||
javascript-natural-sort "0.7.1"
|
||||
lodash "4.17.21"
|
||||
|
||||
"@trpc/client@^9.8.0":
|
||||
version "9.8.0"
|
||||
resolved "https://registry.npmjs.org/@trpc/client/-/client-9.8.0.tgz#e60f4ff1fff7c34b1f36e240441022192c463d6e"
|
||||
integrity sha512-YGUJI8EvAykXKciDe62aLNwk4TNh4bX+qHSWtgbF5d6qRQOwViINpB6vR8YSugPvmC4F+hvKm61wSd01cCWN4g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.0"
|
||||
"@trpc/server" "^9.8.0"
|
||||
|
||||
"@trpc/next@^9.8.0":
|
||||
version "9.8.0"
|
||||
resolved "https://registry.npmjs.org/@trpc/next/-/next-9.8.0.tgz#ab76cf56de604551565f13509f9163fb98dc8b1a"
|
||||
integrity sha512-VfpTPtFt8E2lgHVolFtE90DkP2mV8CqdvLQKQ8gY4OfsOoAlitnJPAVeAxvHPVruUyDUjuggFocc1TZnEWRdxg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.0"
|
||||
"@trpc/client" "^9.8.0"
|
||||
"@trpc/react" "^9.8.0"
|
||||
"@trpc/server" "^9.8.0"
|
||||
react-ssr-prepass "^1.4.0"
|
||||
|
||||
"@trpc/react@^9.8.0":
|
||||
version "9.8.0"
|
||||
resolved "https://registry.npmjs.org/@trpc/react/-/react-9.8.0.tgz#1ae46b84da9fb4e257335e6bdb2a489d70a1a9b2"
|
||||
integrity sha512-vErvC98QBQh0XzfPm9LA/dmGHBm6N9/m+B3XdIkimGOD055bsxgKMLW25BeeDV3gLWlzQJ7nwOrsxKRd6fLi3w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.0"
|
||||
"@trpc/client" "^9.8.0"
|
||||
"@trpc/server" "^9.8.0"
|
||||
|
||||
"@trpc/server@^9.8.0":
|
||||
version "9.8.0"
|
||||
resolved "https://registry.npmjs.org/@trpc/server/-/server-9.8.0.tgz#f7e8a0ab46cc41179dc06722cb3dbe33901eddb6"
|
||||
integrity sha512-YFmS+5SwDQ9NRO9JvNyl1oLprWE2AwS2huXYcGo9e3Fl5ju2Q2MN0JYvVh7XYNyp1rI2EKqVVRzXEuCNk+3vVQ==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
|
||||
"@tsconfig/node10@^1.0.7":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz"
|
||||
|
@ -6004,10 +6032,10 @@ react-portal@^4.2.0:
|
|||
dependencies:
|
||||
prop-types "^15.5.8"
|
||||
|
||||
react-query@^3.21.0:
|
||||
version "3.24.4"
|
||||
resolved "https://registry.npmjs.org/react-query/-/react-query-3.24.4.tgz"
|
||||
integrity sha512-p/t18+FN5P//bk/xR39r4JRWEigYzia2+J3lmKWSZHYbcivQlygJixY+81NiTNxT1P+/P6cl173b1lEbh1R8yQ==
|
||||
react-query@^3.23.1:
|
||||
version "3.23.1"
|
||||
resolved "https://registry.npmjs.org/react-query/-/react-query-3.23.1.tgz#cde2d268958716d34a23e62aabba668752ba8f95"
|
||||
integrity sha512-pq0vEwB5PNGvkWJNUk0qPpsxcDmhzY80ZLNPLIVQJ3k2UyXoGccPTrgOIj4Kz2TrMfgvRBTNwiSxHdaW7Sl0WQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.5.5"
|
||||
broadcast-channel "^3.4.1"
|
||||
|
@ -6046,6 +6074,11 @@ react-select@^4.3.1:
|
|||
react-input-autosize "^3.0.0"
|
||||
react-transition-group "^4.3.0"
|
||||
|
||||
react-ssr-prepass@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.4.0.tgz#33a3db19414f0f8f9f3f781c88f760ae366b4f51"
|
||||
integrity sha512-0SzdmiQUtHvhxCabHg9BI/pkJfijGkQ0jQL6fC4YFy7idaDOuaiQLsajIkkNxffFXtJFHIWFITlve2WB88e0Jw==
|
||||
|
||||
react-style-singleton@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.1.tgz"
|
||||
|
@ -7421,3 +7454,8 @@ zen-observable-ts@^1.0.0:
|
|||
zen-observable@0.8.15:
|
||||
version "0.8.15"
|
||||
resolved "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz"
|
||||
|
||||
zod@^3.8.2:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.npmjs.org/zod/-/zod-3.8.2.tgz#f25b78bc76e64f31318d242e301c23d3d610b7a1"
|
||||
integrity sha512-kpwVRACazsOhELVt5h4R2pC2OndrqaBK4+z134TWOsnzn7n2uOYnSyvx0QAn410pl28CgVtkSi5ew7e/AgO0oA==
|
||||
|
|
Loading…
Reference in a new issue