Compare commits

...

52 Commits

Author SHA1 Message Date
kodiakhq[bot] 9022e6b556
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-07 15:32:48 +00:00
kodiakhq[bot] 33cca1c574
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-07 15:05:19 +00:00
kodiakhq[bot] 13c6df1c02
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-07 10:09:14 +00:00
kodiakhq[bot] 527cdb60e3
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-07 10:03:44 +00:00
kodiakhq[bot] f4d8abf365
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-07 10:02:26 +00:00
kodiakhq[bot] d3f138cefa
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-06 19:35:11 +00:00
kodiakhq[bot] b39922f2b9
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-06 13:25:37 +00:00
kodiakhq[bot] 72ea0ad769
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-06 13:25:15 +00:00
kodiakhq[bot] 5f2aa280f8
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-06 11:37:57 +00:00
kodiakhq[bot] ea5de84f5f
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-06 11:23:18 +00:00
kodiakhq[bot] c250ae9e0e
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-06 09:50:36 +00:00
kodiakhq[bot] c8027c4cab
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-03 16:51:18 +00:00
kodiakhq[bot] 6f0a8f5d0f
Merge branch 'main' into katt/cal-620-edge-fns 2021-12-03 16:19:19 +00:00
Alex van Andel 6b2a4d00e1 Merge branch 'main' into katt/cal-620-edge-fns 2021-12-03 12:49:20 +01:00
kodiakhq[bot] 4f7175edc0
Merge branch 'main' into katt/cal-620-edge-fns 2021-11-24 11:45:56 +00:00
kodiakhq[bot] d3cdd02612
Merge branch 'main' into katt/cal-620-edge-fns 2021-11-24 10:43:48 +00:00
kodiakhq[bot] c225b5ffe4
Merge branch 'main' into katt/cal-620-edge-fns 2021-11-24 09:53:53 +00:00
kodiakhq[bot] 180afa08de
Merge branch 'main' into katt/cal-620-edge-fns 2021-11-22 11:38:00 +00:00
kodiakhq[bot] 9dac687ae7
Merge branch 'main' into katt/cal-620-edge-fns 2021-11-22 11:08:06 +00:00
Omar López 27b9b57d17 Simplified locale redirect logic 2021-11-19 11:38:51 -07:00
Omar López 56f8baf503 Fixes broken font url 2021-11-19 11:36:58 -07:00
KATT bb4710a0b8 tweak `useRouterBasePath` 2021-11-19 13:58:49 +01:00
KATT e593f61363 ts tweaks 2021-11-19 13:01:19 +01:00
KATT 9d74d371f2 fix 2021-11-19 12:57:37 +01:00
KATT f2e92f830f tweak 2021-11-19 12:54:23 +01:00
KATT 3031cea99c lint fix 2021-11-19 12:47:51 +01:00
KATT 2b4eb6c977 fix date 2021-11-19 12:44:57 +01:00
KATT 346fd1c5ad wip 2021-11-19 12:34:28 +01:00
KATT 6cd30f0a41 make path prettier 2021-11-19 12:28:30 +01:00
KATT 22545c3e40 make linking prettier 2021-11-19 12:18:25 +01:00
KATT 3658348f85 tweaks 2021-11-19 12:14:10 +01:00
KATT 739f112792 cleanup 2021-11-19 12:09:52 +01:00
KATT bc5289f2e1 fix test 2021-11-19 12:05:38 +01:00
KATT 0e2dbe51e6 fix 2021-11-19 12:01:43 +01:00
KATT 3f54cb04af tweak 2021-11-19 11:50:01 +01:00
KATT d41f225b15 port fix 2021-11-19 11:41:12 +01:00
KATT bd339e2ac3 bad merge 2021-11-19 11:39:41 +01:00
KATT d138f59264 tweak 2021-11-19 11:38:42 +01:00
KATT fbc5ac4c14 Merge remote-tracking branch 'origin/main' into katt/cal-620-edge-fns
# Conflicts:
#	components/booking/DatePicker.tsx
#	pages/[locale]/[user].tsx
#	pages/[user]/[type].tsx
2021-11-19 11:37:56 +01:00
KATT b495e03e16 tweak 2021-11-15 11:05:47 +01:00
KATT 1bd9780f27 add happy path book an event 2021-11-15 11:03:18 +01:00
KATT fe1f4627be fix bad merge 2021-11-15 10:23:12 +01:00
KATT 4677f71923 fix lockfile 2021-11-15 10:08:24 +01:00
KATT 8ec0c30afd Merge remote-tracking branch 'origin/main' into katt/cal-620-edge-fns
# Conflicts:
#	pages/[user]/[type].tsx
#	yarn.lock
2021-11-15 10:06:38 +01:00
mihaic195 95b645ce4a
fix reschedule and teams 2021-11-10 17:20:57 +02:00
KATT 1b099036f5 more fixes 2021-11-08 18:26:56 +01:00
KATT 75358cf348 seems to kinda work 2021-11-08 17:22:37 +01:00
KATT 6257dee4e1 rm vercel config 2021-11-08 16:47:03 +01:00
KATT d25f72cfef wip --- just showing that Link works 2021-11-05 12:18:15 +00:00
KATT 60867a98ed Merge remote-tracking branch 'origin/main' into katt/cal-620-edge-fns
# Conflicts:
#	lib/app-providers.tsx
#	next.config.js
#	package.json
#	yarn.lock
2021-11-05 11:25:37 +00:00
KATT c888a18bd2 might fix middleware 2021-10-27 04:24:43 +02:00
KATT 87cd589112 edge functions init 2021-10-27 01:16:31 +02:00
20 changed files with 1307 additions and 1072 deletions

View File

@ -3,13 +3,30 @@ import { SchedulingType } from "@prisma/client";
import { Dayjs } from "dayjs";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { FC } from "react";
import React, { FC, useMemo } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { useSlots } from "@lib/hooks/useSlots";
import Loader from "@components/Loader";
/**
* @returns i.e. `/peer` for users or `/team/cal` for teams
*/
function useRouterBasePath() {
const router = useRouter();
return useMemo(() => {
const path = router.asPath.split("/").filter(Boolean);
// For teams
if (path[0] === "team") {
return `${path[0]}/${path[1]}`;
}
return path[0] as string;
}, [router.asPath]);
}
type AvailableTimesProps = {
timeFormat: string;
minimumBookingNotice: number;
@ -44,6 +61,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
eventTypeId,
});
const basePath = useRouterBasePath();
return (
<div className="flex flex-col mt-8 text-center sm:pl-4 sm:mt-0 sm:w-1/3 md:-mb-5">
<div className="mb-4 text-lg font-light text-left text-gray-600">
@ -59,30 +78,20 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
{!loading &&
slots?.length > 0 &&
slots.map((slot) => {
type BookingURL = {
pathname: string;
query: Record<string, string | number | string[] | undefined>;
};
const bookingUrl: BookingURL = {
pathname: "book",
const url = {
pathname: `/${basePath}/book`,
query: {
...router.query,
date: slot.time.format(),
type: eventTypeId,
date: slot.time.format(),
// conditionally add things to query params
...(rescheduleUid ? { rescheduleUid } : {}),
...(schedulingType === SchedulingType.ROUND_ROBIN ? { user: slot.users } : {}),
},
};
if (rescheduleUid) {
bookingUrl.query.rescheduleUid = rescheduleUid as string;
}
if (schedulingType === SchedulingType.ROUND_ROBIN) {
bookingUrl.query.user = slot.users;
}
return (
<div key={slot.time.format()}>
<Link href={bookingUrl}>
<Link href={url} as={url}>
<a
className="block py-4 mb-2 font-medium bg-white border rounded-sm dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border-brand dark:border-transparent hover:text-white hover:bg-brand dark:hover:border-black dark:hover:bg-black"
data-testid="time">

View File

@ -4,10 +4,12 @@ import dayjs, { Dayjs } from "dayjs";
// Then, include dayjs-business-time
import dayjsBusinessTime from "dayjs-business-time";
import utc from "dayjs/plugin/utc";
import Link from "next/link";
import { useEffect, useState } from "react";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { useRouterAsPath } from "@lib/hooks/useRouterPath";
import getSlots from "@lib/slots";
import { WorkingHours } from "@lib/types/schedule";
@ -16,7 +18,6 @@ dayjs.extend(utc);
type DatePickerProps = {
weekStart: string;
onDatePicked: (pickedDate: Dayjs) => void;
workingHours: WorkingHours[];
eventLength: number;
date: Dayjs | null;
@ -26,11 +27,11 @@ type DatePickerProps = {
periodDays: number | null;
periodCountCalendarDays: boolean | null;
minimumBookingNotice: number;
rescheduleUid: string | undefined;
};
function DatePicker({
weekStart,
onDatePicked,
workingHours,
eventLength,
date,
@ -40,10 +41,11 @@ function DatePicker({
periodDays,
periodCountCalendarDays,
minimumBookingNotice,
rescheduleUid,
}: DatePickerProps): JSX.Element {
const { t } = useLocale();
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
const asPath = useRouterAsPath();
const [selectedMonth, setSelectedMonth] = useState<number>(
date
? periodType === PeriodType.RANGE
@ -194,25 +196,41 @@ function DatePicker({
{day === null ? (
<div key={`e-${idx}`} />
) : (
<button
onClick={() => onDatePicked(inviteeDate().date(day.date))}
disabled={day.disabled}
className={classNames(
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
"hover:border hover:border-brand dark:hover:border-white",
day.disabled
? "text-gray-400 font-light hover:border-0 cursor-default"
: "dark:text-white text-primary-500 font-medium",
date && date.isSame(inviteeDate().date(day.date), "day")
? "bg-brand text-white-important"
: !day.disabled
? " bg-gray-100 dark:bg-gray-600"
: ""
)}
data-testid="day"
data-disabled={day.disabled}>
{day.date}
</button>
<Link
href={{
pathname: asPath,
query: {
date: inviteeDate().date(day.date).format("YYYY-MM-DDZZ"),
// add rescheduleUid to query if set
...(rescheduleUid ? { rescheduleUid } : {}),
},
}}
shallow={true}>
<a
className={classNames(
"rounded-sm text-center border border-transparent absolute inset-0",
"hover:border hover:border-brand dark:hover:border-white",
day.disabled
? "text-gray-400 font-light hover:border-0 cursor-default"
: "dark:text-white text-primary-500 font-medium",
date && date.isSame(inviteeDate().date(day.date), "day")
? "bg-brand text-white-important"
: !day.disabled
? " bg-gray-100 dark:bg-gray-600"
: ""
)}
onClick={(e) => {
if (day.disabled) {
e.preventDefault();
}
}}
data-testid="day"
data-disabled={day.disabled}>
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{day.date}
</span>
</a>
</Link>
)}
</div>
))}

View File

@ -11,6 +11,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { useLocale } from "@lib/hooks/useLocale";
import { useRouterAsPath } from "@lib/hooks/useRouterPath";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
@ -23,7 +24,7 @@ import { HeadSeo } from "@components/seo/head-seo";
import AvatarGroup from "@components/ui/AvatarGroup";
import PoweredByCal from "@components/ui/PoweredByCal";
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
import { AvailabilityPageProps } from "../../../pages/[locale]/[user]/[type]";
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
dayjs.extend(utc);
@ -60,8 +61,10 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
}, [telemetry]);
const asPath = useRouterAsPath();
const changeDate = (newDate: Dayjs) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
router.replace(
{
query: {
@ -69,7 +72,12 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
date: newDate.format("YYYY-MM-DDZZ"),
},
},
undefined,
{
pathname: asPath,
query: {
date: newDate.format("YYYY-MM-DDZZ"),
},
},
{
shallow: true,
}
@ -96,7 +104,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
name={profile.name || undefined}
avatar={profile.image || undefined}
/>
<CustomBranding val={profile.brandColor} />
{"brandColor" in profile && <CustomBranding val={profile.brandColor} />}
<div>
<main
className={
@ -198,13 +206,13 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div>
<DatePicker
rescheduleUid={router.query?.rescheduleUid as string | undefined}
date={selectedDate}
periodType={eventType?.periodType}
periodStartDate={eventType?.periodStartDate}
periodEndDate={eventType?.periodEndDate}
periodDays={eventType?.periodDays}
periodCountCalendarDays={eventType?.periodCountCalendarDays}
onDatePicked={changeDate}
workingHours={workingHours}
weekStart={profile.weekStart || "Sunday"}
eventLength={eventType.length}

View File

@ -34,7 +34,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
import { Button } from "@components/ui/Button";
import PhoneInput from "@components/ui/form/PhoneInput";
import { BookPageProps } from "../../../pages/[user]/book";
import { BookPageProps } from "../../../pages/[locale]/[user]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
type BookingPageProps = BookPageProps | TeamBookingPageProps;

View File

@ -0,0 +1,12 @@
import { useRouter } from "next/router";
import { useMemo } from "react";
/**
* Used for pages where edge functions
*/
export function useRouterAsPath() {
const router = useRouter();
return useMemo(() => {
return router.asPath.split("?")[0] as string;
}, [router.asPath]);
}

View File

@ -10,7 +10,7 @@ declare global {
export const prisma =
globalThis.prisma ||
new PrismaClient({
log: ["query", "error", "warn"],
// log: ["query", "error", "warn"],
});
if (!IS_PRODUCTION) {

View File

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const withTM = require("@vercel/edge-functions-ui/transpile")(["react-timezone-select"]);
const { i18n } = require("./next-i18next.config");
// So we can test deploy previews preview
if (process.env.VERCEL_URL && !process.env.BASE_URL) {
@ -55,7 +54,6 @@ plugins.push(withTM);
// prettier-ignore
module.exports = () => plugins.reduce((acc, next) => next(acc), {
i18n,
eslint: {
// This allows production builds to successfully complete even if the project has ESLint errors.
ignoreDuringBuilds: true,

View File

@ -1,26 +1,47 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { GetServerSidePropsContext } from "next";
import { GetStaticPaths, GetStaticPropsContext } from "next";
import { i18n } from "next-i18next.config";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import React, { useEffect } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import prisma from "@lib/prisma";
import { trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import Loader from "@components/Loader";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import { HeadSeo } from "@components/seo/head-seo";
import Avatar from "@components/ui/Avatar";
import { ssrInit } from "@server/lib/ssr";
import { ssgInit } from "@server/ssg";
export default function User(props: inferSSRProps<typeof getStaticProps>) {
const { username } = props;
const utils = trpc.useContext();
// data of query below will be will be generally prepopulated b/c of `getStaticProps`
const query = trpc.useQuery(["booking.userEventTypes", { username }], { enabled: !!username });
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = useTheme(props.user.theme);
const { user, eventTypes } = props;
const { t } = useLocale();
const router = useRouter();
const { isReady } = useTheme(query.data?.user.theme);
useEffect(() => {
if (!query.data || !username) {
return;
}
for (const { slug } of query.data.eventTypes) {
utils.prefetchQuery(["booking.eventTypeByUsername", { slug, username }]);
}
}, [query.data, username, utils]);
if (!query.data) {
return <Loader />;
}
const { user, eventTypes } = query.data;
const nameOrUsername = user.name || user.username || "";
return (
@ -29,7 +50,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
title={nameOrUsername}
description={nameOrUsername}
name={nameOrUsername}
avatar={user.avatar || undefined}
avatar={user.avatar || ""}
/>
{isReady && (
<div className="h-screen bg-neutral-50 dark:bg-black">
@ -83,84 +104,68 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const username = (context.query.user as string).toLowerCase();
const user = await prisma.user.findUnique({
where: {
username: username.toLowerCase(),
},
export const getStaticPaths: GetStaticPaths = async () => {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
avatar: true,
theme: true,
plan: true,
locale: true,
},
where: {
// will statically render everyone on the PRO plan
// the rest will be statically rendered on first visit
plan: "PRO",
},
});
const { defaultLocale } = i18n;
return {
paths: users.flatMap((user) => {
if (!user.username) {
return [];
}
// statically render english
const paths = [
{
params: {
user: user.username,
locale: defaultLocale,
},
},
];
// statically render user's preferred language
if (user.locale && user.locale !== defaultLocale) {
const locale = user.locale;
paths.push({
params: {
user: user.username,
locale,
},
});
}
return paths;
}),
if (!user) {
// https://nextjs.org/docs/basic-features/data-fetching#fallback-blocking
fallback: true,
};
};
export async function getStaticProps(context: GetStaticPropsContext<{ user: string; locale: string }>) {
const ssg = await ssgInit(context);
// 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,
},
},
},
],
},
],
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
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: {
user,
eventTypes,
trpcState: ssr.dehydrate(),
trpcState: ssg.dehydrate(),
username,
},
revalidate: 1,
};
};
}

View File

@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { GetStaticPaths, GetStaticPropsContext } from "next";
import { useRouter } from "next/router";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import Loader from "@components/Loader";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
import { ssgInit } from "@server/ssg";
type TEventTypeByUsername = NonNullable<inferQueryOutput<"booking.eventTypeByUsername">>;
export type AvailabilityPageProps = TEventTypeByUsername;
export default function Type(props: inferSSRProps<typeof getStaticProps>) {
const searchParams = useRouter().query;
const username = (props.username || searchParams.username) as string;
const slug = (props.slug || searchParams.slug) as string;
const query = trpc.useQuery(["booking.eventTypeByUsername", { username, slug }], {
enabled: !!username && !!slug,
});
if (!query.data) {
return <Loader />;
}
return <AvailabilityPage {...query.data} />;
}
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: true,
};
};
export async function getStaticProps(
context: GetStaticPropsContext<{ user: string; type: string; locale: string }>
) {
const ssg = await ssgInit(context);
const username = context.params!.user;
const slug = context.params!.type;
const data = await ssg.fetchQuery("booking.eventTypeByUsername", { username, slug });
if (!data) {
return {
notFound: true,
};
}
return {
props: {
username,
slug,
trpcState: ssg.dehydrate(),
revalidate: 1,
},
};
}

5
pages/[locale]/index.tsx Normal file
View File

@ -0,0 +1,5 @@
/**
* Needed for `_middleware.ts` to work properly
* We can decide later what should happen when a user tries to access a locale root.
*/
export default () => null;

View File

@ -1,194 +0,0 @@
import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
import { ssrInit } from "@server/lib/ssr";
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type(props: AvailabilityPageProps) {
return <AvailabilityPage {...props} />;
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
// get query params and typecast them to string
// (would be even better to assert them instead of typecasting)
const userParam = asStringOrNull(context.query.user);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
if (!userParam || !typeParam) {
throw new Error(`File is not named [type]/[user]`);
}
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
availability: true,
description: true,
length: true,
price: true,
currency: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
schedulingType: true,
minimumBookingNotice: true,
timeZone: true,
users: {
select: {
avatar: true,
name: true,
username: true,
hideBranding: true,
plan: true,
timeZone: true,
},
},
});
const user = await prisma.user.findUnique({
where: {
username: userParam.toLowerCase(),
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
startTime: true,
endTime: true,
timeZone: true,
weekStart: true,
availability: true,
hideBranding: true,
brandColor: true,
theme: true,
plan: true,
eventTypes: {
where: {
AND: [
{
slug: typeParam,
},
{
teamId: null,
},
],
},
select: eventTypeSelect,
},
},
});
if (!user) {
return {
notFound: true,
};
}
if (user.eventTypes.length !== 1) {
const eventTypeBackwardsCompat = await prisma.eventType.findFirst({
where: {
AND: [
{
userId: user.id,
},
{
slug: typeParam,
},
],
},
select: eventTypeSelect,
});
if (!eventTypeBackwardsCompat) {
return {
notFound: true,
};
}
eventTypeBackwardsCompat.users.push({
avatar: user.avatar,
name: user.name,
username: user.username,
hideBranding: user.hideBranding,
plan: user.plan,
timeZone: user.timeZone,
});
user.eventTypes.push(eventTypeBackwardsCompat);
}
const [eventType] = user.eventTypes;
// check this is the first event
// TEMPORARILY disabled because of a bug during event create - during which users were able
// to create event types >n1.
/*if (user.plan === "FREE") {
const firstEventType = await prisma.eventType.findFirst({
where: {
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
select: {
id: true,
},
});
if (firstEventType?.id !== eventType.id) {
return {
notFound: true,
} as const;
}
}*/
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const workingHours = getWorkingHours(
{
timeZone: eventType.timeZone || user.timeZone,
},
eventType.availability.length ? eventType.availability : user.availability
);
eventTypeObject.availability = [];
return {
props: {
profile: {
name: user.name,
image: user.avatar,
slug: user.username,
theme: user.theme,
weekStart: user.weekStart,
brandColor: user.brandColor,
},
date: dateParam,
eventType: eventTypeObject,
workingHours,
trpcState: ssr.dehydrate(),
},
};
};

35
pages/_middleware.ts Normal file
View File

@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { NextRequest, NextResponse } from "next/server";
import { i18n } from "../next-i18next.config";
const REDIRECTED_PAGES = ["/[locale]", "/[locale]/[user]"];
export async function middleware(req: NextRequest) {
const pageName = req.page.name;
const pathname = req.nextUrl.pathname;
if (!pathname || !pageName) {
return;
}
const parts = pathname.split("/").filter(Boolean);
const [firstPart] = parts;
const isFileRequest = pathname.includes(".");
if (!REDIRECTED_PAGES.includes(pageName) || isFileRequest || i18n.locales.includes(firstPart)) {
return;
}
const localeWithCountry = req.headers.get("accept-language")?.split(",")?.[0] || "en-US";
const locale = localeWithCountry.split("-")[0];
const newPathname = `/${locale}${pathname}`;
console.log("redirect", { pathname, newPathname });
req.nextUrl.pathname = newPathname;
return NextResponse.rewrite(req.nextUrl);
}

View File

@ -1,4 +1,3 @@
import dayjs from "dayjs";
import { kont } from "kont";
import { loginProvider } from "./lib/loginProvider";
@ -35,11 +34,13 @@ describe("webhooks", () => {
// page contains the url
await expect(page).toHaveSelector(`text='${webhookReceiver.url}'`);
// --- go to tomorrow in the pro user's "30min"-event
const tomorrow = dayjs().add(1, "day");
const tomorrowFormatted = tomorrow.format("YYYY-MM-DDZZ");
// --- go to pro user's "30min"-event
await page.goto(`http://localhost:3000/pro/30min`);
await page.goto(`http://localhost:3000/pro/30min?date=${encodeURIComponent(tomorrowFormatted)}`);
// Click [data-testid="incrementMonth"]
await page.click('[data-testid="incrementMonth"]');
// Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]');
// click first time available
await page.click("[data-testid=time]");

View File

@ -4,6 +4,7 @@
import superjson from "superjson";
import { createRouter } from "../createRouter";
import { bookingRouter } from "./booking";
import { viewerRouter } from "./viewer";
/**
@ -23,6 +24,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;

257
server/routers/booking.ts Normal file
View File

@ -0,0 +1,257 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { getWorkingHours } from "@lib/availability";
import { createRouter } from "../createRouter";
export const bookingRouter = createRouter()
.query("userEventTypes", {
input: z.object({
username: z
.string()
.min(1)
.transform((v) => v.toLowerCase()),
}),
async resolve({ input, ctx }) {
const { prisma } = ctx;
const { username } = input;
const user = await prisma.user.findUnique({
where: {
username: username.toLowerCase(),
},
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,
},
},
},
],
},
],
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
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,
};
},
})
.query("eventTypeByUsername", {
input: z.object({
username: z.string().min(1),
slug: z.string(),
date: z.string().nullish(),
}),
async resolve({ input, ctx }) {
const { prisma } = ctx;
const { username: userParam, slug: typeParam, date: dateParam } = input;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
availability: true,
description: true,
length: true,
price: true,
currency: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
schedulingType: true,
minimumBookingNotice: true,
timeZone: true,
users: {
select: {
avatar: true,
name: true,
username: true,
hideBranding: true,
plan: true,
timeZone: true,
},
},
});
const user = await prisma.user.findUnique({
where: {
username: userParam.toLowerCase(),
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
startTime: true,
endTime: true,
timeZone: true,
weekStart: true,
availability: true,
hideBranding: true,
brandColor: true,
theme: true,
plan: true,
eventTypes: {
where: {
AND: [
{
slug: typeParam,
},
{
teamId: null,
},
],
},
select: eventTypeSelect,
},
},
});
if (!user) {
return null;
}
if (user.eventTypes.length !== 1) {
const eventTypeBackwardsCompat = await prisma.eventType.findFirst({
where: {
AND: [
{
userId: user.id,
},
{
slug: typeParam,
},
],
},
select: eventTypeSelect,
});
if (!eventTypeBackwardsCompat) {
return null;
}
eventTypeBackwardsCompat.users.push({
avatar: user.avatar,
name: user.name,
username: user.username,
hideBranding: user.hideBranding,
plan: user.plan,
timeZone: user.timeZone,
});
user.eventTypes.push(eventTypeBackwardsCompat);
}
const [eventType] = user.eventTypes;
// check this is the first event
// TEMPORARILY disabled because of a bug during event create - during which users were able
// to create event types >n1.
/*if (user.plan === "FREE") {
const firstEventType = await prisma.eventType.findFirst({
where: {
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
select: {
id: true,
},
});
if (firstEventType?.id !== eventType.id) {
return null;
}
}*/
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const workingHours = getWorkingHours(
{
timeZone: eventType.timeZone || user.timeZone,
},
eventType.availability.length ? eventType.availability : user.availability
);
eventTypeObject.availability = [];
return {
profile: {
name: user.name,
image: user.avatar,
slug: user.username,
theme: user.theme,
weekStart: user.weekStart,
brandColor: user.brandColor,
},
date: dateParam,
eventType: eventTypeObject,
workingHours,
};
},
});

38
server/ssg.ts Normal file
View File

@ -0,0 +1,38 @@
import { GetStaticPropsContext } from "next";
import { i18n } from "next-i18next.config";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import superjson from "superjson";
import prisma from "@lib/prisma";
import { createSSGHelpers } from "@trpc/react/ssg";
import { appRouter } from "./routers/_app";
export async function ssgInit<TParams extends { locale?: string }>(opts: GetStaticPropsContext<TParams>) {
const requestedLocale = opts.params?.locale || opts.locale || i18n.defaultLocale;
const isSupportedLocale = i18n.locales.includes(requestedLocale);
if (!isSupportedLocale) {
console.warn(`Requested unsupported locale "${requestedLocale}"`);
}
const locale = isSupportedLocale ? requestedLocale : i18n.defaultLocale;
const _i18n = await serverSideTranslations(locale, ["common"]);
const ssg = createSSGHelpers({
router: appRouter,
transformer: superjson,
ctx: {
prisma,
session: null,
user: null,
locale,
i18n: _i18n,
},
});
// always preload i18n
await ssg.fetchQuery("viewer.i18n");
return ssg;
}

View File

@ -114,7 +114,7 @@
@font-face {
font-family: "Roboto";
src: url("/fonts/roboto.ttf");
src: url("/roboto.ttf");
}
@font-face {

View File

@ -38,7 +38,7 @@
"jest-playwright-preset",
"expect-playwright"
],
"allowJs": false,
"allowJs": true,
"incremental": true
},
"include": [

1477
yarn.lock

File diff suppressed because it is too large Load Diff