diff --git a/apps/web/components/UpgradeToProDialog.tsx b/apps/web/components/UpgradeToProDialog.tsx new file mode 100644 index 00000000..066cfc70 --- /dev/null +++ b/apps/web/components/UpgradeToProDialog.tsx @@ -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> + ); +} diff --git a/apps/web/lib/isSuccessRedirectAvailable.tsx b/apps/web/lib/isSuccessRedirectAvailable.tsx new file mode 100644 index 00000000..cca37452 --- /dev/null +++ b/apps/web/lib/isSuccessRedirectAvailable.tsx @@ -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; +} diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index 0ac45114..7cb5b3c3 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -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, diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx index e69abd88..9b90cb4b 100644 --- a/apps/web/pages/settings/profile.tsx +++ b/apps/web/pages/settings/profile.tsx @@ -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> </> ); } diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index bdd9d4d8..0afba68a 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -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, diff --git a/apps/web/playwright/booking-pages.test.ts b/apps/web/playwright/booking-pages.test.ts index d60cce05..4fb038e4 100644 --- a/apps/web/playwright/booking-pages.test.ts +++ b/apps/web/playwright/booking-pages.test.ts @@ -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"); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index bc212f9d..c0dac6fc 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -710,6 +710,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." } diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 58b97ff1..72c71991 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -131,6 +131,7 @@ const loggedInViewerRouter = createProtectedRouter() price: true, currency: true, position: true, + successRedirectUrl: true, users: { select: { id: true, diff --git a/apps/web/server/routers/viewer/eventTypes.tsx b/apps/web/server/routers/viewer/eventTypes.tsx index b7923198..2c84949d 100644 --- a/apps/web/server/routers/viewer/eventTypes.tsx +++ b/apps/web/server/routers/viewer/eventTypes.tsx @@ -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 })), }; } diff --git a/packages/prisma/migrations/20220404132522_redirect_url/migration.sql b/packages/prisma/migrations/20220404132522_redirect_url/migration.sql new file mode 100644 index 00000000..e6ebdedf --- /dev/null +++ b/packages/prisma/migrations/20220404132522_redirect_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "successRedirectUrl" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 6bcf9e30..217f9b7e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -68,6 +68,7 @@ model EventType { currency String @default("usd") slotInterval Int? metadata Json? + successRedirectUrl String? @@unique([userId, slug]) }