Merge branch 'main' of ssh://github.com/calcom/cal.com into feature/reschedule-bookings

This commit is contained in:
Alan 2022-04-06 01:42:31 -06:00
commit 542b7b9259
38 changed files with 378 additions and 106 deletions

View File

@ -0,0 +1,54 @@
import { InformationCircleIcon } from "@heroicons/react/outline";
import { Trans } from "next-i18next";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
import { useLocale } from "@lib/hooks/useLocale";
export function UpgradeToProDialog({
modalOpen,
setModalOpen,
children,
}: {
modalOpen: boolean;
setModalOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
const { t } = useLocale();
return (
<Dialog open={modalOpen}>
<DialogContent>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
<InformationCircleIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" />
</div>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg font-bold leading-6 text-gray-900" id="modal-title">
{t("only_available_on_pro_plan")}
</h3>
</div>
</div>
<div className="flex flex-col space-y-3">
<p>{children}</p>
<p>
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
</p>
</div>
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button className="btn-wide table-cell text-center" onClick={() => setModalOpen(false)}>
{t("dismiss")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -52,7 +52,7 @@ type BookingFormValues = {
};
};
const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const { contracts } = useContracts();
@ -130,21 +130,6 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
const telemetry = useTelemetry();
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
// TODO: Move to translations
// Also TODO: Get these dynamically from App Store
const locationLabels = {
[LocationType.InPerson]: t("in_person_meeting"),
[LocationType.Phone]: t("phone_call"),
[LocationType.Link]: t("link_meeting"),
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
[LocationType.Jitsi]: "Jitsi Meet",
[LocationType.Daily]: "Cal Video",
[LocationType.Huddle01]: "Huddle01 Video",
[LocationType.Tandem]: "Tandem Video",
[LocationType.Teams]: "MS Teams",
};
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
const defaultValues = () => {
if (!rescheduleUid) {

View File

@ -23,7 +23,7 @@ Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, s
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
the AGPLv3 license. Any part of this Software distributed under the AGPLv3 license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall

View File

@ -0,0 +1,14 @@
import { Team, User } from ".prisma/client";
export function isSuccessRedirectAvailable(
eventType: {
users: {
plan: User["plan"];
}[];
} & {
team: Partial<Team> | null;
}
) {
// As Team Event is available in PRO plan only, just check if it's a team event.
return eventType.users[0]?.plan !== "FREE" || eventType.team;
}

View File

@ -1 +1 @@
export * from "@calcom/lib/location";
export * from "@calcom/core/location";

View File

@ -5,12 +5,15 @@ import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import BookingPage from "@components/booking/pages/BookingPage";
import { getTranslation } from "@server/lib/i18n";
import { ssrInit } from "@server/lib/ssr";
dayjs.extend(utc);
@ -133,8 +136,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking = await getBooking();
}
const t = await getTranslation(context.locale ?? "en", "common");
return {
props: {
locationLabels: getLocationLabels(t),
profile: {
slug: user.username,
name: user.name,

View File

@ -106,7 +106,7 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
/* If the app doesn't have a README we fallback to the packagfe description */
source = fs.readFileSync(postFilePath).toString();
} catch (error) {
console.log("error", error);
console.log(`No README.mdx provided for: ${appDirname}`);
source = singleApp.description;
}

View File

@ -23,7 +23,7 @@ import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Controller, Noop, useForm } from "react-hook-form";
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import Select, { Props as SelectProps } from "react-select";
import { JSONObject } from "superjson/dist/types";
@ -42,6 +42,7 @@ import { QueryCell } from "@lib/QueryCell";
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
import { LocationType } from "@lib/location";
import prisma from "@lib/prisma";
import { slugify } from "@lib/slugify";
@ -52,8 +53,10 @@ import { ClientSuspense } from "@components/ClientSuspense";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
import Badge from "@components/ui/Badge";
import InfoBadge from "@components/ui/InfoBadge";
import CheckboxField from "@components/ui/form/CheckboxField";
import CheckedSelect from "@components/ui/form/CheckedSelect";
@ -86,6 +89,53 @@ type OptionTypeBase = {
disabled?: boolean;
};
const SuccessRedirectEdit = <T extends UseFormReturn<any, any>>({
eventType,
formMethods,
}: {
eventType: inferSSRProps<typeof getServerSideProps>["eventType"];
formMethods: T;
}) => {
const { t } = useLocale();
const proUpgradeRequired = !isSuccessRedirectAvailable(eventType);
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-48 sm:mb-0">
<label
htmlFor="successRedirectUrl"
className="flex h-full items-center text-sm font-medium text-neutral-700">
{t("redirect_success_booking")}
<span className="ml-1">{proUpgradeRequired && <Badge variant="default">PRO</Badge>}</span>
</label>
</div>
<div className="w-full">
<input
id="successRedirectUrl"
onClick={(e) => {
if (proUpgradeRequired) {
e.preventDefault();
setModalOpen(true);
}
}}
readOnly={proUpgradeRequired}
type="url"
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
placeholder={t("external_redirect_url")}
defaultValue={eventType.successRedirectUrl || ""}
{...formMethods.register("successRedirectUrl")}
/>
</div>
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
{t("redirect_url_upgrade_description")}
</UpgradeToProDialog>
</div>
</>
);
};
type AvailabilityOption = {
label: string;
value: number;
@ -166,13 +216,21 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
);
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not able to update this event`;
message = `${err.data.code}: You are not able to update this event`;
}
if (err.data?.code === "PARSE_ERROR") {
message = `${err.data.code}: ${err.message}`;
}
if (message) {
showToast(message, "error");
}
},
@ -432,6 +490,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
integration: string;
externalId: string;
};
successRedirectUrl: string;
}>({
defaultValues: {
locations: eventType.locations || [],
@ -1508,7 +1567,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
</div>
<SuccessRedirectEdit<typeof formMethods>
formMethods={formMethods}
eventType={eventType}></SuccessRedirectEdit>
{hasPaymentIntegration && (
<>
<hr className="border-neutral-200" />
@ -1831,6 +1892,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
id: true,
avatar: true,
email: true,
plan: true,
locale: true,
});
@ -1890,6 +1952,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
successRedirectUrl: true,
team: {
select: {
slug: true,

View File

@ -1,9 +1,7 @@
import { InformationCircleIcon } from "@heroicons/react/outline";
import { TrashIcon } from "@heroicons/react/solid";
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { Trans } from "next-i18next";
import { useRouter } from "next/router";
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
import Select from "react-select";
@ -12,7 +10,7 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
import showToast from "@calcom/lib/notification";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { TextField } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
@ -33,11 +31,13 @@ import Avatar from "@components/ui/Avatar";
import Badge from "@components/ui/Badge";
import ColorPicker from "@components/ui/colorpicker";
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
type Props = inferSSRProps<typeof getServerSideProps>;
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
const { t } = useLocale();
const [modelOpen, setModalOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
return (
<>
@ -61,39 +61,9 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
setModalOpen(true);
}}
/>
<Dialog open={modelOpen}>
<DialogContent>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
<InformationCircleIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" />
</div>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg leading-6 text-gray-900" id="modal-title">
{t("only_available_on_pro_plan")}
</h3>
</div>
</div>
<div className="flex flex-col space-y-3">
<p>{t("remove_cal_branding_description")}</p>
<p>
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
</p>
</div>
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button className="btn-wide table-cell text-center" onClick={() => setModalOpen(false)}>
{t("dismiss")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
{t("remove_cal_branding_description")}
</UpgradeToProDialog>
</>
);
}

View File

@ -8,10 +8,11 @@ import { createEvent } from "ics";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { sdkActionManager } from "@calcom/embed-core";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EventType, Team, User } from "@calcom/prisma/client";
import Button from "@calcom/ui/Button";
import { EmailInput } from "@calcom/ui/form/fields";
@ -19,6 +20,7 @@ import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import { getEventName } from "@lib/event";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
import prisma from "@lib/prisma";
import { isBrowserLocale24h } from "@lib/timeFormat";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -32,6 +34,111 @@ dayjs.extend(utc);
dayjs.extend(toArray);
dayjs.extend(timezone);
function redirectToExternalUrl(url: string) {
window.parent.location.href = url;
}
/**
* Redirects to external URL with query params from current URL.
* Query Params and Hash Fragment if present in external URL are kept intact.
*/
function RedirectionToast({ url }: { url: string }) {
const [timeRemaining, setTimeRemaining] = useState(10);
const [isToastVisible, setIsToastVisible] = useState(true);
const parsedSuccessUrl = new URL(document.URL);
const parsedExternalUrl = new URL(url);
/* @ts-ignore */ //https://stackoverflow.com/questions/49218765/typescript-and-iterator-type-iterableiteratort-is-not-an-array-type
for (let [name, value] of parsedExternalUrl.searchParams.entries()) {
parsedSuccessUrl.searchParams.set(name, value);
}
const urlWithSuccessParams =
parsedExternalUrl.origin +
parsedExternalUrl.pathname +
"?" +
parsedSuccessUrl.searchParams.toString() +
parsedExternalUrl.hash;
const { t } = useLocale();
const timerRef = useRef<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
if (timeRemaining > 0) {
setTimeRemaining((timeRemaining) => {
return timeRemaining - 1;
});
} else {
redirectToExternalUrl(urlWithSuccessParams);
window.clearInterval(timerRef.current as number);
}
}, 1000);
return () => {
window.clearInterval(timerRef.current as number);
};
}, [timeRemaining, urlWithSuccessParams]);
if (!isToastVisible) {
return null;
}
return (
<>
{/* z-index just higher than Success Message Box */}
<div className="fixed inset-x-0 top-4 z-[60] pb-2 sm:pb-5">
<div className="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div className="rounded-sm bg-red-600 bg-green-500 p-2 shadow-lg sm:p-3">
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<p className="ml-3 truncate font-medium text-white">
<span className="md:hidden">Redirecting to {url} ...</span>
<span className="hidden md:inline">
You are being redirected to {url} in {timeRemaining}{" "}
{timeRemaining === 1 ? "second" : "seconds"}.
</span>
</p>
</div>
<div className="order-3 mt-2 w-full flex-shrink-0 sm:order-2 sm:mt-0 sm:w-auto">
<button
onClick={() => {
redirectToExternalUrl(urlWithSuccessParams);
}}
className="flex items-center justify-center rounded-sm border border-transparent bg-white px-4 py-2 text-sm font-medium text-indigo-600 shadow-sm hover:bg-indigo-50">
{t("Continue")}
</button>
</div>
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-2">
<button
type="button"
onClick={() => {
setIsToastVisible(false);
window.clearInterval(timerRef.current as number);
}}
className="-mr-1 flex rounded-md p-2 hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-white">
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const router = useRouter();
@ -114,6 +221,9 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
/>
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto max-w-3xl py-24">
{isSuccessRedirectAvailable(eventType) && eventType.successRedirectUrl ? (
<RedirectionToast url={eventType.successRedirectUrl}></RedirectionToast>
) : null}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
@ -329,6 +439,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventName: true,
requiresConfirmation: true,
userId: true,
successRedirectUrl: true,
users: {
select: {
name: true,

View File

@ -2,12 +2,16 @@ import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import BookingPage from "@components/booking/pages/BookingPage";
import { getTranslation } from "@server/lib/i18n";
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
export default function TeamBookingPage(props: TeamBookingPageProps) {
@ -94,8 +98,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking = await getBooking();
}
const t = await getTranslation(context.locale ?? "en", "common");
return {
props: {
locationLabels: getLocationLabels(t),
profile: {
...eventTypeObject.team,
slug: "team/" + eventTypeObject.slug,

View File

@ -151,7 +151,7 @@ test.describe("pro user", () => {
await bookFirstEvent(page);
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="cancel"]').click();
await page.locator('[data-testid="cancel"]').first().click();
await page.waitForNavigation({
url: (url) => {
return url.pathname.startsWith("/cancel");

View File

@ -713,6 +713,9 @@
"time_format": "Time format",
"12_hour": "12 hour",
"24_hour": "24 hour",
"redirect_success_booking": "Redirect on booking ",
"external_redirect_url": "External Redirect URL - Starts with https://",
"redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.",
"duplicate": "Duplicate",
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
"request_reschedule_booking": "Request to reschedule your booking",

View File

@ -131,6 +131,7 @@ const loggedInViewerRouter = createProtectedRouter()
price: true,
currency: true,
position: true,
successRedirectUrl: true,
users: {
select: {
id: true,

View File

@ -18,6 +18,22 @@ function isPeriodType(keyInput: string): keyInput is PeriodType {
return Object.keys(PeriodType).includes(keyInput);
}
/**
* Ensures that it is a valid HTTP URL
* It automatically avoids
* - XSS attempts through javascript:alert('hi')
* - mailto: links
*/
function assertValidUrl(url: string | null | undefined) {
if (!url) {
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new TRPCError({ code: "PARSE_ERROR", message: "Invalid URL" });
}
}
function handlePeriodType(periodType: string | undefined): PeriodType | undefined {
if (typeof periodType !== "string") return undefined;
const passedPeriodType = periodType.toUpperCase();
@ -97,7 +113,6 @@ export const eventTypesRouter = createProtectedRouter()
input: createEventTypeInput,
async resolve({ ctx, input }) {
const { schedulingType, teamId, ...rest } = input;
const userId = ctx.user.id;
const data: Prisma.EventTypeCreateInput = {
@ -181,9 +196,9 @@ export const eventTypesRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
input;
assertValidUrl(input.successRedirectUrl);
const data: Prisma.EventTypeUpdateInput = rest;
data.locations = locations ?? undefined;
if (periodType) {
data.periodType = handlePeriodType(periodType);
}
@ -211,7 +226,7 @@ export const eventTypesRouter = createProtectedRouter()
if (users) {
data.users = {
set: [],
connect: users.map((userId) => ({ id: userId })),
connect: users.map((userId: number) => ({ id: userId })),
};
}

View File

@ -10,7 +10,6 @@ export const metadata = {
// If using static next public folder, can then be referenced from the base URL (/).
imageSrc: "/api/app-store/_example/icon.svg",
logo: "/api/app-store/_example/icon.svg",
label: "Example App",
publisher: "Cal.com",
rating: 5,
reviews: 69,

View File

@ -11,7 +11,6 @@ export const metadata = {
imageSrc: "/api/app-store/applecalendar/icon.svg",
variant: "calendar",
category: "calendar",
label: "Apple Calendar",
logo: "/api/app-store/applecalendar/icon.svg",
publisher: "Cal.com",
rating: 5,

View File

@ -11,7 +11,6 @@ export const metadata = {
imageSrc: "/api/app-store/caldavcalendar/icon.svg",
variant: "calendar",
category: "calendar",
label: "CalDav Calendar",
logo: "/api/app-store/caldavcalendar/icon.svg",
publisher: "Cal.com",
rating: 5,

View File

@ -1,5 +1,6 @@
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -17,12 +18,12 @@ export const metadata = {
rating: 4.3, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
label: "Cal Video",
slug: "dailyvideo",
title: "Cal Video",
isGlobal: true,
email: "help@cal.com",
locationType: "integrations:daily",
locationType: LocationType.Daily,
locationLabel: "Cal Video",
key: { apikey: process.env.DAILY_API_KEY },
} as App;

View File

@ -1,6 +1,7 @@
import { validJson } from "@calcom/lib/jsonUtils";
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -12,7 +13,6 @@ export const metadata = {
imageSrc: "/api/app-store/googlecalendar/icon.svg",
variant: "calendar",
category: "calendar",
label: "Google Calendar",
logo: "/api/app-store/googlecalendar/icon.svg",
publisher: "Cal.com",
rating: 5,
@ -22,6 +22,8 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
locationType: LocationType.GoogleMeet,
locationLabel: "Google Meet",
} as App;
export * as api from "./api";

View File

@ -1,6 +1,7 @@
import { validJson } from "@calcom/lib/jsonUtils";
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -13,7 +14,6 @@ export const metadata = {
title: "Google Meet",
imageSrc: "https://cdn.iconscout.com/icon/free/png-256/google-meet-2923654-2416657.png",
variant: "conferencing",
label: "Google Meet",
logo: "https://cdn.iconscout.com/icon/free/png-256/google-meet-2923654-2416657.png",
publisher: "Cal.com",
rating: 5,
@ -22,7 +22,8 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
locationType: "integrations:google:meet",
locationType: LocationType.GoogleMeet,
locationLabel: "Google Meet",
} as App;
// export * as api from "./api";

View File

@ -1,6 +1,7 @@
import { randomString } from "@calcom/lib/random";
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -17,13 +18,13 @@ export const metadata = {
rating: 0, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 0, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "web3",
label: "Huddle01 Video",
slug: "huddle01_video",
title: "Huddle01",
trending: true,
isGlobal: true,
email: "support@huddle01.com",
locationType: "integrations:huddle01",
locationType: LocationType.Huddle01,
locationLabel: "Huddle01 Video",
key: { apikey: randomString(12) },
} as App;

View File

@ -1,5 +1,6 @@
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -10,19 +11,19 @@ export const metadata = {
imageSrc: "/api/app-store/jitsivideo/icon.svg",
variant: "conferencing",
logo: "/api/app-store/jitsivideo/icon.svg",
locationType: "integrations:jitsi",
publisher: "Cal.com",
url: "https://jitsi.org/",
verified: true,
rating: 0, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 0, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
label: "Jitsi Video",
slug: "jitsi_video",
title: "Jitsi Meet",
trending: true,
isGlobal: true,
email: "help@cal.com",
locationType: LocationType.Jitsi,
locationLabel: "Jitsi Video",
} as App;
export * as lib from "./lib";

View File

@ -1,8 +1,11 @@
/** TODO: These should all come from each individual App Store package, and merge them here. */
export enum LocationType {
export enum DefaultLocationType {
InPerson = "inPerson",
Phone = "phone",
Link = "link",
}
/** If your App has a location option, add it here */
export enum AppStoreLocationType {
GoogleMeet = "integrations:google:meet",
Zoom = "integrations:zoom",
Daily = "integrations:daily",
@ -11,3 +14,6 @@ export enum LocationType {
Tandem = "integrations:tandem",
Teams = "integrations:office365_video",
}
export const LocationType = { ...DefaultLocationType, ...AppStoreLocationType };
export type LocationType = DefaultLocationType | AppStoreLocationType;

View File

@ -11,7 +11,6 @@ export const metadata = {
imageSrc: "/api/app-store/office365calendar/icon.svg",
variant: "calendar",
category: "calendar",
label: "Example App",
logo: "/api/app-store/office365calendar/icon.svg",
publisher: "Cal.com",
rating: 5,

View File

@ -1,5 +1,6 @@
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -16,12 +17,12 @@ export const metadata = {
rating: 4.3, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
label: "MS Teams",
slug: "msteams",
title: "MS Teams",
trending: true,
email: "help@cal.com",
locationType: "integrations:office365_video",
locationType: LocationType.Teams,
locationLabel: "MS Teams",
} as App;
export * as api from "./api";

View File

@ -17,7 +17,6 @@ export const metadata = {
trending: true,
reviews: 69,
imageSrc: "/api/app-store/stripepayment/icon.svg",
label: "Stripe",
publisher: "Cal.com",
title: "Stripe",
type: "stripe_payment",

View File

@ -1,5 +1,6 @@
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -10,7 +11,6 @@ export const metadata = {
title: "Tandem Video",
imageSrc: "/api/app-store/tandemvideo/icon.svg",
variant: "conferencing",
label: "",
slug: "tandem",
category: "video",
logo: "/api/app-store/tandemvideo/icon.svg",
@ -22,7 +22,8 @@ export const metadata = {
reviews: 0,
isGlobal: false,
email: "help@cal.com",
locationType: "integrations:tandem",
locationType: LocationType.Tandem,
locationLabel: "Tandem Video",
} as App;
export * as api from "./api";

View File

@ -1,10 +1,10 @@
import { Prisma } from "@prisma/client";
import { TFunction } from "next-i18next";
import { LocationType } from "@calcom/lib/location";
import type { App } from "@calcom/types/App";
import appStore from ".";
import { LocationType } from "./locations";
const ALL_APPS_MAP = Object.keys(appStore).reduce((store, key) => {
store[key] = appStore[key as keyof typeof appStore].metadata;
@ -31,14 +31,13 @@ function translateLocations(locations: OptionTypeBase[], t: TFunction) {
label: t(l.label),
}));
}
const defaultLocations: OptionTypeBase[] = [
{ value: LocationType.InPerson, label: "in_person_meeting" },
{ value: LocationType.Link, label: "link_meeting" },
{ value: LocationType.Phone, label: "phone_call" },
];
export function getLocationOptions(integrations: AppMeta, t: TFunction) {
const defaultLocations: OptionTypeBase[] = [
{ value: LocationType.InPerson, label: "in_person_meeting" },
{ value: LocationType.Link, label: "link_meeting" },
{ value: LocationType.Phone, label: "phone_call" },
];
integrations.forEach((app) => {
if (app.locationOption) {
defaultLocations.push(app.locationOption);
@ -70,8 +69,8 @@ function getApps(userCredentials: CredentialData[]) {
/** Check if app has location option AND add it if user has credentials for it */
if (credentials.length > 0 && appMeta?.locationType) {
locationOption = {
value: appMeta.locationType as LocationType,
label: appMeta.label,
value: appMeta.locationType,
label: appMeta.locationLabel || "No label set",
disabled: false,
};
}
@ -112,6 +111,20 @@ export function getLocationTypes(): string[] {
}, [] as string[]);
}
export function getLocationLabels(t: TFunction) {
const defaultLocationLabels = defaultLocations.reduce((locations, location) => {
locations[location.value] = t(location.label);
return locations;
}, {} as Record<LocationType, string>);
return ALL_APPS.reduce((locations, app) => {
if (typeof app.locationType === "string") {
locations[app.locationType] = t(app.locationLabel || "No label set");
}
return locations;
}, defaultLocationLabels);
}
export function getAppName(name: string) {
return ALL_APPS_MAP[name as keyof typeof ALL_APPS_MAP]?.name || "No App Name";
}

View File

@ -1,5 +1,6 @@
import type { App } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
export const metadata = {
@ -16,12 +17,12 @@ export const metadata = {
rating: 4.3, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
label: "Zoom Video",
slug: "zoom",
title: "Zoom Video",
trending: true,
email: "help@cal.com",
locationType: "integrations:zoom",
locationType: LocationType.Zoom,
locationLabel: "Zoom Video",
} as App;
export * as api from "./api";

View File

@ -4,7 +4,6 @@ import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import getApps from "@calcom/app-store/utils";
import { LocationType } from "@calcom/lib/location";
import prisma from "@calcom/prisma";
import type { AdditionInformation, CalendarEvent } from "@calcom/types/Calendar";
import type {
@ -16,6 +15,7 @@ import type {
import type { VideoCallData } from "@calcom/types/VideoApiAdapter";
import { createEvent, updateEvent } from "./CalendarManager";
import { LocationType } from "./location";
import { createMeeting, updateMeeting } from "./videoClient";
export type Event = AdditionInformation & VideoCallData;

View File

@ -0,0 +1 @@
export * from "@calcom/app-store/locations";

View File

@ -248,12 +248,16 @@ export default abstract class BaseCalendarService implements Calendar {
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
const timezoneComp = vcalendar.getFirstSubcomponent("vtimezone");
const tzid: string = timezoneComp?.getFirstPropertyValue("tzid") ?? "UTC";
const vtimezone = vcalendar.getFirstSubcomponent("vtimezone");
if (vtimezone) {
const zone = new ICAL.Timezone(vtimezone);
event.startDate = event.startDate.convertToZone(zone);
event.endDate = event.endDate.convertToZone(zone);
}
return {
start: dayjs.tz(event.startDate.toJSDate(), tzid).toISOString(),
end: dayjs.tz(event.endDate.toJSDate(), tzid).toISOString(),
start: dayjs(event.startDate.toJSDate()).toISOString(),
end: dayjs(event.endDate.toJSDate()).toISOString(),
};
});

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "successRedirectUrl" TEXT;

View File

@ -68,6 +68,7 @@ model EventType {
currency String @default("usd")
slotInterval Int?
metadata Json?
successRedirectUrl String?
@@unique([userId, slug])
}

View File

@ -1,6 +1,6 @@
import { z } from "zod";
import { LocationType } from "@calcom/lib/location";
import { LocationType } from "@calcom/core/location";
import { slugify } from "@calcom/lib/slugify";
export const eventTypeLocations = z.array(

View File

@ -1,4 +1,6 @@
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import type { LocationType } from "@calcom/app-store/locations";
/**
* This is the definition for an app store's app metadata.
@ -22,7 +24,6 @@ export interface App {
imageSrc: string;
/** TODO determine if we should use this instead of category */
variant: "calendar" | "payment" | "conferencing";
label: string;
/** The slug for the app store public page inside `/apps/[slug] */
slug: string;
/** The category to which this app belongs, currently we have `calendar`, `payment` or `video` */
@ -51,7 +52,9 @@ export interface App {
/** A contact email, mainly to ask for support */
email: string;
/** Add this value as a posible location option in event types */
locationType?: string;
locationType?: LocationType;
/** If the app adds a location, how should it be displayed? */
locationLabel?: string;
/** Needed API Keys (usually for global apps) */
key?: Prisma.JsonValue;
/** Needed API Keys (usually for global apps) */

View File

@ -73,7 +73,16 @@
"dependsOn": ["@calcom/prisma#db-deploy"]
},
"@calcom/website#build": {
"dependsOn": ["$WEBSITE_BASE_URL"],
"dependsOn": [
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT",
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"$NEXT_PUBLIC_WEBAPP_URL",
"$NEXT_PUBLIC_WEBSITE_URL",
"$STRIPE_WEBHOOK_SECRET"
],
"outputs": [".next/**"]
},
"build": {
@ -83,7 +92,7 @@
"db-deploy": {},
"db-seed": {},
"deploy": {
"dependsOn": []
"dependsOn": ["@calcom/web#build"]
},
"clean": {
"cache": false