Feature: Support redirecting to an external URL on successful booking (#2087)
This commit is contained in:
parent
b3f9921dd8
commit
d76b9b0d01
11 changed files with 279 additions and 45 deletions
54
apps/web/components/UpgradeToProDialog.tsx
Normal file
54
apps/web/components/UpgradeToProDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
14
apps/web/lib/isSuccessRedirectAvailable.tsx
Normal file
14
apps/web/lib/isSuccessRedirectAvailable.tsx
Normal 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;
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ import utc from "dayjs/plugin/utc";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useState } from "react";
|
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 { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import Select, { Props as SelectProps } from "react-select";
|
import Select, { Props as SelectProps } from "react-select";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
@ -42,6 +42,7 @@ import { QueryCell } from "@lib/QueryCell";
|
||||||
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
|
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
|
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { slugify } from "@lib/slugify";
|
import { slugify } from "@lib/slugify";
|
||||||
|
@ -52,8 +53,10 @@ import { ClientSuspense } from "@components/ClientSuspense";
|
||||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||||
|
import Badge from "@components/ui/Badge";
|
||||||
import InfoBadge from "@components/ui/InfoBadge";
|
import InfoBadge from "@components/ui/InfoBadge";
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
||||||
|
@ -86,6 +89,53 @@ type OptionTypeBase = {
|
||||||
disabled?: boolean;
|
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 = {
|
type AvailabilityOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
@ -166,13 +216,21 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
let message = "";
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
const message = `${err.statusCode}: ${err.message}`;
|
const message = `${err.statusCode}: ${err.message}`;
|
||||||
showToast(message, "error");
|
showToast(message, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err.data?.code === "UNAUTHORIZED") {
|
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");
|
showToast(message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -432,6 +490,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
integration: string;
|
integration: string;
|
||||||
externalId: string;
|
externalId: string;
|
||||||
};
|
};
|
||||||
|
successRedirectUrl: string;
|
||||||
}>({
|
}>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
locations: eventType.locations || [],
|
locations: eventType.locations || [],
|
||||||
|
@ -1508,7 +1567,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SuccessRedirectEdit<typeof formMethods>
|
||||||
|
formMethods={formMethods}
|
||||||
|
eventType={eventType}></SuccessRedirectEdit>
|
||||||
{hasPaymentIntegration && (
|
{hasPaymentIntegration && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-neutral-200" />
|
<hr className="border-neutral-200" />
|
||||||
|
@ -1831,6 +1892,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
id: true,
|
id: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
plan: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1890,6 +1952,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
beforeEventBuffer: true,
|
beforeEventBuffer: true,
|
||||||
afterEventBuffer: true,
|
afterEventBuffer: true,
|
||||||
slotInterval: true,
|
slotInterval: true,
|
||||||
|
successRedirectUrl: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
slug: true,
|
slug: true,
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
|
||||||
import { TrashIcon } from "@heroicons/react/solid";
|
import { TrashIcon } from "@heroicons/react/solid";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { Trans } from "next-i18next";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
|
@ -12,7 +10,7 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
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 { TextField } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
@ -33,11 +31,13 @@ import Avatar from "@components/ui/Avatar";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
import ColorPicker from "@components/ui/colorpicker";
|
import ColorPicker from "@components/ui/colorpicker";
|
||||||
|
|
||||||
|
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
|
||||||
|
|
||||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
|
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [modelOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -61,39 +61,9 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Dialog open={modelOpen}>
|
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
|
||||||
<DialogContent>
|
{t("remove_cal_branding_description")}
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
|
</UpgradeToProDialog>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,11 @@ import { createEvent } from "ics";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
import { sdkActionManager } from "@calcom/embed-core";
|
import { sdkActionManager } from "@calcom/embed-core";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { EventType, Team, User } from "@calcom/prisma/client";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { EmailInput } from "@calcom/ui/form/fields";
|
import { EmailInput } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
|
@ -19,6 +20,7 @@ import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getEventName } from "@lib/event";
|
import { getEventName } from "@lib/event";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
|
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { isBrowserLocale24h } from "@lib/timeFormat";
|
import { isBrowserLocale24h } from "@lib/timeFormat";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
@ -32,6 +34,111 @@ dayjs.extend(utc);
|
||||||
dayjs.extend(toArray);
|
dayjs.extend(toArray);
|
||||||
dayjs.extend(timezone);
|
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>) {
|
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -114,6 +221,9 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
/>
|
/>
|
||||||
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
||||||
<main className="mx-auto max-w-3xl py-24">
|
<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="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="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">
|
<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,
|
eventName: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
successRedirectUrl: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
|
|
@ -151,7 +151,7 @@ test.describe("pro user", () => {
|
||||||
await bookFirstEvent(page);
|
await bookFirstEvent(page);
|
||||||
|
|
||||||
await page.goto("/bookings/upcoming");
|
await page.goto("/bookings/upcoming");
|
||||||
await page.locator('[data-testid="cancel"]').click();
|
await page.locator('[data-testid="cancel"]').first().click();
|
||||||
await page.waitForNavigation({
|
await page.waitForNavigation({
|
||||||
url: (url) => {
|
url: (url) => {
|
||||||
return url.pathname.startsWith("/cancel");
|
return url.pathname.startsWith("/cancel");
|
||||||
|
|
|
@ -710,6 +710,9 @@
|
||||||
"time_format": "Time format",
|
"time_format": "Time format",
|
||||||
"12_hour": "12 hour",
|
"12_hour": "12 hour",
|
||||||
"24_hour": "24 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",
|
"duplicate": "Duplicate",
|
||||||
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page."
|
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page."
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,6 +131,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
position: true,
|
position: true,
|
||||||
|
successRedirectUrl: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
@ -18,6 +18,22 @@ function isPeriodType(keyInput: string): keyInput is PeriodType {
|
||||||
return Object.keys(PeriodType).includes(keyInput);
|
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 {
|
function handlePeriodType(periodType: string | undefined): PeriodType | undefined {
|
||||||
if (typeof periodType !== "string") return undefined;
|
if (typeof periodType !== "string") return undefined;
|
||||||
const passedPeriodType = periodType.toUpperCase();
|
const passedPeriodType = periodType.toUpperCase();
|
||||||
|
@ -97,7 +113,6 @@ export const eventTypesRouter = createProtectedRouter()
|
||||||
input: createEventTypeInput,
|
input: createEventTypeInput,
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
const { schedulingType, teamId, ...rest } = input;
|
const { schedulingType, teamId, ...rest } = input;
|
||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
const data: Prisma.EventTypeCreateInput = {
|
const data: Prisma.EventTypeCreateInput = {
|
||||||
|
@ -181,9 +196,9 @@ export const eventTypesRouter = createProtectedRouter()
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
|
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
|
||||||
input;
|
input;
|
||||||
|
assertValidUrl(input.successRedirectUrl);
|
||||||
const data: Prisma.EventTypeUpdateInput = rest;
|
const data: Prisma.EventTypeUpdateInput = rest;
|
||||||
data.locations = locations ?? undefined;
|
data.locations = locations ?? undefined;
|
||||||
|
|
||||||
if (periodType) {
|
if (periodType) {
|
||||||
data.periodType = handlePeriodType(periodType);
|
data.periodType = handlePeriodType(periodType);
|
||||||
}
|
}
|
||||||
|
@ -211,7 +226,7 @@ export const eventTypesRouter = createProtectedRouter()
|
||||||
if (users) {
|
if (users) {
|
||||||
data.users = {
|
data.users = {
|
||||||
set: [],
|
set: [],
|
||||||
connect: users.map((userId) => ({ id: userId })),
|
connect: users.map((userId: number) => ({ id: userId })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventType" ADD COLUMN "successRedirectUrl" TEXT;
|
|
@ -68,6 +68,7 @@ model EventType {
|
||||||
currency String @default("usd")
|
currency String @default("usd")
|
||||||
slotInterval Int?
|
slotInterval Int?
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
successRedirectUrl String?
|
||||||
|
|
||||||
@@unique([userId, slug])
|
@@unique([userId, slug])
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue