statically render profile pages (#615)
This commit is contained in:
parent
34300650e4
commit
649e79bdc7
7 changed files with 152 additions and 81 deletions
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
|
@ -4,6 +4,19 @@ jobs:
|
||||||
build:
|
build:
|
||||||
name: Build on Node ${{ matrix.node }} and ${{ matrix.os }}
|
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 }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
@ -28,5 +41,6 @@ jobs:
|
||||||
path: ${{ github.workspace }}/.next/cache
|
path: ${{ github.workspace }}/.next/cache
|
||||||
key: ${{ runner.os }}-nextjs
|
key: ${{ runner.os }}-nextjs
|
||||||
|
|
||||||
|
- run: yarn prisma migrate deploy
|
||||||
- run: yarn test
|
- run: yarn test
|
||||||
- run: yarn build
|
- run: yarn build
|
||||||
|
|
3
.github/workflows/e2e.yml
vendored
3
.github/workflows/e2e.yml
vendored
|
@ -53,9 +53,10 @@ jobs:
|
||||||
path: ${{ github.workspace }}/.next/cache
|
path: ${{ github.workspace }}/.next/cache
|
||||||
key: ${{ runner.os }}-nextjs
|
key: ${{ runner.os }}-nextjs
|
||||||
|
|
||||||
- run: yarn build
|
- run: yarn test
|
||||||
- run: yarn prisma migrate deploy
|
- run: yarn prisma migrate deploy
|
||||||
- run: yarn db-seed
|
- run: yarn db-seed
|
||||||
|
- run: yarn build
|
||||||
- run: yarn start &
|
- run: yarn start &
|
||||||
- run: npx wait-port 3000 --timeout 10000
|
- run: npx wait-port 3000 --timeout 10000
|
||||||
- run: yarn cypress run
|
- run: yarn cypress run
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
|
import { Maybe } from "@trpc/server";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
// makes sure the ui doesn't flash
|
// 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);
|
const [isReady, setIsReady] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (!theme) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
if (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.add(theme);
|
document.documentElement.classList.add(theme);
|
||||||
}
|
}
|
||||||
setIsReady(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -26,3 +26,6 @@ export type inferQueryInput<TRouteKey extends keyof AppRouter["_def"]["queries"]
|
||||||
export type inferMutationInput<TRouteKey extends keyof AppRouter["_def"]["mutations"]> = inferProcedureInput<
|
export type inferMutationInput<TRouteKey extends keyof AppRouter["_def"]["mutations"]> = inferProcedureInput<
|
||||||
AppRouter["_def"]["mutations"][TRouteKey]
|
AppRouter["_def"]["mutations"][TRouteKey]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type inferMutationOutput<TRouteKey extends keyof AppRouter["_def"]["mutations"]> =
|
||||||
|
inferProcedureOutput<AppRouter["_def"]["mutations"][TRouteKey]>;
|
||||||
|
|
128
pages/[user].tsx
128
pages/[user].tsx
|
@ -1,48 +1,58 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
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 Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { trpc } from "@lib/trpc";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
|
|
||||||
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
export default function User(props: inferSSRProps<typeof getStaticProps>) {
|
||||||
const { isReady } = useTheme(props.user.theme);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
title={props.user.name || props.user.username}
|
title={user.name || user.username}
|
||||||
description={props.user.name || props.user.username}
|
description={user.name || user.username}
|
||||||
name={props.user.name || props.user.username}
|
name={user.name || user.username}
|
||||||
avatar={props.user.avatar}
|
avatar={user.avatar}
|
||||||
/>
|
/>
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<div className="bg-neutral-50 dark:bg-black h-screen">
|
<div className="bg-neutral-50 dark:bg-black h-screen">
|
||||||
<main className="max-w-3xl mx-auto py-24 px-4">
|
<main className="max-w-3xl mx-auto py-24 px-4">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
imageSrc={props.user.avatar}
|
imageSrc={user.avatar}
|
||||||
displayName={props.user.name}
|
displayName={user.name}
|
||||||
className="mx-auto w-24 h-24 rounded-full mb-4"
|
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">
|
<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>
|
</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>
|
||||||
<div className="space-y-6" data-testid="event-types">
|
<div className="space-y-6" data-testid="event-types">
|
||||||
{props.eventTypes.map((type) => (
|
{eventTypes.map((type) => (
|
||||||
<div
|
<div
|
||||||
key={type.id}
|
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">
|
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" />
|
<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">
|
<a className="block px-6 py-4">
|
||||||
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||||
<EventTypeDescription eventType={type} />
|
<EventTypeDescription eventType={type} />
|
||||||
|
@ -51,7 +61,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{props.eventTypes.length == 0 && (
|
{eventTypes.length === 0 && (
|
||||||
<div className="shadow overflow-hidden rounded-sm">
|
<div className="shadow overflow-hidden rounded-sm">
|
||||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
<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>
|
<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) => {
|
export const getStaticPaths: GetStaticPaths = async () => {
|
||||||
const username = (context.query.user as string).toLowerCase();
|
const allUsers = await prisma.user.findMany({
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
username,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
},
|
||||||
name: true,
|
where: {
|
||||||
bio: true,
|
// will statically render everyone on the PRO plan
|
||||||
avatar: true,
|
// the rest will be statically rendered on first visit
|
||||||
theme: true,
|
plan: "PRO",
|
||||||
plan: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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 {
|
return {
|
||||||
notFound: true,
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
eventTypes,
|
trpcState: ssg.dehydrate(),
|
||||||
user,
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
* This file contains the root router of your tRPC-backend
|
* This file contains the root router of your tRPC-backend
|
||||||
*/
|
*/
|
||||||
import { createRouter } from "../createRouter";
|
import { createRouter } from "../createRouter";
|
||||||
|
import { bookingRouter } from "./booking";
|
||||||
import { viewerRouter } from "./viewer";
|
import { viewerRouter } from "./viewer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,6 +22,7 @@ export const appRouter = createRouter()
|
||||||
* @link https://trpc.io/docs/error-formatting
|
* @link https://trpc.io/docs/error-formatting
|
||||||
*/
|
*/
|
||||||
// .formatError(({ shape, error }) => { })
|
// .formatError(({ shape, error }) => { })
|
||||||
.merge("viewer.", viewerRouter);
|
.merge("viewer.", viewerRouter)
|
||||||
|
.merge("booking.", bookingRouter);
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
73
server/routers/booking.tsx
Normal file
73
server/routers/booking.tsx
Normal file
|
@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
Loading…
Reference in a new issue