dynamic group links (#2239)

* --init

* added default event types

* updated lib path

* updated group link design

* fixed collective description

* added default minimum booking notice

* Accept multi user query for a default event type

* check types

* check types --WIP

* check types still --WIP

* --WIP

* --WIP

* fixed single user type not working

* check fix

* --import path fix

* functional collective eventtype page

* fixed check type

* minor fixes and --WIP

* typefix

* custominput in defaultevent fix

* added booking page compatibility for dynamic group links

* added /book compatibility for dynamic group links

* checktype fix --WIP

* checktype fix

* Success page compatibility added

* added migrations

* added dynamic group booking slug to booking creation

* reschedule and database fix

* daily integration

* daily integration --locationtype fetch

* fixed reschedule

* added index to key parameter in eventtype list

* fix + added after last group slug

* added user setting option for dynamic booking

* changed defaultEvents location based on recent changes

* updated default event name in updated import

* disallow booking when one in group disallows it

* fixed setting checkbox association

* cleanup

* udded better error handling for disabled dynamic group bookings

* cleanup

* added tooltip to allow dynamic setting and enable by default

* Update yarn.lock

* Fix: Embed Fixes, UI configuration PRO Only, Tests (#2341)

* #2325 Followup (#2369)

* Adds initial MDX implementation for App Store pages

* Adds endpoint to serve app store static files

* Replaces zoom icon with dynamic-served one

* Fixes zoom icon

* Makes Slider reusable

* Adds gray-matter for MDX

* Adds zoom screenshots

* Update yarn.lock

* Slider improvements

* WIP

* Update TrendingAppsSlider.tsx

* WIP

* Adds MS teams screenshots

* Adds stripe screenshots

* Cleanup

* Update index.ts

* WIP

* Cleanup

* Cleanup

* Adds jitsi screenshot

* Adds Google meet screenshots

* Adds office 365 calendar screenshots

* Adds google calendar screenshots

* Follow #2325

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>

* requested changes

* further requested changes

* more changes

* type fix

* fixed prisma/client import path

* added e2e test

* test-fix

* E2E fixes

* Fixes circular dependency

* Fixed paid bookings seeder

* Added missing imports

* requested changes

* added username slugs as part of event description

* updated event description

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Syed Ali Shahbaz 2022-04-06 22:50:30 +05:30 committed by GitHub
parent d340ee62bb
commit d1ffd1edae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 719 additions and 205 deletions

View File

@ -20,6 +20,7 @@ type AvailableTimesProps = {
afterBufferTime: number;
eventTypeId: number;
eventLength: number;
eventTypeSlug: string;
slotInterval: number | null;
date: Dayjs;
users: {
@ -32,6 +33,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
date,
eventLength,
eventTypeId,
eventTypeSlug,
slotInterval,
minimumBookingNotice,
timeFormat,
@ -86,6 +88,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
...router.query,
date: slot.time.format(),
type: eventTypeId,
slug: eventTypeSlug,
},
};

View File

@ -260,6 +260,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
eventTypeId={eventType.id}
eventTypeSlug={eventType.slug}
slotInterval={eventType.slotInterval}
eventLength={eventType.length}
date={selectedDate}

View File

@ -12,6 +12,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client";
import { Button } from "@calcom/ui/Button";
@ -20,7 +21,6 @@ import { EmailInput, Form } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { ensureArray } from "@lib/ensureArray";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
@ -55,7 +55,13 @@ type BookingFormValues = {
};
};
const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPageProps) => {
const BookingPage = ({
eventType,
booking,
profile,
isDynamicGroupBooking,
locationLabels,
}: BookingPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const { contracts } = useContracts();
@ -99,6 +105,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
query: {
date,
type: eventType.id,
eventSlug: eventType.slug,
user: profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
@ -160,7 +167,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
return {
name: primaryAttendee.name || "",
email: primaryAttendee.email || "",
guests: booking.attendees.slice(1).map((attendee) => attendee.email),
guests: !isDynamicGroupBooking ? booking.attendees.slice(1).map((attendee) => attendee.email) : [],
};
};
@ -241,6 +248,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
start: dayjs(date).format(),
end: dayjs(date).add(eventType.length, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,

View File

@ -13,6 +13,7 @@ export type BookingCreateBody = {
userSignature: unknown;
};
eventTypeId: number;
eventTypeSlug: string;
guests?: string[];
location: string;
name: string;

View File

@ -1,5 +1,6 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { BadgeCheckIcon } from "@heroicons/react/solid";
import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
import Link from "next/link";
@ -9,6 +10,11 @@ import { Toaster } from "react-hot-toast";
import { JSONObject } from "superjson/dist/types";
import { sdkActionManager, useEmbedStyles } from "@calcom/embed-core";
import defaultEvents, {
getDynamicEventDescription,
getUsernameList,
getUsernameSlugLink,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
@ -16,6 +22,7 @@ import useTheme from "@lib/hooks/useTheme";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AvatarGroup from "@components/ui/AvatarGroup";
import { AvatarSSR } from "@components/ui/AvatarSSR";
import { ssrInit } from "@server/lib/ssr";
@ -29,10 +36,66 @@ interface EvtsToVerify {
}
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const { Theme } = useTheme(props.user.theme);
const { user, eventTypes } = props;
const { users } = props;
const [user] = users; //To be used when we only have a single user, not dynamic group
const { Theme } = useTheme(user.theme);
const { t } = useLocale();
const router = useRouter();
const isSingleUser = props.users.length === 1;
const isDynamicGroup = props.users.length > 1;
const dynamicUsernames = isDynamicGroup
? props.users.map((user) => {
return user.username || "";
})
: [];
const eventTypes = isDynamicGroup
? defaultEvents.map((event) => {
event.description = getDynamicEventDescription(dynamicUsernames, event.slug);
return event;
})
: props.eventTypes;
const groupEventTypes = props.users.some((user) => {
return !user.allowDynamicBooking;
}) ? (
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">{" " + t("unavailable")}</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
) : (
<ul className="space-y-3">
{eventTypes.map((type, index) => (
<li
key={index}
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-0 dark:bg-neutral-900 dark:hover:border-neutral-600">
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
<div className="flex-shrink">
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1">
<AvatarGroup
border="border-2 border-white"
truncateAfter={4}
className="flex-shrink-0"
size={10}
items={props.users.map((user) => ({
alt: user.name || "",
image: user.avatar || "",
}))}
/>
</div>
</a>
</Link>
</li>
))}
</ul>
);
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
const query = { ...router.query };
delete query.user; // So it doesn't display in the Link (and make tests fail)
@ -51,16 +114,18 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
/>
<div className="h-screen dark:bg-neutral-900">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="mb-8 text-center">
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername} />
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
<span>{nameOrUsername}</span>
{user.verified && (
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
</div>
{isSingleUser && ( // When we deal with a single user, not dynamic group
<div className="mb-8 text-center">
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername}></AvatarSSR>
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
{nameOrUsername}
{user.verified && (
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
</div>
)}
<div className="space-y-6" data-testid="event-types">
{user.away ? (
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
@ -71,6 +136,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
</div>
</div>
) : isDynamicGroup ? ( //When we deal with dynamic group (users > 1)
groupEventTypes
) : (
eventTypes.map((type) => (
<div
@ -136,50 +203,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const crypto = require("crypto");
const username = (context.query.user as string).toLowerCase();
const dataFetchStart = Date.now();
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,
away: true,
verified: true,
},
});
if (!user) {
return {
notFound: true,
};
}
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: {
id: true,
type: true,
key: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const eventTypesWithHidden = await prisma.eventType.findMany({
const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) => {
return await prisma.eventType.findMany({
where: {
AND: [
{
@ -188,12 +213,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
{
OR: [
{
userId: user.id,
userId,
},
{
users: {
some: {
id: user.id,
id: userId,
},
},
},
@ -221,8 +246,63 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
currency: true,
metadata: true,
},
take: user.plan === "FREE" ? 1 : undefined,
take: plan === UserPlan.FREE ? 1 : undefined,
});
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const crypto = require("crypto");
const usernameList = getUsernameList(context.query.user as string);
const dataFetchStart = Date.now();
const users = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
},
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
avatar: true,
theme: true,
plan: true,
away: true,
verified: true,
allowDynamicBooking: true,
},
});
if (!users.length) {
return {
notFound: true,
};
}
const isDynamicGroup = users.length > 1;
const [user] = users; //to be used when dealing with single user, not dynamic group
const usersIds = users.map((user) => user.id);
const credentials = await prisma.credential.findMany({
where: {
userId: {
in: usersIds,
},
},
select: {
id: true,
type: true,
key: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id, user.plan);
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
@ -240,8 +320,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
users,
user: {
...user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
eventTypes,

View File

@ -1,7 +1,11 @@
import { Prisma } from "@prisma/client";
import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import prisma from "@lib/prisma";
@ -14,13 +18,33 @@ import { ssrInit } from "@server/lib/ssr";
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type(props: AvailabilityPageProps) {
return <AvailabilityPage {...props} />;
const { t } = useLocale();
return props.isDynamicGroup && !props.profile.allowDynamicBooking ? (
<div className="h-screen dark:bg-neutral-900">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<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 usernameList = getUsernameList(context.query.user as string);
const userParam = asStringOrNull(context.query.user);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
@ -49,6 +73,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
timeZone: true,
},
},
hidden: true,
slug: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
@ -67,9 +93,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
});
const user = await prisma.user.findUnique({
const users = await prisma.user.findMany({
where: {
username: userParam.toLowerCase(),
username: {
in: usernameList,
},
},
select: {
id: true,
@ -87,6 +115,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
brandColor: true,
darkBrandColor: true,
defaultScheduleId: true,
allowDynamicBooking: true,
schedules: {
select: {
availability: true,
@ -112,13 +141,16 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
});
if (!user) {
if (!users) {
return {
notFound: true,
};
}
const [user] = users; //to be used when dealing with single user, not dynamic group
const isSingleUser = users.length === 1;
const isDynamicGroup = users.length > 1;
if (user.eventTypes.length !== 1) {
if (isSingleUser && user.eventTypes.length !== 1) {
const eventTypeBackwardsCompat = await prisma.eventType.findFirst({
where: {
AND: [
@ -150,10 +182,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
user.eventTypes.push(eventTypeBackwardsCompat);
}
const [eventType] = user.eventTypes;
let [eventType] = user.eventTypes;
// check this is the first event
if (user.plan === "FREE") {
if (isDynamicGroup) {
eventType = getDefaultEvent(typeParam);
eventType["users"] = users.map((user) => {
return {
avatar: user.avatar as string,
name: user.name as string,
username: user.username as string,
hideBranding: user.hideBranding,
plan: user.plan,
timeZone: user.timeZone as string,
};
});
}
// check this is the first event for free user
if (isSingleUser && user.plan === UserPlan.FREE) {
const firstEventType = await prisma.eventType.findFirst({
where: {
OR: [
@ -202,21 +248,35 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
)[0],
};
const timeZone = schedule.timeZone || eventType.timeZone || user.timeZone;
const timeZone = isDynamicGroup ? undefined : schedule.timeZone || eventType.timeZone || user.timeZone;
const workingHours = getWorkingHours(
{
timeZone,
},
schedule.availability || (eventType.availability.length ? eventType.availability : user.availability)
isDynamicGroup
? eventType.availability || undefined
: schedule.availability || (eventType.availability.length ? eventType.availability : user.availability)
);
eventTypeObject.schedule = null;
eventTypeObject.availability = [];
return {
props: {
profile: {
const profile = isDynamicGroup
? {
name: getGroupName(usernameList),
image: null,
slug: typeParam,
theme: null,
weekStart: "Sunday",
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: users.some((user) => {
return !user.allowDynamicBooking;
})
? false
: true,
}
: {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
@ -224,7 +284,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
weekStart: user.weekStart,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
};
return {
props: {
isDynamicGroup,
profile,
plan: user.plan,
date: dateParam,
eventType: eventTypeObject,

View File

@ -6,6 +6,8 @@ import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
@ -22,14 +24,36 @@ dayjs.extend(timezone);
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Book(props: BookPageProps) {
return <BookingPage {...props} />;
const { t } = useLocale();
return props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
<div className="h-screen dark:bg-neutral-900">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<BookingPage {...props} />
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const user = await prisma.user.findUnique({
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
const eventTypeSlug = context.query.slug as string;
const users = await prisma.user.findMany({
where: {
username: asStringOrThrow(context.query.user).toLowerCase(),
username: {
in: usernameList,
},
},
select: {
id: true,
@ -41,50 +65,56 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
},
});
if (!user) return { notFound: true };
const eventTypeRaw = await prisma.eventType.findUnique({
where: {
id: parseInt(asStringOrThrow(context.query.type)),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
locations: true,
customInputs: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
price: true,
currency: true,
disableGuests: true,
users: {
select: {
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
},
},
},
});
if (!users.length) return { notFound: true };
const [user] = users;
const eventTypeRaw =
usernameList.length > 1
? getDefaultEvent(eventTypeSlug)
: await prisma.eventType.findUnique({
where: {
id: parseInt(asStringOrThrow(context.query.type)),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
locations: true,
customInputs: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
price: true,
currency: true,
disableGuests: true,
users: {
select: {
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
},
},
},
});
if (!eventTypeRaw) return { notFound: true };
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
userId: {
in: users.map((user) => user.id),
},
},
select: {
id: true,
@ -136,22 +166,41 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking = await getBooking();
}
const isDynamicGroupBooking = users.length > 1;
const profile = isDynamicGroupBooking
? {
name: getGroupName(usernameList),
image: null,
slug: eventTypeSlug,
theme: null,
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: users.some((user) => {
return !user.allowDynamicBooking;
})
? false
: true,
}
: {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
};
const t = await getTranslation(context.locale ?? "en", "common");
return {
props: {
locationLabels: getLocationLabels(t),
profile: {
slug: user.username,
name: user.name,
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
profile,
eventType: eventTypeObject,
booking,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
},
};
}

View File

@ -12,6 +12,7 @@ import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty";
@ -181,30 +182,8 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
return userNamesWithBookingCounts;
};
type User = Prisma.UserGetPayload<typeof userSelect>;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const reqBody = req.body as BookingCreateBody;
const eventTypeId = reqBody.eventTypeId;
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const isTimeInPast = (time: string): boolean => {
return dayjs(time).isBefore(new Date(), "day");
};
if (isTimeInPast(reqBody.start)) {
const error = {
errorCode: "BookingDateInPast",
message: "Attempting to create a meeting in the past.",
};
log.error(`Booking ${eventTypeId} failed`, error);
return res.status(400).json(error);
}
const eventType = await prisma.eventType.findUnique({
const getEventTypesFromDB = async (eventTypeId: number) => {
return await prisma.eventType.findUnique({
rejectOnNotFound: true,
where: {
id: eventTypeId,
@ -235,10 +214,48 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
hideCalendarNotes: true,
},
});
};
type User = Prisma.UserGetPayload<typeof userSelect>;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const reqBody = req.body as BookingCreateBody;
// handle dynamic user
const dynamicUserList = getUsernameList(reqBody.user as string);
const eventTypeSlug = reqBody.eventTypeSlug;
const eventTypeId = reqBody.eventTypeId;
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const isTimeInPast = (time: string): boolean => {
return dayjs(time).isBefore(new Date(), "day");
};
if (isTimeInPast(reqBody.start)) {
const error = {
errorCode: "BookingDateInPast",
message: "Attempting to create a meeting in the past.",
};
log.error(`Booking ${eventTypeId} failed`, error);
return res.status(400).json(error);
}
const eventType = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
if (!eventType) return res.status(404).json({ message: "eventType.notFound" });
let users = eventType.users;
let users = !eventTypeId
? await prisma.user.findMany({
where: {
username: {
in: dynamicUserList,
},
},
...userSelect,
})
: eventType.users;
/* If this event was pre-relationship migration */
if (!users.length && eventType.userId) {
@ -340,7 +357,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
attendees: attendeesList,
location: reqBody.location, // Will be processed by the EventManager later.
/** For team events, we will need to handle each member destinationCalendar eventually */
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || users[0].destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
};
@ -362,6 +379,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await verifyAccount(web3Details.userSignature, web3Details.userWallet);
}
const eventTypeRel = !eventTypeId
? {}
: {
connect: {
id: eventTypeId,
},
};
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
return prisma.booking.create({
include: {
user: {
@ -377,11 +405,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
description: evt.description,
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
location: evt.location,
eventType: {
connect: {
id: eventTypeId,
},
},
eventType: eventTypeRel,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
@ -397,6 +421,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}),
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: users[0].id,

View File

@ -1,5 +1,7 @@
import { GetServerSidePropsContext } from "next";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { asStringOrUndefined } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
@ -30,6 +32,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
},
},
dynamicEventSlugRef: true,
dynamicGroupSlugRef: true,
user: true,
title: true,
description: true,
@ -38,17 +42,19 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
attendees: true,
},
});
const dynamicEventSlugRef = booking?.dynamicEventSlugRef || "";
if (!booking?.eventType && !booking?.dynamicEventSlugRef) throw Error("This booking doesn't exists");
if (!booking?.eventType) throw Error("This booking doesn't exists");
const eventType = booking.eventType;
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const eventPage =
(eventType.team
? "team/" + eventType.team.slug
: dynamicEventSlugRef
? booking.dynamicGroupSlugRef
: booking.user?.username || "rick") /* This shouldn't happen */ +
"/" +
booking.eventType.slug;
eventType?.slug;
return {
redirect: {

View File

@ -29,6 +29,7 @@ import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Badge from "@components/ui/Badge";
import InfoBadge from "@components/ui/InfoBadge";
import ColorPicker from "@components/ui/colorpicker";
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
@ -126,6 +127,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
const avatarRef = useRef<HTMLInputElement>(null!);
const hideBrandingRef = useRef<HTMLInputElement>(null!);
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
value: props.user.timeFormat || 12,
@ -168,6 +170,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked;
const enteredAllowDynamicGroupBooking = allowDynamicGroupBookingRef.current.checked;
const enteredLanguage = selectedLanguage.value;
const enteredTimeFormat = selectedTimeFormat.value;
@ -182,6 +185,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
timeZone: enteredTimeZone,
weekStart: asStringOrUndefined(enteredWeekStartDay),
hideBranding: enteredHideBranding,
allowDynamicBooking: enteredAllowDynamicGroupBooking,
theme: asStringOrNull(selectedTheme?.value),
brandColor: enteredBrandColor,
darkBrandColor: enteredDarkBrandColor,
@ -363,6 +367,25 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
/>
</div>
</div>
<div className="relative mt-8 flex items-start">
<div className="flex h-5 items-center">
<input
id="dynamic-group-booking"
name="dynamic-group-booking"
type="checkbox"
ref={allowDynamicGroupBookingRef}
defaultChecked={props.user.allowDynamicBooking || false}
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label
htmlFor="dynamic-group-booking"
className="flex items-center font-medium text-gray-700">
{t("allow_dynamic_booking")} <InfoBadge content={t("allow_dynamic_booking_tooltip")} />
</label>
</div>
</div>
<div>
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
{t("single_theme")}
@ -507,6 +530,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
darkBrandColor: true,
metadata: true,
timeFormat: true,
allowDynamicBooking: true,
},
});

View File

@ -11,6 +11,7 @@ import { useRouter } from "next/router";
import { useEffect, useState, useRef } from "react";
import { sdkActionManager } from "@calcom/embed-core";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { EmailInput } from "@calcom/ui/form/fields";
@ -402,17 +403,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
if (isNaN(typeId)) {
return {
notFound: true,
};
}
const eventType = await prisma.eventType.findUnique({
const getEventTypesFromDB = async (typeId: number) => {
return await prisma.eventType.findUnique({
where: {
id: typeId,
},
@ -445,6 +437,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
},
});
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
if (isNaN(typeId)) {
return {
notFound: true,
};
}
const eventType = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
if (!eventType) {
return {
@ -481,6 +487,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,

View File

@ -40,6 +40,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
select: {
id: true,
slug: true,
users: {
select: {
id: true,

View File

@ -113,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
eventType: eventTypeObject,
booking,
isDynamicGroupBooking: false,
},
};
}

View File

@ -1,36 +1,14 @@
import { expect, Page, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import {
bookFirstEvent,
bookTimeSlot,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
todo,
} from "./lib/testUtils";
async function bookFirstEvent(page: Page) {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
}
const bookTimeSlot = async (page: Page) => {
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
};
test.describe("free user", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/free");

View File

@ -0,0 +1,78 @@
import { Page, test } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import {
bookFirstEvent,
bookTimeSlot,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.describe("dynamic booking", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test.beforeEach(async ({ page }) => {
await page.goto("/pro+free");
});
test.afterAll(async () => {
// delete test bookings
await deleteAllBookingsByEmail("pro@example.com");
await deleteAllBookingsByEmail("free@example.com");
});
test("book an event first day in next month", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
});
test("can reschedule a booking", async ({ page }) => {
await bookFirstEvent(page);
// Logged in
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click();
await page.waitForNavigation({
url: (url) => {
const bookingId = url.searchParams.get("rescheduleUid");
return !!bookingId;
},
});
await selectSecondAvailableTimeSlotNextMonth(page);
// --- fill form
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({
url(url) {
return url.pathname === "/success" && url.searchParams.get("reschedule") === "true";
},
});
});
test("Can cancel the recently created booking", async ({ page }) => {
await bookFirstEvent(page);
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="cancel"]').first().click();
await page.waitForNavigation({
url: (url) => {
return url.pathname.startsWith("/cancel");
},
});
// --- fill form
await page.locator('[data-testid="cancel"]').click();
await page.waitForNavigation({
url(url) {
return url.pathname === "/cancel/success";
},
});
});
});

View File

@ -73,7 +73,8 @@ export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
await page.waitForTimeout(400);
await page.click('[data-testid="day"][data-disabled="false"]');
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await page.click('[data-testid="time"]');
}
@ -83,10 +84,34 @@ export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
await page.waitForTimeout(400);
await page.click('[data-testid="day"][data-disabled="false"]');
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
await page.locator('[data-testid="time"]').nth(1).click();
}
export async function bookFirstEvent(page: Page) {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
}
export const bookTimeSlot = async (page: Page) => {
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
};
// Provide an standalone localize utility not managed by next-i18n
export async function localize(locale: string) {
const localeModule = `../../public/static/locales/${locale}/common.json`;

View File

@ -306,6 +306,9 @@
"light": "Light",
"dark": "Dark",
"automatically_adjust_theme": "Automatically adjust theme based on invitee preferences",
"user_dynamic_booking_disabled": "Some of the users in the group have currently disabled dynamic group bookings",
"allow_dynamic_booking_tooltip": "Group booking links that can be created dynamically by adding multiple usernames with a '+'. example: 'cal.com/bailey+peer'",
"allow_dynamic_booking": "Allow attendees to book you through dynamic group bookings",
"email": "Email",
"email_placeholder": "jdoe@example.com",
"full_name": "Full name",

View File

@ -619,6 +619,7 @@ const loggedInViewerRouter = createProtectedRouter()
timeZone: z.string().optional(),
weekStart: z.string().optional(),
hideBranding: z.boolean().optional(),
allowDynamicBooking: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),

View File

@ -0,0 +1,150 @@
import { PeriodType, SchedulingType, UserPlan, EventTypeCustomInput } from "@prisma/client";
const availability = [
{
days: [1, 2, 3, 4, 5],
startTime: new Date().getTime(),
endTime: new Date().getTime(),
date: new Date(),
scheduleId: null,
},
];
type UsernameSlugLinkProps = {
users: {
id?: number;
username: string | null;
email?: string;
name?: string | null;
bio?: string | null;
avatar?: string | null;
theme?: string | null;
plan?: UserPlan;
away?: boolean;
verified?: boolean | null;
allowDynamicBooking?: boolean | null;
}[];
slug: string;
};
const customInputs: EventTypeCustomInput[] = [];
const commons = {
periodCountCalendarDays: true,
periodStartDate: null,
periodEndDate: null,
beforeEventBuffer: 0,
afterEventBuffer: 0,
periodType: PeriodType.UNLIMITED,
periodDays: null,
slotInterval: null,
locations: [{ type: "integrations:daily" }],
customInputs,
disableGuests: true,
minimumBookingNotice: 120,
schedule: null,
timeZone: null,
successRedirectUrl: "",
availability: [],
price: 0,
currency: "usd",
schedulingType: SchedulingType.COLLECTIVE,
id: 0,
metadata: {
smartContractAddress: "",
},
isWeb3Active: false,
hideCalendarNotes: false,
destinationCalendar: null,
team: null,
requiresConfirmation: false,
hidden: false,
userId: 0,
users: [
{
id: 0,
plan: UserPlan.PRO,
email: "jdoe@example.com",
name: "John Doe",
username: "jdoe",
avatar: "",
hideBranding: true,
timeZone: "",
destinationCalendar: null,
credentials: [],
bufferTime: 0,
locale: "en",
theme: null,
brandColor: "#292929",
darkBrandColor: "#fafafa",
},
],
};
const min15Event = {
length: 15,
slug: "15min",
title: "15min",
eventName: "Dynamic Collective 15min Event",
description: "Dynamic Collective 15min Event",
...commons,
};
const min30Event = {
length: 30,
slug: "30min",
title: "30min",
eventName: "Dynamic Collective 30min Event",
description: "Dynamic Collective 30min Event",
...commons,
};
const min60Event = {
length: 60,
slug: "60min",
title: "60min",
eventName: "Dynamic Collective 60min Event",
description: "Dynamic Collective 60min Event",
...commons,
};
const defaultEvents = [min15Event, min30Event, min60Event];
export const getDynamicEventDescription = (dynamicUsernames: string[], slug: string): string => {
return `Book a ${slug} event with ${dynamicUsernames.join(", ")}`;
};
export const getDefaultEvent = (slug: string) => {
const event = defaultEvents.find((obj) => {
return obj.slug === slug;
});
return event || min15Event;
};
export const getGroupName = (usernameList: string[]): string => {
return usernameList.join(", ");
};
export const getUsernameSlugLink = ({ users, slug }: UsernameSlugLinkProps): string => {
let slugLink = ``;
if (users.length > 1) {
let combinedUsername = ``;
for (let i = 0; i < users.length - 1; i++) {
combinedUsername = `${users[i].username}+`;
}
combinedUsername = `${combinedUsername}${users[users.length - 1].username}`;
slugLink = `/${combinedUsername}/${slug}`;
} else {
slugLink = `/${users[0].username}/${slug}`;
}
return slugLink;
};
export const getUsernameList = (users: string): string[] => {
return users
.toLowerCase()
.split("+")
.filter((el) => {
return el.length != 0;
});
};
export default defaultEvents;

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "dynamicEventSlugRef" TEXT;
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "dynamicGroupSlugRef" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "allowDynamicBooking" BOOLEAN DEFAULT true;

View File

@ -150,6 +150,8 @@ model User {
// the location where the events will end up
destinationCalendar DestinationCalendar?
away Boolean @default(false)
// participate in dynamic group booking or not
allowDynamicBooking Boolean? @default(true)
metadata Json?
verified Boolean? @default(false)
@ -256,6 +258,8 @@ model Booking {
destinationCalendar DestinationCalendar?
cancellationReason String?
rejectionReason String?
dynamicEventSlugRef String?
dynamicGroupSlugRef String?
}
model Schedule {

View File

@ -250,7 +250,7 @@ async function main() {
title: "paid",
slug: "paid",
length: 60,
price: 50,
price: 100,
},
{
title: "In person meeting",

View File

@ -12858,12 +12858,7 @@ prettier-plugin-tailwindcss@^0.1.8:
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.1.8.tgz#ba0f606ed91959ede670303d905b99106e9e6293"
integrity sha512-hwarSBCswAXa+kqYtaAkFr3Vop9o04WOyZs0qo3NyvW8L7f1rif61wRyq0+ArmVThOuRBcJF5hjGXYk86cwemg==
prettier@^2.5.1:
version "2.6.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
prettier@^2.6.1:
prettier@^2.5.1, prettier@^2.6.1:
version "2.6.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==