From 649e79bdc79551fe0008060577f35ffdef6471eb Mon Sep 17 00:00:00 2001
From: Alex Johansson <alexander@n1s.se>
Date: Mon, 27 Sep 2021 17:09:19 +0100
Subject: [PATCH] statically render profile pages (#615)

---
 .github/workflows/build.yml |  14 ++++
 .github/workflows/e2e.yml   |   3 +-
 lib/hooks/useTheme.tsx      |   8 ++-
 lib/trpc.ts                 |   3 +
 pages/[user].tsx            | 128 ++++++++++++++----------------------
 server/routers/_app.ts      |   4 +-
 server/routers/booking.tsx  |  73 ++++++++++++++++++++
 7 files changed, 152 insertions(+), 81 deletions(-)
 create mode 100644 server/routers/booking.tsx

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9da61565..b5b040de 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,6 +4,19 @@ jobs:
   build:
     name: Build on Node ${{ matrix.node }} and ${{ matrix.os }}
 
+    env:
+      DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
+      NODE_ENV: test
+      BASE_URL: http://localhost:3000
+      JWT_SECRET: secret
+    services:
+      postgres:
+        image: postgres:12.1
+        env:
+          POSTGRES_USER: postgres
+          POSTGRES_DB: calendso
+        ports:
+          - 5432:5432
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
@@ -28,5 +41,6 @@ jobs:
           path: ${{ github.workspace }}/.next/cache
           key: ${{ runner.os }}-nextjs
 
+      - run: yarn prisma migrate deploy
       - run: yarn test
       - run: yarn build
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index dbadbf61..973b4e14 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -53,9 +53,10 @@ jobs:
           path: ${{ github.workspace }}/.next/cache
           key: ${{ runner.os }}-nextjs
 
-      - run: yarn build
+      - run: yarn test
       - run: yarn prisma migrate deploy
       - run: yarn db-seed
+      - run: yarn build
       - run: yarn start &
       - run: npx wait-port 3000 --timeout 10000
       - run: yarn cypress run
diff --git a/lib/hooks/useTheme.tsx b/lib/hooks/useTheme.tsx
index 09d505b9..99ad205e 100644
--- a/lib/hooks/useTheme.tsx
+++ b/lib/hooks/useTheme.tsx
@@ -1,15 +1,19 @@
+import { Maybe } from "@trpc/server";
 import { useEffect, useState } from "react";
 
 // makes sure the ui doesn't flash
-export default function useTheme(theme?: string) {
+export default function useTheme(theme?: Maybe<string>) {
   const [isReady, setIsReady] = useState(false);
   useEffect(() => {
+    setIsReady(true);
+    if (!theme) {
+      return;
+    }
     if (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) {
       document.documentElement.classList.add("dark");
     } else {
       document.documentElement.classList.add(theme);
     }
-    setIsReady(true);
   }, []);
 
   return {
diff --git a/lib/trpc.ts b/lib/trpc.ts
index 12f74345..a008c73f 100644
--- a/lib/trpc.ts
+++ b/lib/trpc.ts
@@ -26,3 +26,6 @@ export type inferQueryInput<TRouteKey extends keyof AppRouter["_def"]["queries"]
 export type inferMutationInput<TRouteKey extends keyof AppRouter["_def"]["mutations"]> = inferProcedureInput<
   AppRouter["_def"]["mutations"][TRouteKey]
 >;
+
+export type inferMutationOutput<TRouteKey extends keyof AppRouter["_def"]["mutations"]> =
+  inferProcedureOutput<AppRouter["_def"]["mutations"][TRouteKey]>;
diff --git a/pages/[user].tsx b/pages/[user].tsx
index dad96ec0..cf6f536e 100644
--- a/pages/[user].tsx
+++ b/pages/[user].tsx
@@ -1,48 +1,58 @@
 import { ArrowRightIcon } from "@heroicons/react/outline";
-import { GetServerSidePropsContext } from "next";
+import { ssg } from "@server/ssg";
+import { GetStaticPaths, GetStaticPropsContext } from "next";
 import Link from "next/link";
 import React from "react";
 
 import useTheme from "@lib/hooks/useTheme";
 import prisma from "@lib/prisma";
+import { trpc } from "@lib/trpc";
 import { inferSSRProps } from "@lib/types/inferSSRProps";
 
 import EventTypeDescription from "@components/eventtype/EventTypeDescription";
 import { HeadSeo } from "@components/seo/head-seo";
 import Avatar from "@components/ui/Avatar";
 
-export default function User(props: inferSSRProps<typeof getServerSideProps>) {
-  const { isReady } = useTheme(props.user.theme);
+export default function User(props: inferSSRProps<typeof getStaticProps>) {
+  const { username } = props;
+  // data of query below will be will be prepopulated b/c of `getStaticProps`
+  const query = trpc.useQuery(["booking.userEventTypes", { username }]);
+  const { isReady } = useTheme(query.data?.user.theme);
+  if (!query.data) {
+    // this shold never happen as we do `blocking: true`
+    return <>...</>;
+  }
+  const { user, eventTypes } = query.data;
 
   return (
     <>
       <HeadSeo
-        title={props.user.name || props.user.username}
-        description={props.user.name || props.user.username}
-        name={props.user.name || props.user.username}
-        avatar={props.user.avatar}
+        title={user.name || user.username}
+        description={user.name || user.username}
+        name={user.name || user.username}
+        avatar={user.avatar}
       />
       {isReady && (
         <div className="bg-neutral-50 dark:bg-black h-screen">
           <main className="max-w-3xl mx-auto py-24 px-4">
             <div className="mb-8 text-center">
               <Avatar
-                imageSrc={props.user.avatar}
-                displayName={props.user.name}
+                imageSrc={user.avatar}
+                displayName={user.name}
                 className="mx-auto w-24 h-24 rounded-full mb-4"
               />
               <h1 className="font-cal text-3xl font-bold text-neutral-900 dark:text-white mb-1">
-                {props.user.name || props.user.username}
+                {user.name || user.username}
               </h1>
-              <p className="text-neutral-500 dark:text-white">{props.user.bio}</p>
+              <p className="text-neutral-500 dark:text-white">{user.bio}</p>
             </div>
             <div className="space-y-6" data-testid="event-types">
-              {props.eventTypes.map((type) => (
+              {eventTypes.map((type) => (
                 <div
                   key={type.id}
                   className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-black rounded-sm">
                   <ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
-                  <Link href={`/${props.user.username}/${type.slug}`}>
+                  <Link href={`/${user.username}/${type.slug}`}>
                     <a className="block px-6 py-4">
                       <h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
                       <EventTypeDescription eventType={type} />
@@ -51,7 +61,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
                 </div>
               ))}
             </div>
-            {props.eventTypes.length == 0 && (
+            {eventTypes.length === 0 && (
               <div className="shadow overflow-hidden rounded-sm">
                 <div className="p-8 text-center text-gray-400 dark:text-white">
                   <h2 className="font-cal font-semibold text-3xl text-gray-600 dark:text-white">Uh oh!</h2>
@@ -66,79 +76,43 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
   );
 }
 
-export const getServerSideProps = async (context: GetServerSidePropsContext) => {
-  const username = (context.query.user as string).toLowerCase();
-
-  const user = await prisma.user.findUnique({
-    where: {
-      username,
-    },
+export const getStaticPaths: GetStaticPaths = async () => {
+  const allUsers = await prisma.user.findMany({
     select: {
-      id: true,
       username: true,
-      email: true,
-      name: true,
-      bio: true,
-      avatar: true,
-      theme: true,
-      plan: true,
+    },
+    where: {
+      // will statically render everyone on the PRO plan
+      // the rest will be statically rendered on first visit
+      plan: "PRO",
     },
   });
-  if (!user) {
+  const usernames = allUsers.flatMap((u) => (u.username ? [u.username] : []));
+  return {
+    paths: usernames.map((user) => ({
+      params: { user },
+    })),
+
+    // https://nextjs.org/docs/basic-features/data-fetching#fallback-blocking
+    fallback: "blocking",
+  };
+};
+
+export async function getStaticProps(context: GetStaticPropsContext<{ user: string }>) {
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const username = context.params!.user;
+  const data = await ssg.fetchQuery("booking.userEventTypes", { username });
+
+  if (!data) {
     return {
       notFound: true,
     };
   }
-
-  const eventTypesWithHidden = await prisma.eventType.findMany({
-    where: {
-      AND: [
-        {
-          teamId: null,
-        },
-        {
-          OR: [
-            {
-              userId: user.id,
-            },
-            {
-              users: {
-                some: {
-                  id: user.id,
-                },
-              },
-            },
-          ],
-        },
-      ],
-    },
-    select: {
-      id: true,
-      slug: true,
-      title: true,
-      length: true,
-      description: true,
-      hidden: true,
-      schedulingType: true,
-      price: true,
-      currency: true,
-    },
-    take: user.plan === "FREE" ? 1 : undefined,
-  });
-  const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden);
   return {
     props: {
-      eventTypes,
-      user,
+      trpcState: ssg.dehydrate(),
+      username,
     },
+    revalidate: 1,
   };
-};
-
-// Auxiliary methods
-export function getRandomColorCode(): string {
-  let color = "#";
-  for (let idx = 0; idx < 6; idx++) {
-    color += Math.floor(Math.random() * 10);
-  }
-  return color;
 }
diff --git a/server/routers/_app.ts b/server/routers/_app.ts
index 5809af52..2093a8b5 100644
--- a/server/routers/_app.ts
+++ b/server/routers/_app.ts
@@ -2,6 +2,7 @@
  * This file contains the root router of your tRPC-backend
  */
 import { createRouter } from "../createRouter";
+import { bookingRouter } from "./booking";
 import { viewerRouter } from "./viewer";
 
 /**
@@ -21,6 +22,7 @@ export const appRouter = createRouter()
    * @link https://trpc.io/docs/error-formatting
    */
   // .formatError(({ shape, error }) => { })
-  .merge("viewer.", viewerRouter);
+  .merge("viewer.", viewerRouter)
+  .merge("booking.", bookingRouter);
 
 export type AppRouter = typeof appRouter;
diff --git a/server/routers/booking.tsx b/server/routers/booking.tsx
new file mode 100644
index 00000000..bb3443b9
--- /dev/null
+++ b/server/routers/booking.tsx
@@ -0,0 +1,73 @@
+import { z } from "zod";
+
+import { createRouter } from "../createRouter";
+
+export const bookingRouter = createRouter().query("userEventTypes", {
+  input: z.object({
+    username: z.string().min(1),
+  }),
+  async resolve({ input, ctx }) {
+    const { prisma } = ctx;
+    const { username } = input;
+
+    const user = await prisma.user.findUnique({
+      where: {
+        username,
+      },
+      select: {
+        id: true,
+        username: true,
+        email: true,
+        name: true,
+        bio: true,
+        avatar: true,
+        theme: true,
+        plan: true,
+      },
+    });
+    if (!user) {
+      return null;
+    }
+
+    const eventTypesWithHidden = await prisma.eventType.findMany({
+      where: {
+        AND: [
+          {
+            teamId: null,
+          },
+          {
+            OR: [
+              {
+                userId: user.id,
+              },
+              {
+                users: {
+                  some: {
+                    id: user.id,
+                  },
+                },
+              },
+            ],
+          },
+        ],
+      },
+      select: {
+        id: true,
+        slug: true,
+        title: true,
+        length: true,
+        description: true,
+        hidden: true,
+        schedulingType: true,
+        price: true,
+        currency: true,
+      },
+      take: user.plan === "FREE" ? 1 : undefined,
+    });
+    const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden);
+    return {
+      user,
+      eventTypes,
+    };
+  },
+});