22 Commits

Author SHA1 Message Date
zomars
f2988870d5 Update booking-pages.test.ts 2022-05-11 12:03:22 -06:00
zomars
f0ea8d30ca Parallelizes some tests 2022-05-11 11:19:22 -06:00
zomars
c3909ccc70 Multiple E2E improvements 2022-05-11 10:46:52 -06:00
Syed Ali Shahbaz
01e88b3807 Allow deletion of a disabled event (#2737)
* allows deletion of disabled event

* some visual fixes

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-05-11 14:14:08 +00:00
Hariom Balhara
effb9d56d9 Fix preview.html not built and thus served during depooy (#2727)
Co-authored-by: Omar López <zomars@me.com>
2022-05-11 14:01:49 +00:00
Leo Giovanetti
3bbbc80511 Hotfix: Success page for recurring event (#2725)
* Merge pull request #2672 from calcom/main

v1.5.4

* Turbo fixes

* Make apps single pages public

* Fix preview.html not built and thus served during depooy (#2713)

* Hotfix: Success page layout broken due to duplicate "When" (#2716)

* Update BookingPage.tsx

* Reverting unchaged lines

* Fixing recurrenceRule for ICS files

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2022-05-11 10:12:59 -03:00
Hariom Balhara
19128fb08e Hotfix : Fix Infinite loading of Bookings (#2729)
* Add more embed events

* Add more embed events

* Fix nextCursor calculation logic

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-05-11 10:35:43 +00:00
Peer Richelsen
0945bbe5cf fixes #2732 (#2732) 2022-05-11 12:04:04 +02:00
Peer Richelsen
ced6975fc8 added giphy description (#2730) 2022-05-11 11:46:27 +02:00
Joe Au-Yeung
fb436996c0 Change date format for RecurringBookings (#2707)
* Change date format for RecurringBookings

* Missing bookingId query param

Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-05-11 07:59:49 +00:00
Hariom Balhara
50f1fe544e Improve logs and Fix unwanted 500 to reduce noise in logs (#2674)
* Improve logs

* Fix unintentional 500

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2022-05-11 05:41:09 +00:00
Syed Ali Shahbaz
746643bf8e adds availability select loader (#2718) 2022-05-11 05:26:06 +00:00
Hariom Balhara
65a69ef1e4 Add more embed events (#2719)
* Add more embed events

* Add more embed events

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-05-11 05:14:08 +00:00
Carina Wollendorfer
6483182ef6 add invite link to Zapier setup page (#2696)
* add invite link and toaster to zapier setup page

* create env variable for invite link and save in database

* fetch invite link form getStaticProps

* add getStaticPath method

* clean code

* Moves app setup and index page

* Moves Loader to ui

* Trying new way to handle dynamic app store pages

* Cleanup

* Update tailwind.config.js

* zapier invite link fixes

* Tests fixes

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2022-05-11 04:58:10 +00:00
zomars
784a91709c Update dynamic-booking-pages.test.ts 2022-05-10 22:46:22 -06:00
zomars
82a52e065f More test fixes 2022-05-10 22:28:48 -06:00
zomars
a1f6738cf1 Update playwright.config.ts 2022-05-10 21:51:24 -06:00
zomars
a231945842 Test fixes 2022-05-10 21:37:09 -06:00
zomars
a507d5963c Type fixes 2022-05-10 21:35:44 -06:00
Peer Richelsen
92806d5257 fixed /booking skeleton (#2722)
* fixed /booking skeleton

* nit
2022-05-10 16:59:23 +02:00
zomars
9440df4445 Make apps single pages public 2022-05-09 16:17:23 -06:00
zomars
4e0efb76cd Turbo fixes 2022-05-09 16:17:23 -06:00
55 changed files with 1766 additions and 277 deletions

View File

@@ -81,4 +81,9 @@ VITAL_WEBHOOK_SECRET=
VITAL_DEVELOPMENT_MODE="sandbox"
# "us" | "eu"
VITAL_REGION="us"
# - ZAPIER
# Used for the Zapier integration
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
ZAPIER_INVITE_LINK=""
# *********************************************************************************************************

View File

@@ -84,7 +84,7 @@ export default function App({
}, []);
return (
<>
<Shell large>
<Shell large isPublic>
<div className="-mx-4 md:-mx-8">
<div className="bg-gray-50 px-4">
<Link href="/apps">

View File

@@ -1,7 +1 @@
export default function Loader() {
return (
<div className="loader border-brand dark:border-darkmodebrand">
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
);
}
export { default } from "@calcom/ui/Loader";

View File

@@ -31,3 +31,16 @@ function SkeletonItem() {
</li>
);
}
export const AvailabilitySelectSkeletonLoader = () => {
return (
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
<div className="flex-grow truncate text-sm">
<div className="flex justify-between">
<SkeletonText width="32" height="4"></SkeletonText>
<SkeletonText width="4" height="4"></SkeletonText>
</div>
</div>
</li>
);
};

View File

@@ -2,8 +2,6 @@ import React from "react";
import { SkeletonText } from "@calcom/ui";
import BookingsShell from "@components/BookingsShell";
function SkeletonLoader() {
return (
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
@@ -22,10 +20,9 @@ function SkeletonItem() {
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<SkeletonText width="32" height="5" />
<SkeletonText width="16" height="4" />
<SkeletonText width="16" height="5" />
<SkeletonText width="32" height="4" />
</div>
<SkeletonText width="24" height="5" className="ml-4" />
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">

View File

@@ -113,8 +113,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
telemetry.withJitsu((jitsu) =>
jitsu.track(
telemetryEventTypes.pageView,
collectPageParameters("availability", { isTeamBooking: document.URL.includes("team/") })
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
)
);
}, [telemetry]);

View File

@@ -90,6 +90,15 @@ const BookingPage = ({
const { data: session } = useSession();
const isBackgroundTransparent = useIsBackgroundTransparent();
useEffect(() => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
)
);
}, []);
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];
@@ -144,7 +153,7 @@ const BookingPage = ({
const recurringMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData = []) => {
const { attendees = [], recurringEventId } = responseData[0] || {};
const { attendees = [], id, recurringEventId } = responseData[0] || {};
const location = (function humanReadableLocation(location) {
if (!location) {
return;
@@ -168,6 +177,7 @@ const BookingPage = ({
email: attendees[0].email,
location,
eventName: profile.eventName || "",
bookingId: id,
},
});
},
@@ -293,7 +303,7 @@ const BookingPage = ({
const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
telemetryEventTypes.bookingConfirmed,
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
)
);

View File

@@ -2,6 +2,7 @@ import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
import { createPaymentLink } from "@calcom/stripe/client";
@@ -16,8 +17,8 @@ export type PaymentInfo = {
id?: string | null;
};
const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!;
const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!;
let paymentFeePercentage: number | undefined;
let paymentFeeFixed: number | undefined;
export async function handlePayment(
evt: CalendarEvent,
@@ -33,6 +34,10 @@ export async function handlePayment(
uid: string;
}
) {
const appKeys = await getAppKeysFromSlug("stripe");
if (typeof appKeys.payment_fee_fixed === "number") paymentFeePercentage = appKeys.payment_fee_fixed;
if (typeof appKeys.payment_fee_percentage === "number") paymentFeeFixed = appKeys.payment_fee_percentage;
const paymentFee = Math.round(
selectedEventType.price * parseFloat(`${paymentFeePercentage}`) + parseInt(`${paymentFeeFixed}`)
);

View File

@@ -60,7 +60,7 @@ export default class AttendeeScheduledEmail {
// Taking care of recurrence rule beforehand
let recurrenceRule: string | undefined = undefined;
if (this.recurringEvent?.count) {
recurrenceRule = new rrule(this.recurringEvent).toString();
recurrenceRule = new rrule(this.recurringEvent).toString().replace("RRULE:", "");
}
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)

View File

@@ -57,7 +57,7 @@ export default class OrganizerScheduledEmail {
// Taking care of recurrence rule beforehand
let recurrenceRule: string | undefined = undefined;
if (this.recurringEvent?.count) {
recurrenceRule = new rrule(this.recurringEvent).toString();
recurrenceRule = new rrule(this.recurringEvent).toString().replace("RRULE:", "");
}
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)

View File

@@ -13,6 +13,8 @@ export const telemetryEventTypes = {
googleLogin: "google_login",
samlLogin: "saml_login",
samlConfig: "saml_config",
embedView: "embed_view",
embedBookingConfirmed: "embed_booking_confirmed",
};
/**

View File

@@ -119,7 +119,10 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
useEffect(() => {
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.pageView, collectPageParameters("/[user]"))
jitsu.track(
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/[user]")
)
);
}, [telemetry]);
return (

View File

@@ -94,6 +94,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!users.length) return { notFound: true };
const [user] = users;
const isDynamicGroupBooking = users.length > 1;
// Dynamic Group link doesn't need a type but it must have a slug
if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) {
return { notFound: true };
}
const eventTypeRaw =
usernameList.length > 1
? getDefaultEvent(eventTypeSlug)
@@ -173,8 +180,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking = await getBooking(prisma, context.query.rescheduleUid as string);
}
const isDynamicGroupBooking = users.length > 1;
const dynamicNames = isDynamicGroupBooking
? users.map((user) => {
return user.name || "";

View File

@@ -25,6 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(404);
}
} catch (error) {
console.log(error);
res.status(500);
}
} else {

View File

@@ -54,7 +54,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
type={data.type}
logo={data.logo}
categories={[data.category]}
author="Cal.com"
author={data.publisher}
feeType={data.feeType || "usage-based"}
price={data.price || 0}
commission={data.commission || 0}

View File

@@ -0,0 +1,45 @@
import { InferGetStaticPropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { AppSetupPageMap, getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import prisma from "@calcom/prisma";
import Loader from "@calcom/ui/Loader";
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
const router = useRouter();
const slug = router.query.slug as string;
const { status } = useSession();
if (status === "loading") {
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
}
if (status === "unauthenticated") {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `/apps/${slug}/setup`,
},
});
}
return <AppSetupPage slug={slug} {...props} />;
}
export const getStaticPaths = async () => {
const appStore = await prisma.app.findMany({ select: { slug: true } });
const paths = appStore.filter((a) => a.slug in AppSetupPageMap).map((app) => app.slug);
return {
paths: paths.map((slug) => ({ params: { slug } })),
fallback: false,
};
};
export { getStaticProps };

View File

@@ -1,38 +0,0 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import _zapierMetadata from "@calcom/app-store/zapier/_metadata";
import { ZapierSetup } from "@calcom/app-store/zapier/components";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
export default function SetupInformation() {
const router = useRouter();
const appName = router.query.appName;
const { status } = useSession();
if (status === "loading") {
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
}
if (status === "unauthenticated") {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `/apps/setup/${appName}`,
},
});
}
if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") {
return <ZapierSetup trpc={trpc}></ZapierSetup>;
}
return null;
}

View File

@@ -60,10 +60,7 @@ export default function Bookings() {
};
return (
<Shell
heading={t("bookings")}
subtitle={t("bookings_description")}
customLoader={<SkeletonLoader></SkeletonLoader>}>
<Shell heading={t("bookings")} subtitle={t("bookings_description")} customLoader={<SkeletonLoader />}>
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
<BookingsShell>
<div className="-mx-4 flex flex-col sm:mx-auto">

View File

@@ -60,6 +60,7 @@ import { EmbedButton, EmbedDialog } from "@components/Embed";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import { AvailabilitySelectSkeletonLoader } from "@components/availability/SkeletonLoader";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import RecurringEventController from "@components/eventtype/RecurringEventController";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
@@ -164,6 +165,7 @@ const AvailabilitySelect = ({
return (
<QueryCell
query={query}
customLoader={<AvailabilitySelectSkeletonLoader />}
success={({ data }) => {
const options = data.schedules.map((schedule) => ({
value: schedule.id,

View File

@@ -78,7 +78,10 @@ const Item = ({ type, group, readOnly }: any) => {
return (
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow truncate text-sm"
className={classNames(
"flex-grow truncate text-sm ",
type.$disabled && "pointer-events-none cursor-not-allowed opacity-30"
)}
title={`${type.title} ${type.description ? ` ${type.description}` : ""}`}>
<div>
<span
@@ -207,17 +210,19 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{types.map((type, index) => (
<li
key={type.id}
className={classNames(
type.$disabled && "pointer-events-none cursor-not-allowed select-none opacity-30"
)}
className={classNames(type.$disabled && "select-none")}
data-disabled={type.$disabled ? 1 : 0}>
<div
className={classNames(
"flex items-center justify-between hover:bg-neutral-50 ",
type.$disabled && "pointer-events-none"
type.$disabled && "hover:bg-white"
)}>
<div className="group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6">
{types.length > 1 && (
<div
className={classNames(
"group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6",
type.$disabled && "hover:bg-white"
)}>
{types.length > 1 && !type.$disabled && (
<>
<button
className="invisible absolute left-1/2 -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
@@ -238,7 +243,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{type.users?.length > 1 && (
<AvatarGroup
border="border-2 border-white"
className="relative top-1 right-3"
className={classNames("relative top-1 right-3", type.$disabled && " opacity-30")}
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
@@ -247,28 +252,38 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
}))}
/>
)}
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className="btn-icon appearance-none">
<ExternalLinkIcon className="h-5 w-5 group-hover:text-black" />
</a>
</Tooltip>
<div
className={classNames(
"flex justify-between space-x-2 rtl:space-x-reverse ",
type.$disabled && "pointer-events-none cursor-not-allowed"
)}>
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className={classNames("btn-icon appearance-none", type.$disabled && " opacity-30")}>
<ExternalLinkIcon
className={classNames("h-5 w-5", !type.$disabled && "group-hover:text-black")}
/>
</a>
</Tooltip>
<Tooltip content={t("copy_link")}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`
);
}}
className="btn-icon">
<LinkIcon className="h-5 w-5 group-hover:text-black" />
</button>
</Tooltip>
<Tooltip content={t("copy_link")}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`
);
}}
className={classNames("btn-icon", type.$disabled && " opacity-30")}>
<LinkIcon
className={classNames("h-5 w-5", !type.$disabled && "group-hover:text-black")}
/>
</button>
</Tooltip>
</div>
<Dropdown>
<DropdownMenuTrigger
className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900 focus:border-gray-300"
@@ -282,7 +297,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
type="button"
size="sm"
color="minimal"
className="w-full rounded-none"
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
StartIcon={PencilIcon}>
{" "}
{t("edit")}
@@ -294,7 +312,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
type="button"
color="minimal"
size="sm"
className="w-full rounded-none"
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
data-testid={"event-type-duplicate-" + type.id}
StartIcon={DuplicateIcon}
onClick={() => openModal(group, type)}>
@@ -304,7 +325,10 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<DropdownMenuItem>
<EmbedButton
dark
className="w-full rounded-none"
className={classNames(
"w-full rounded-none",
type.$disabled && " pointer-events-none cursor-not-allowed opacity-30"
)}
eventTypeId={type.id}></EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />

View File

@@ -35,6 +35,7 @@ import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
import prisma from "@lib/prisma";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { isBrowserLocale24h } from "@lib/timeFormat";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -172,6 +173,15 @@ export default function Success(props: SuccessProps) {
const eventName = getEventName(eventNameObject);
const needsConfirmation = eventType.requiresConfirmation && reschedule != "true";
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) =>
jitsu.track(
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
collectPageParameters("/success")
)
);
}, [telemetry]);
useEffect(() => {
const users = eventType.users;
@@ -300,14 +310,6 @@ export default function Success(props: SuccessProps) {
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6">
{date.format("MMMM DD, YYYY")}
<br />
{date.format("LT")} - {date.add(props.eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
<div className="col-span-2">
<RecurringBookings
isReschedule={reschedule === "true"}
eventType={props.eventType}
@@ -317,7 +319,7 @@ export default function Success(props: SuccessProps) {
/>
</div>
<div className="font-medium">{t("who")}</div>
<div className="col-span-2">
<div className="col-span-2 mb-6">
{bookingInfo?.user && (
<div className="mb-3">
<p>{bookingInfo.user.name}</p>
@@ -546,9 +548,9 @@ function RecurringBookings({
{eventType.recurringEvent?.count &&
recurringBookings.slice(0, 4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
{dayjs(dateStr).format("MMMM DD, YYYY")}
<br />
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
@@ -565,9 +567,9 @@ function RecurringBookings({
{eventType.recurringEvent?.count &&
recurringBookings.slice(4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
{dayjs(dateStr).format("MMMM DD, YYYY")}
<br />
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
@@ -579,9 +581,9 @@ function RecurringBookings({
</>
) : !eventType.recurringEvent.freq ? (
<>
{date.format("dddd, DD MMMM YYYY")}
{date.format("MMMM DD, YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
{date.format("LT")} - {date.add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>

View File

@@ -1,44 +1,27 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("App Store - Authed", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
test("Browse apple-calendar and try to install", async ({ page }) => {
await page.goto("/apps");
page.click('[data-testid="app-store-category-calendar"]');
await page.waitForNavigation({
url: (url) => {
console.log(url, url.pathname);
return url.pathname.includes("apps/categories/calendar");
},
});
page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.waitForNavigation({
url: (url) => {
return url.pathname.includes("apps/apple-calendar");
},
});
await page.click('[data-testid="app-store-category-calendar"]');
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.click('[data-testid="install-app-button"]');
await expect(page.locator(`text=Connect to Apple Server`)).toBeVisible();
});
});
test.describe("App Store - Unauthed", () => {
test("Browse apple-calendar and try to install", async ({ page }) => {
await page.goto("/apps");
await page.waitForSelector("[data-testid=dashboard-shell]");
await page.click('[data-testid="app-store-category-calendar"]');
if (!page.url().includes("apps/categories/calendar")) {
await page.waitForNavigation({
url: (url) => {
console.log(url, url.pathname);
return url.pathname.includes("apps/categories/calendar");
},
});
}
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
await page.waitForNavigation({
url: (url) => {
return url.pathname.includes("/auth/login");
},
});
await page.click('[data-testid="install-app-button"]');
await expect(page.locator(`[data-testid="login-form"]`)).toBeVisible();
});
});

View File

@@ -9,6 +9,8 @@ import {
todo,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("free user", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/free");
@@ -44,11 +46,7 @@ test.describe("free user", () => {
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// return to same time spot booking page
await page.goto(bookingUrl);
@@ -98,11 +96,7 @@ test.describe("pro user", () => {
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("can reschedule a booking", async ({ page }) => {
await bookFirstEvent(page);

View File

@@ -1,4 +1,4 @@
import { Page, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import {
@@ -28,13 +28,7 @@ test.describe("dynamic booking", () => {
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");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("can reschedule a booking", async ({ page }) => {
@@ -58,6 +52,7 @@ test.describe("dynamic booking", () => {
return url.pathname === "/success" && url.searchParams.get("reschedule") === "true";
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("Can cancel the recently created booking", async ({ page }) => {

View File

@@ -79,6 +79,8 @@ async function expectToContainValidPreviewIframe(
);
}
test.describe.configure({ mode: "parallel" });
test.describe("Embed Code Generator Tests", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });

View File

@@ -3,6 +3,8 @@ import { expect, Locator, test } from "@playwright/test";
import { randomString } from "../lib/random";
import { deleteEventTypeByTitle } from "./lib/teardown";
test.describe.configure({ mode: "parallel" });
test.describe("Event Types tests", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/event-types");

View File

@@ -46,11 +46,7 @@ test.describe("hash my url", () => {
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("hash regenerates after successful booking", async ({ page }) => {

View File

@@ -71,11 +71,7 @@ test.describe.serial("Stripe integration", () => {
await page.click('button:has-text("Pay now")');
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
todo("Pending payment booking should not be confirmed by default");

View File

@@ -1,4 +1,4 @@
import { Page, test } from "@playwright/test";
import { expect, Page, test } from "@playwright/test";
import { createServer, IncomingMessage, ServerResponse } from "http";
export function todo(title: string) {
@@ -99,11 +99,7 @@ export async function bookFirstEvent(page: Page) {
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");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
}
export const bookTimeSlot = async (page: Page) => {

View File

@@ -153,11 +153,7 @@ test.describe("Reschedule Tests", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page).toHaveURL(/.*success/);

View File

@@ -817,7 +817,7 @@
"generate_api_key": "Generate Api Key",
"your_unique_api_key": "Your unique API key",
"copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
"zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
"zapier_setup_instructions": "<0>Go to: <1>Zapier Invite Link</1></0><1>Log into your Zapier account and create a new Zap.</1><2>Select Cal.com as your Trigger app. Also choose a Trigger event.</2><3>Choose your account and then enter your Unique API Key.</3><4>Test your Trigger.</4><5>You're set!</5>",
"install_zapier_app": "Please first install the Zapier App in the app store.",
"go_to_app_store": "Go to App Store",
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions",

View File

@@ -371,7 +371,6 @@ const loggedInViewerRouter = createProtectedRouter()
};
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
const orderBy = bookingListingOrderby[bookingListingByStatus];
const bookingsQuery = await prisma.booking.findMany({
where: {
OR: [
@@ -440,7 +439,7 @@ const loggedInViewerRouter = createProtectedRouter()
endTime: booking.endTime.toISOString(),
};
});
const bookingsFetched = bookings.length;
const seenBookings: Record<string, boolean> = {};
// Remove duplicate recurring bookings for upcoming status.
@@ -461,7 +460,8 @@ const loggedInViewerRouter = createProtectedRouter()
}
let nextCursor: typeof skip | null = skip;
if (bookings.length > take) {
if (bookingsFetched > take) {
bookings.shift();
nextCursor += bookings.length;
} else {

View File

@@ -4,6 +4,6 @@ module.exports = {
content: [
...base.content,
"../../packages/ui/**/*.{js,ts,jsx,tsx}",
"../../packages/app-store/**/components/*.{js,ts,jsx,tsx}",
"../../packages/app-store/**/{components,pages}/**/*.{js,ts,jsx,tsx}",
],
};

View File

@@ -28,7 +28,7 @@
"heroku-postbuild": "turbo run @calcom/web#build",
"lint": "turbo run lint",
"lint:report": "turbo run lint:report",
"postinstall": "turbo run postinstall",
"postinstall": "turbo run post-install",
"pre-commit": "lint-staged",
"env-check:common": "dotenv-checker --schema .env.example --env .env",
"env-check:app-store": "dotenv-checker --schema .env.appStore.example --env .env.appStore",

View File

@@ -0,0 +1,9 @@
export function DynamicComponent<T extends Record<string, any>>(props: { componentMap: T; slug: string }) {
const { componentMap, slug, ...rest } = props;
if (!componentMap[slug]) return null;
const Component = componentMap[slug];
return <Component {...rest} />;
}

View File

@@ -0,0 +1,20 @@
import { GetStaticPropsContext } from "next";
export const AppSetupPageMap = {
zapier: import("../../zapier/pages/setup/_getStaticProps"),
};
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
const { slug } = ctx.params || {};
if (typeof slug !== "string") return { notFound: true } as const;
if (!(slug in AppSetupPageMap)) return { props: {} };
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
if (!page.getStaticProps) return { props: {} };
const props = await page.getStaticProps(ctx);
return props;
};

View File

@@ -0,0 +1,13 @@
import dynamic from "next/dynamic";
import { DynamicComponent } from "../../_components/DynamicComponent";
export const AppSetupMap = {
zapier: dynamic(() => import("../../zapier/pages/setup")),
};
export const AppSetupPage = (props: { slug: string }) => {
return <DynamicComponent<typeof AppSetupMap> componentMap={AppSetupMap} {...props} />;
};
export default AppSetupPage;

View File

@@ -0,0 +1,9 @@
---
items:
- /api/app-store/giphy/GIPHY1.png
- /api/app-store/giphy/GIPHY2.png
---
<Slider items={items} />
An online database and search engine that allows users to search for and share short looping videos with no sound that resemble animated GIF files. GIPHY is your top source for the best & newest GIFs & Animated Stickers online. Find everything from funny GIFs, reaction GIFs, unique GIFs and more to add to your custom booking page. Located under advanced settings in each event type.

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,3 +1,5 @@
import { ButtonBaseProps } from "@calcom/ui/Button";
export type IntegrationOAuthCallbackState = {
returnTo: string;
};

View File

@@ -56,9 +56,9 @@ Booking created, Booking rescheduled, Booking cancelled
Create the other two triggers (booking rescheduled, booking cancelled) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created)
### Testing integration
### Set ZAPIER_INVITE_LINK
Use the sharing link under Manage → Sharing to create your first Cal.com trigger in Zapier
The invite link can be found under under Manage → Sharing.
## Localhost

View File

@@ -2,5 +2,4 @@ Workflow automation for everyone. Use the Cal.com Zapier app to trigger your wor
<br />
**After Installation:** You lost your generated API key? Here you can generate a new key and find all information
on how to use the installed app: <a href="/apps/setup/zapier">Zapier App Setup</a>
on how to use the installed app: <a href="/apps/zapier/setup">Zapier App Setup</a>

View File

@@ -35,5 +35,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500);
}
return res.status(200).json({ url: "/apps/setup/zapier" });
return res.status(200).json({ url: "/apps/zapier/setup" });
}

View File

@@ -1,3 +1,2 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as ZapierSetup } from "./zapierSetup";
export { default as Icon } from "./icon";

View File

@@ -0,0 +1,20 @@
import { GetStaticPropsContext } from "next";
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
export interface IZapierSetupProps {
inviteLink: string;
}
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
let inviteLink = "";
const appKeys = await getAppKeysFromSlug("zapier");
if (typeof appKeys.invite_link === "string") inviteLink = appKeys.invite_link;
return {
props: {
inviteLink,
},
};
};

View File

@@ -2,28 +2,31 @@ import { ClipboardCopyIcon } from "@heroicons/react/solid";
import { Trans } from "next-i18next";
import Link from "next/link";
import { useState } from "react";
import { Toaster } from "react-hot-toast";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui";
import { Tooltip } from "@calcom/ui/Tooltip";
import Loader from "@calcom/web/components/Loader";
import { Button, Loader, Tooltip } from "@calcom/ui";
import Icon from "./icon";
/** TODO: Maybe extract this into a package to prevent circular dependencies */
import { trpc } from "@calcom/web/lib/trpc";
interface IZapierSetupProps {
trpc: any;
import Icon from "../../components/icon";
export interface IZapierSetupProps {
inviteLink: string;
}
const ZAPIER = "zapier";
export default function ZapierSetup(props: IZapierSetupProps) {
const { trpc } = props;
const [newApiKey, setNewApiKey] = useState("");
const { t } = useLocale();
const utils = trpc.useContext();
const integrations = trpc.useQuery(["viewer.integrations"]);
// @ts-ignore
const oldApiKey = trpc.useQuery(["viewer.apiKeys.findKeyOfType", { appId: ZAPIER }]);
const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete");
const zapierCredentials: { credentialIds: number[] } | undefined = integrations.data?.other?.items.find(
(item: { type: string }) => item.type === "zapier_other"
@@ -33,6 +36,7 @@ export default function ZapierSetup(props: IZapierSetupProps) {
async function createApiKey() {
const event = { note: "Zapier", expiresAt: null, appId: ZAPIER };
// @ts-ignore
const apiKey = await utils.client.mutation("viewer.apiKeys.create", event);
if (oldApiKey.data) {
deleteApiKey.mutate({
@@ -91,8 +95,14 @@ export default function ZapierSetup(props: IZapierSetupProps) {
</>
)}
<ol className="mt-5 mb-5 mr-5 list-decimal">
<ol className="mt-5 mb-5 ml-5 mr-5 list-decimal">
<Trans i18nKey="zapier_setup_instructions">
<li>
Go to:
<a href={props.inviteLink} className="text-orange-600 underline">
Zapier Invite Link
</a>
</li>
<li>Log into your Zapier account and create a new Zap.</li>
<li>Select Cal.com as your Trigger app. Also choose a Trigger event.</li>
<li>Choose your account and then enter your Unique API Key.</li>
@@ -116,6 +126,7 @@ export default function ZapierSetup(props: IZapierSetupProps) {
</div>
</div>
)}
<Toaster position="bottom-right" />
</div>
);
}

View File

@@ -83,11 +83,7 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
const responseObj = await response.json();
const bookingId = responseObj.uid;
// Make sure we're navigated to the success page
await frame.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
expect(await page.screenshot()).toMatchSnapshot("success-page.png");
return bookingId;
}

View File

@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "yarn generate-schemas",
"build": "yarn prisma migrate deploy && yarn seed-app-store",
"clean": "rm -rf .turbo && rm -rf node_modules",
"db-deploy": "yarn prisma migrate deploy",
"db-migrate": "yarn prisma migrate dev",
@@ -13,10 +13,9 @@
"db-setup": "run-s db-up db-deploy db-seed",
"db-studio": "yarn prisma studio",
"db-up": "docker-compose up -d",
"deploy": "yarn prisma migrate deploy && yarn seed-app-store",
"dx": "yarn db-setup",
"generate-schemas": "prisma generate && prisma format",
"postinstall": "yarn generate-schemas",
"post-install": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts"
},
"devDependencies": {

View File

@@ -95,7 +95,12 @@ async function main() {
webhook_secret: process.env.VITAL_WEBHOOK_SECRET,
});
}
await createApp("zapier", "zapier", ["other"], "zapier_other");
if (process.env.ZAPIER_INVITE_LINK) {
await createApp("zapier", "zapier", ["other"], "zapier_other", {
invite_link: process.env.ZAPIER_INVITE_LINK,
});
}
// Web3 apps
await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video");
await createApp("metamask", "metamask", ["web3"], "metamask_web3");

7
packages/ui/Loader.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function Loader() {
return (
<div className="loader border-brand dark:border-darkmodebrand">
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
);
}

View File

@@ -1,6 +1,7 @@
export { default as Button } from "./Button";
export { default as EmptyScreen } from "./EmptyScreen";
export { default as Select } from "./form/Select";
export { default as Loader } from "./Loader";
export * from "./skeleton";
export { default as Switch } from "./Switch";
export { default as Tooltip } from "./Tooltip";

View File

@@ -1,5 +1,6 @@
import { PlaywrightTestConfig, devices } from "@playwright/test";
import { devices, PlaywrightTestConfig } from "@playwright/test";
import { addAliases } from "module-alias";
import * as os from "os";
import * as path from "path";
// Add aliases for the paths specified in the tsconfig.json file.
@@ -17,11 +18,16 @@ addAliases({
const outputDir = path.join(__dirname, "..", "..", "test-results");
const testDir = path.join(__dirname, "..", "..", "apps/web/playwright");
const DEFAULT_NAVIGATION_TIMEOUT = 5000;
const headless = !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS;
const config: PlaywrightTestConfig = {
forbidOnly: !!process.env.CI,
retries: 1,
workers: 1,
workers: os.cpus().length,
timeout: 60_000,
maxFailures: headless ? 3 : undefined,
reporter: [
[process.env.CI ? "github" : "list"],
["html", { outputFolder: "./playwright/reports/playwright-html-report", open: "never" }],
@@ -36,16 +42,20 @@ const config: PlaywrightTestConfig = {
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://localhost:3000",
baseURL: "http://localhost:3000/",
locale: "en-US",
trace: "retain-on-failure",
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
headless,
},
projects: [
{
name: "chromium",
testDir,
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
/** If navigation takes more than this, then something's wrong, let's fail fast. */
navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT,
},
},
/* {
name: "firefox",

View File

@@ -3,10 +3,6 @@
"baseBranch": "origin/main",
"globalDependencies": [".env", "packages/prisma/.env"],
"pipeline": {
"@calcom/prisma#build": {
"dependsOn": ["$DATABASE_URL"],
"outputs": ["zod/**"]
},
"@calcom/prisma#db-deploy": {
"cache": false,
"dependsOn": ["$DATABASE_URL"]
@@ -123,7 +119,7 @@
"cache": false,
"outputs": ["lint-results"]
},
"postinstall": {},
"post-install": {},
"start": {},
"embed-tests": {
"cache": false
@@ -136,7 +132,7 @@
},
"test-e2e": {
"cache": false,
"dependsOn": ["@calcom/web#test", "@calcom/web#build", "@calcom/prisma#db-reset"]
"dependsOn": ["@calcom/prisma#db-reset", "@calcom/web#test", "@calcom/web#build"]
},
"type-check": {
"outputs": []

1480
yarn.lock

File diff suppressed because it is too large Load Diff