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, + }; + }, +});