Compare commits
52 Commits
main
...
katt/cal-6
Author | SHA1 | Date |
---|---|---|
kodiakhq[bot] | 9022e6b556 | |
kodiakhq[bot] | 33cca1c574 | |
kodiakhq[bot] | 13c6df1c02 | |
kodiakhq[bot] | 527cdb60e3 | |
kodiakhq[bot] | f4d8abf365 | |
kodiakhq[bot] | d3f138cefa | |
kodiakhq[bot] | b39922f2b9 | |
kodiakhq[bot] | 72ea0ad769 | |
kodiakhq[bot] | 5f2aa280f8 | |
kodiakhq[bot] | ea5de84f5f | |
kodiakhq[bot] | c250ae9e0e | |
kodiakhq[bot] | c8027c4cab | |
kodiakhq[bot] | 6f0a8f5d0f | |
Alex van Andel | 6b2a4d00e1 | |
kodiakhq[bot] | 4f7175edc0 | |
kodiakhq[bot] | d3cdd02612 | |
kodiakhq[bot] | c225b5ffe4 | |
kodiakhq[bot] | 180afa08de | |
kodiakhq[bot] | 9dac687ae7 | |
Omar López | 27b9b57d17 | |
Omar López | 56f8baf503 | |
KATT | bb4710a0b8 | |
KATT | e593f61363 | |
KATT | 9d74d371f2 | |
KATT | f2e92f830f | |
KATT | 3031cea99c | |
KATT | 2b4eb6c977 | |
KATT | 346fd1c5ad | |
KATT | 6cd30f0a41 | |
KATT | 22545c3e40 | |
KATT | 3658348f85 | |
KATT | 739f112792 | |
KATT | bc5289f2e1 | |
KATT | 0e2dbe51e6 | |
KATT | 3f54cb04af | |
KATT | d41f225b15 | |
KATT | bd339e2ac3 | |
KATT | d138f59264 | |
KATT | fbc5ac4c14 | |
KATT | b495e03e16 | |
KATT | 1bd9780f27 | |
KATT | fe1f4627be | |
KATT | 4677f71923 | |
KATT | 8ec0c30afd | |
mihaic195 | 95b645ce4a | |
KATT | 1b099036f5 | |
KATT | 75358cf348 | |
KATT | 6257dee4e1 | |
KATT | d25f72cfef | |
KATT | 60867a98ed | |
KATT | c888a18bd2 | |
KATT | 87cd589112 |
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
}
|
|
@ -10,7 +10,7 @@ declare global {
|
|||
export const prisma =
|
||||
globalThis.prisma ||
|
||||
new PrismaClient({
|
||||
log: ["query", "error", "warn"],
|
||||
// log: ["query", "error", "warn"],
|
||||
});
|
||||
|
||||
if (!IS_PRODUCTION) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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]");
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -114,7 +114,7 @@
|
|||
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
src: url("/fonts/roboto.ttf");
|
||||
src: url("/roboto.ttf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
"jest-playwright-preset",
|
||||
"expect-playwright"
|
||||
],
|
||||
"allowJs": false,
|
||||
"allowJs": true,
|
||||
"incremental": true
|
||||
},
|
||||
"include": [
|
||||
|
|
Loading…
Reference in New Issue