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:
parent
d340ee62bb
commit
d1ffd1edae
23 changed files with 719 additions and 205 deletions
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -13,6 +13,7 @@ export type BookingCreateBody = {
|
|||
userSignature: unknown;
|
||||
};
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
guests?: string[];
|
||||
location: string;
|
||||
name: string;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -40,6 +40,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
|
@ -113,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
},
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
isDynamicGroupBooking: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
78
apps/web/playwright/dynamic-booking-pages.test.ts
Normal file
78
apps/web/playwright/dynamic-booking-pages.test.ts
Normal 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";
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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`;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(),
|
||||
|
|
150
packages/lib/defaultEvents.ts
Normal file
150
packages/lib/defaultEvents.ts
Normal 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;
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -250,7 +250,7 @@ async function main() {
|
|||
title: "paid",
|
||||
slug: "paid",
|
||||
length: 60,
|
||||
price: 50,
|
||||
price: 100,
|
||||
},
|
||||
{
|
||||
title: "In person meeting",
|
||||
|
|
|
@ -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==
|
||||
|
|
Loading…
Reference in a new issue