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;
|
afterBufferTime: number;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventLength: number;
|
eventLength: number;
|
||||||
|
eventTypeSlug: string;
|
||||||
slotInterval: number | null;
|
slotInterval: number | null;
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
users: {
|
users: {
|
||||||
|
@ -32,6 +33,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
date,
|
date,
|
||||||
eventLength,
|
eventLength,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
|
eventTypeSlug,
|
||||||
slotInterval,
|
slotInterval,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
|
@ -86,6 +88,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||||
...router.query,
|
...router.query,
|
||||||
date: slot.time.format(),
|
date: slot.time.format(),
|
||||||
type: eventTypeId,
|
type: eventTypeId,
|
||||||
|
slug: eventTypeSlug,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -260,6 +260,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||||
eventTypeId={eventType.id}
|
eventTypeId={eventType.id}
|
||||||
|
eventTypeSlug={eventType.slug}
|
||||||
slotInterval={eventType.slotInterval}
|
slotInterval={eventType.slotInterval}
|
||||||
eventLength={eventType.length}
|
eventLength={eventType.length}
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { ReactMultiEmail } from "react-multi-email";
|
import { ReactMultiEmail } from "react-multi-email";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { createPaymentLink } from "@calcom/stripe/client";
|
import { createPaymentLink } from "@calcom/stripe/client";
|
||||||
import { Button } from "@calcom/ui/Button";
|
import { Button } from "@calcom/ui/Button";
|
||||||
|
@ -20,7 +21,6 @@ import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { ensureArray } from "@lib/ensureArray";
|
import { ensureArray } from "@lib/ensureArray";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
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 { t, i18n } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { contracts } = useContracts();
|
const { contracts } = useContracts();
|
||||||
|
@ -99,6 +105,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
|
||||||
query: {
|
query: {
|
||||||
date,
|
date,
|
||||||
type: eventType.id,
|
type: eventType.id,
|
||||||
|
eventSlug: eventType.slug,
|
||||||
user: profile.slug,
|
user: profile.slug,
|
||||||
reschedule: !!rescheduleUid,
|
reschedule: !!rescheduleUid,
|
||||||
name: attendees[0].name,
|
name: attendees[0].name,
|
||||||
|
@ -160,7 +167,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
|
||||||
return {
|
return {
|
||||||
name: primaryAttendee.name || "",
|
name: primaryAttendee.name || "",
|
||||||
email: primaryAttendee.email || "",
|
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(),
|
start: dayjs(date).format(),
|
||||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
|
eventTypeSlug: eventType.slug,
|
||||||
timeZone: timeZone(),
|
timeZone: timeZone(),
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
rescheduleUid,
|
rescheduleUid,
|
||||||
|
|
|
@ -13,6 +13,7 @@ export type BookingCreateBody = {
|
||||||
userSignature: unknown;
|
userSignature: unknown;
|
||||||
};
|
};
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
|
eventTypeSlug: string;
|
||||||
guests?: string[];
|
guests?: string[];
|
||||||
location: string;
|
location: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { BadgeCheckIcon } from "@heroicons/react/solid";
|
import { BadgeCheckIcon } from "@heroicons/react/solid";
|
||||||
|
import { UserPlan } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
@ -9,6 +10,11 @@ import { Toaster } from "react-hot-toast";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
import { sdkActionManager, useEmbedStyles } from "@calcom/embed-core";
|
import { sdkActionManager, useEmbedStyles } from "@calcom/embed-core";
|
||||||
|
import defaultEvents, {
|
||||||
|
getDynamicEventDescription,
|
||||||
|
getUsernameList,
|
||||||
|
getUsernameSlugLink,
|
||||||
|
} from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||||
|
@ -16,6 +22,7 @@ import useTheme from "@lib/hooks/useTheme";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
@ -29,10 +36,66 @@ interface EvtsToVerify {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const { Theme } = useTheme(props.user.theme);
|
const { users } = props;
|
||||||
const { user, eventTypes } = 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 { t } = useLocale();
|
||||||
const router = useRouter();
|
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 eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
|
||||||
const query = { ...router.query };
|
const query = { ...router.query };
|
||||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
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">
|
<div className="h-screen dark:bg-neutral-900">
|
||||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||||
|
{isSingleUser && ( // When we deal with a single user, not dynamic group
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername} />
|
<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">
|
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
|
||||||
<span>{nameOrUsername}</span>
|
{nameOrUsername}
|
||||||
{user.verified && (
|
{user.verified && (
|
||||||
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
|
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-6" data-testid="event-types">
|
<div className="space-y-6" data-testid="event-types">
|
||||||
{user.away ? (
|
{user.away ? (
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
<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>
|
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : isDynamicGroup ? ( //When we deal with dynamic group (users > 1)
|
||||||
|
groupEventTypes
|
||||||
) : (
|
) : (
|
||||||
eventTypes.map((type) => (
|
eventTypes.map((type) => (
|
||||||
<div
|
<div
|
||||||
|
@ -136,50 +203,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) => {
|
||||||
const ssr = await ssrInit(context);
|
return await prisma.eventType.findMany({
|
||||||
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({
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
|
@ -188,12 +213,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
userId: user.id,
|
userId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
users: {
|
users: {
|
||||||
some: {
|
some: {
|
||||||
id: user.id,
|
id: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -221,8 +246,63 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
currency: true,
|
currency: true,
|
||||||
metadata: 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();
|
const dataFetchEnd = Date.now();
|
||||||
if (context.query.log === "1") {
|
if (context.query.log === "1") {
|
||||||
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
|
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
|
||||||
|
@ -240,8 +320,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
users,
|
||||||
user: {
|
user: {
|
||||||
...user,
|
|
||||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||||
},
|
},
|
||||||
eventTypes,
|
eventTypes,
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { UserPlan } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
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 { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getWorkingHours } from "@lib/availability";
|
import { getWorkingHours } from "@lib/availability";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
@ -14,13 +18,33 @@ import { ssrInit } from "@server/lib/ssr";
|
||||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
export default function Type(props: AvailabilityPageProps) {
|
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) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
// get query params and typecast them to string
|
// get query params and typecast them to string
|
||||||
// (would be even better to assert them instead of typecasting)
|
// (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 userParam = asStringOrNull(context.query.user);
|
||||||
const typeParam = asStringOrNull(context.query.type);
|
const typeParam = asStringOrNull(context.query.type);
|
||||||
const dateParam = asStringOrNull(context.query.date);
|
const dateParam = asStringOrNull(context.query.date);
|
||||||
|
@ -49,6 +73,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hidden: true,
|
||||||
|
slug: true,
|
||||||
minimumBookingNotice: true,
|
minimumBookingNotice: true,
|
||||||
beforeEventBuffer: true,
|
beforeEventBuffer: true,
|
||||||
afterEventBuffer: 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: {
|
where: {
|
||||||
username: userParam.toLowerCase(),
|
username: {
|
||||||
|
in: usernameList,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -87,6 +115,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
|
allowDynamicBooking: true,
|
||||||
schedules: {
|
schedules: {
|
||||||
select: {
|
select: {
|
||||||
availability: true,
|
availability: true,
|
||||||
|
@ -112,13 +141,16 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!users) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
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({
|
const eventTypeBackwardsCompat = await prisma.eventType.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
|
@ -150,10 +182,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
user.eventTypes.push(eventTypeBackwardsCompat);
|
user.eventTypes.push(eventTypeBackwardsCompat);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [eventType] = user.eventTypes;
|
let [eventType] = user.eventTypes;
|
||||||
|
|
||||||
// check this is the first event
|
if (isDynamicGroup) {
|
||||||
if (user.plan === "FREE") {
|
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({
|
const firstEventType = await prisma.eventType.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
|
@ -202,21 +248,35 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
)[0],
|
)[0],
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeZone = schedule.timeZone || eventType.timeZone || user.timeZone;
|
const timeZone = isDynamicGroup ? undefined : schedule.timeZone || eventType.timeZone || user.timeZone;
|
||||||
|
|
||||||
const workingHours = getWorkingHours(
|
const workingHours = getWorkingHours(
|
||||||
{
|
{
|
||||||
timeZone,
|
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.schedule = null;
|
||||||
eventTypeObject.availability = [];
|
eventTypeObject.availability = [];
|
||||||
|
|
||||||
return {
|
const profile = isDynamicGroup
|
||||||
props: {
|
? {
|
||||||
profile: {
|
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,
|
name: user.name || user.username,
|
||||||
image: user.avatar,
|
image: user.avatar,
|
||||||
slug: user.username,
|
slug: user.username,
|
||||||
|
@ -224,7 +284,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
weekStart: user.weekStart,
|
weekStart: user.weekStart,
|
||||||
brandColor: user.brandColor,
|
brandColor: user.brandColor,
|
||||||
darkBrandColor: user.darkBrandColor,
|
darkBrandColor: user.darkBrandColor,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
isDynamicGroup,
|
||||||
|
profile,
|
||||||
plan: user.plan,
|
plan: user.plan,
|
||||||
date: dateParam,
|
date: dateParam,
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
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 { asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
@ -22,14 +24,36 @@ dayjs.extend(timezone);
|
||||||
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
export default function Book(props: BookPageProps) {
|
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) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const ssr = await ssrInit(context);
|
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: {
|
where: {
|
||||||
username: asStringOrThrow(context.query.user).toLowerCase(),
|
username: {
|
||||||
|
in: usernameList,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -41,12 +65,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
theme: true,
|
theme: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
|
allowDynamicBooking: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return { notFound: true };
|
if (!users.length) return { notFound: true };
|
||||||
|
const [user] = users;
|
||||||
const eventTypeRaw = await prisma.eventType.findUnique({
|
const eventTypeRaw =
|
||||||
|
usernameList.length > 1
|
||||||
|
? getDefaultEvent(eventTypeSlug)
|
||||||
|
: await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: parseInt(asStringOrThrow(context.query.type)),
|
id: parseInt(asStringOrThrow(context.query.type)),
|
||||||
},
|
},
|
||||||
|
@ -84,7 +112,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
|
||||||
const credentials = await prisma.credential.findMany({
|
const credentials = await prisma.credential.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: {
|
||||||
|
in: users.map((user) => user.id),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -136,22 +166,41 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
booking = await getBooking();
|
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");
|
const t = await getTranslation(context.locale ?? "en", "common");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
locationLabels: getLocationLabels(t),
|
locationLabels: getLocationLabels(t),
|
||||||
profile: {
|
profile,
|
||||||
slug: user.username,
|
|
||||||
name: user.name,
|
|
||||||
image: user.avatar,
|
|
||||||
theme: user.theme,
|
|
||||||
brandColor: user.brandColor,
|
|
||||||
darkBrandColor: user.darkBrandColor,
|
|
||||||
},
|
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
booking,
|
booking,
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
|
isDynamicGroupBooking,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { v5 as uuidv5 } from "uuid";
|
||||||
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
||||||
import EventManager from "@calcom/core/EventManager";
|
import EventManager from "@calcom/core/EventManager";
|
||||||
import { getBusyVideoTimes } from "@calcom/core/videoClient";
|
import { getBusyVideoTimes } from "@calcom/core/videoClient";
|
||||||
|
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
|
@ -181,30 +182,8 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
|
||||||
return userNamesWithBookingCounts;
|
return userNamesWithBookingCounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||||
|
return await prisma.eventType.findUnique({
|
||||||
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({
|
|
||||||
rejectOnNotFound: true,
|
rejectOnNotFound: true,
|
||||||
where: {
|
where: {
|
||||||
id: eventTypeId,
|
id: eventTypeId,
|
||||||
|
@ -235,10 +214,48 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
hideCalendarNotes: true,
|
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" });
|
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 this event was pre-relationship migration */
|
||||||
if (!users.length && eventType.userId) {
|
if (!users.length && eventType.userId) {
|
||||||
|
@ -340,7 +357,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
attendees: attendeesList,
|
attendees: attendeesList,
|
||||||
location: reqBody.location, // Will be processed by the EventManager later.
|
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,
|
destinationCalendar: eventType.destinationCalendar || users[0].destinationCalendar,
|
||||||
hideCalendarNotes: eventType.hideCalendarNotes,
|
hideCalendarNotes: eventType.hideCalendarNotes,
|
||||||
};
|
};
|
||||||
|
@ -362,6 +379,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
await verifyAccount(web3Details.userSignature, web3Details.userWallet);
|
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({
|
return prisma.booking.create({
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
@ -377,11 +405,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
description: evt.description,
|
description: evt.description,
|
||||||
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
|
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
|
||||||
location: evt.location,
|
location: evt.location,
|
||||||
eventType: {
|
eventType: eventTypeRel,
|
||||||
connect: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attendees: {
|
attendees: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: evt.attendees.map((attendee) => {
|
data: evt.attendees.map((attendee) => {
|
||||||
|
@ -397,6 +421,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
dynamicEventSlugRef,
|
||||||
|
dynamicGroupSlugRef,
|
||||||
user: {
|
user: {
|
||||||
connect: {
|
connect: {
|
||||||
id: users[0].id,
|
id: users[0].id,
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
|
|
||||||
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
@ -30,6 +32,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
dynamicEventSlugRef: true,
|
||||||
|
dynamicGroupSlugRef: true,
|
||||||
user: true,
|
user: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
@ -38,17 +42,19 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
attendees: true,
|
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 ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||||
|
|
||||||
const eventType = booking.eventType;
|
|
||||||
|
|
||||||
const eventPage =
|
const eventPage =
|
||||||
(eventType.team
|
(eventType.team
|
||||||
? "team/" + eventType.team.slug
|
? "team/" + eventType.team.slug
|
||||||
|
: dynamicEventSlugRef
|
||||||
|
? booking.dynamicGroupSlugRef
|
||||||
: booking.user?.username || "rick") /* This shouldn't happen */ +
|
: booking.user?.username || "rick") /* This shouldn't happen */ +
|
||||||
"/" +
|
"/" +
|
||||||
booking.eventType.slug;
|
eventType?.slug;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import Shell from "@components/Shell";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
|
import InfoBadge from "@components/ui/InfoBadge";
|
||||||
import ColorPicker from "@components/ui/colorpicker";
|
import ColorPicker from "@components/ui/colorpicker";
|
||||||
|
|
||||||
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
|
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
|
||||||
|
@ -126,6 +127,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
|
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
|
||||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||||
const hideBrandingRef = useRef<HTMLInputElement>(null!);
|
const hideBrandingRef = useRef<HTMLInputElement>(null!);
|
||||||
|
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
|
||||||
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
||||||
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
|
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
|
||||||
value: props.user.timeFormat || 12,
|
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 enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
|
||||||
const enteredWeekStartDay = selectedWeekStartDay.value;
|
const enteredWeekStartDay = selectedWeekStartDay.value;
|
||||||
const enteredHideBranding = hideBrandingRef.current.checked;
|
const enteredHideBranding = hideBrandingRef.current.checked;
|
||||||
|
const enteredAllowDynamicGroupBooking = allowDynamicGroupBookingRef.current.checked;
|
||||||
const enteredLanguage = selectedLanguage.value;
|
const enteredLanguage = selectedLanguage.value;
|
||||||
const enteredTimeFormat = selectedTimeFormat.value;
|
const enteredTimeFormat = selectedTimeFormat.value;
|
||||||
|
|
||||||
|
@ -182,6 +185,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
timeZone: enteredTimeZone,
|
timeZone: enteredTimeZone,
|
||||||
weekStart: asStringOrUndefined(enteredWeekStartDay),
|
weekStart: asStringOrUndefined(enteredWeekStartDay),
|
||||||
hideBranding: enteredHideBranding,
|
hideBranding: enteredHideBranding,
|
||||||
|
allowDynamicBooking: enteredAllowDynamicGroupBooking,
|
||||||
theme: asStringOrNull(selectedTheme?.value),
|
theme: asStringOrNull(selectedTheme?.value),
|
||||||
brandColor: enteredBrandColor,
|
brandColor: enteredBrandColor,
|
||||||
darkBrandColor: enteredDarkBrandColor,
|
darkBrandColor: enteredDarkBrandColor,
|
||||||
|
@ -363,6 +367,25 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
|
||||||
{t("single_theme")}
|
{t("single_theme")}
|
||||||
|
@ -507,6 +530,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
|
allowDynamicBooking: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { useRouter } from "next/router";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
import { sdkActionManager } from "@calcom/embed-core";
|
import { sdkActionManager } from "@calcom/embed-core";
|
||||||
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { EmailInput } from "@calcom/ui/form/fields";
|
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 getEventTypesFromDB = async (typeId: number) => {
|
||||||
const ssr = await ssrInit(context);
|
return await prisma.eventType.findUnique({
|
||||||
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
|
|
||||||
|
|
||||||
if (isNaN(typeId)) {
|
|
||||||
return {
|
|
||||||
notFound: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findUnique({
|
|
||||||
where: {
|
where: {
|
||||||
id: typeId,
|
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) {
|
if (!eventType) {
|
||||||
return {
|
return {
|
||||||
|
@ -481,6 +487,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
name: eventType.team?.name || eventType.users[0]?.name || null,
|
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,
|
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
|
||||||
brandColor: eventType.team ? null : eventType.users[0].brandColor,
|
brandColor: eventType.team ? null : eventType.users[0].brandColor,
|
||||||
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,
|
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,
|
||||||
|
|
|
@ -40,6 +40,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
slug: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
@ -113,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
},
|
},
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
booking,
|
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 { deleteAllBookingsByEmail } from "./lib/teardown";
|
||||||
import {
|
import {
|
||||||
|
bookFirstEvent,
|
||||||
|
bookTimeSlot,
|
||||||
selectFirstAvailableTimeSlotNextMonth,
|
selectFirstAvailableTimeSlotNextMonth,
|
||||||
selectSecondAvailableTimeSlotNextMonth,
|
selectSecondAvailableTimeSlotNextMonth,
|
||||||
todo,
|
todo,
|
||||||
} from "./lib/testUtils";
|
} 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.describe("free user", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/free");
|
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
|
// so it can click up on the right day, also when resolve remove other todos
|
||||||
// Waiting for full month increment
|
// Waiting for full month increment
|
||||||
await page.waitForTimeout(400);
|
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"]');
|
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
|
// so it can click up on the right day, also when resolve remove other todos
|
||||||
// Waiting for full month increment
|
// Waiting for full month increment
|
||||||
await page.waitForTimeout(400);
|
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();
|
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
|
// Provide an standalone localize utility not managed by next-i18n
|
||||||
export async function localize(locale: string) {
|
export async function localize(locale: string) {
|
||||||
const localeModule = `../../public/static/locales/${locale}/common.json`;
|
const localeModule = `../../public/static/locales/${locale}/common.json`;
|
||||||
|
|
|
@ -306,6 +306,9 @@
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"automatically_adjust_theme": "Automatically adjust theme based on invitee preferences",
|
"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": "Email",
|
||||||
"email_placeholder": "jdoe@example.com",
|
"email_placeholder": "jdoe@example.com",
|
||||||
"full_name": "Full name",
|
"full_name": "Full name",
|
||||||
|
|
|
@ -619,6 +619,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
timeZone: z.string().optional(),
|
timeZone: z.string().optional(),
|
||||||
weekStart: z.string().optional(),
|
weekStart: z.string().optional(),
|
||||||
hideBranding: z.boolean().optional(),
|
hideBranding: z.boolean().optional(),
|
||||||
|
allowDynamicBooking: z.boolean().optional(),
|
||||||
brandColor: z.string().optional(),
|
brandColor: z.string().optional(),
|
||||||
darkBrandColor: z.string().optional(),
|
darkBrandColor: z.string().optional(),
|
||||||
theme: z.string().optional().nullable(),
|
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
|
// the location where the events will end up
|
||||||
destinationCalendar DestinationCalendar?
|
destinationCalendar DestinationCalendar?
|
||||||
away Boolean @default(false)
|
away Boolean @default(false)
|
||||||
|
// participate in dynamic group booking or not
|
||||||
|
allowDynamicBooking Boolean? @default(true)
|
||||||
metadata Json?
|
metadata Json?
|
||||||
verified Boolean? @default(false)
|
verified Boolean? @default(false)
|
||||||
|
|
||||||
|
@ -256,6 +258,8 @@ model Booking {
|
||||||
destinationCalendar DestinationCalendar?
|
destinationCalendar DestinationCalendar?
|
||||||
cancellationReason String?
|
cancellationReason String?
|
||||||
rejectionReason String?
|
rejectionReason String?
|
||||||
|
dynamicEventSlugRef String?
|
||||||
|
dynamicGroupSlugRef String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Schedule {
|
model Schedule {
|
||||||
|
|
|
@ -250,7 +250,7 @@ async function main() {
|
||||||
title: "paid",
|
title: "paid",
|
||||||
slug: "paid",
|
slug: "paid",
|
||||||
length: 60,
|
length: 60,
|
||||||
price: 50,
|
price: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "In person meeting",
|
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"
|
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.1.8.tgz#ba0f606ed91959ede670303d905b99106e9e6293"
|
||||||
integrity sha512-hwarSBCswAXa+kqYtaAkFr3Vop9o04WOyZs0qo3NyvW8L7f1rif61wRyq0+ArmVThOuRBcJF5hjGXYk86cwemg==
|
integrity sha512-hwarSBCswAXa+kqYtaAkFr3Vop9o04WOyZs0qo3NyvW8L7f1rif61wRyq0+ArmVThOuRBcJF5hjGXYk86cwemg==
|
||||||
|
|
||||||
prettier@^2.5.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==
|
|
||||||
|
|
||||||
prettier@^2.6.1:
|
|
||||||
version "2.6.2"
|
version "2.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
|
||||||
integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
|
integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
|
||||||
|
|
Loading…
Reference in a new issue