import Head from "next/head"; import { useRouter, useSearchParams } from "next/navigation"; import type { FormEvent } from "react"; import { useEffect, useRef, useState } from "react"; import { Toaster } from "react-hot-toast"; import { v4 as uuidv4 } from "uuid"; import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import classNames from "@calcom/lib/classNames"; import useGetBrandingColours from "@calcom/lib/getBrandColours"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { trpc } from "@calcom/trpc/react"; import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { Button, showToast, useCalcomTheme } from "@calcom/ui"; import FormInputFields from "../../components/FormInputFields"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; import { getSerializableForm } from "../../lib/getSerializableForm"; import { processRoute } from "../../lib/processRoute"; import type { Response, Route } from "../../types/types"; type Props = inferSSRProps; const useBrandColors = ({ brandColor, darkBrandColor, }: { brandColor?: string | null; darkBrandColor?: string | null; }) => { const brandTheme = useGetBrandingColours({ lightVal: brandColor, darkVal: darkBrandColor, }); useCalcomTheme(brandTheme); }; function RoutingForm({ form, profile, ...restProps }: Props) { const [customPageMessage, setCustomPageMessage] = useState(""); const formFillerIdRef = useRef(uuidv4()); const isEmbed = useIsEmbed(restProps.isEmbed); useTheme(profile.theme); useBrandColors({ brandColor: profile.brandColor, darkBrandColor: profile.darkBrandColor, }); const [response, setResponse] = usePrefilledResponse(form); // TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews // But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that. // - like a network error // - or he abandoned booking flow in between const formFillerId = formFillerIdRef.current; const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>(); const router = useRouter(); const onSubmit = (response: Response) => { const decidedAction = processRoute({ form, response }); if (!decidedAction) { // FIXME: Make sure that when a form is created, there is always a fallback route and then remove this. alert("Define atleast 1 route"); return; } responseMutation.mutate({ formId: form.id, formFillerId, response: response, }); decidedActionWithFormResponseRef.current = { action: decidedAction, response, }; }; useEffect(() => { // Custom Page doesn't actually change Route, so fake it so that embed can adjust the scroll to make the content visible sdkActionManager?.fire("__routeChanged", {}); }, [customPageMessage]); const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({ onSuccess: async () => { const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current; if (!decidedActionWithFormResponse) { return; } const fields = form.fields; if (!fields) { throw new Error("Routing Form fields must exist here"); } const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields); const decidedAction = decidedActionWithFormResponse.action; //TODO: Maybe take action after successful mutation if (decidedAction.type === "customPageMessage") { setCustomPageMessage(decidedAction.value); } else if (decidedAction.type === "eventTypeRedirectUrl") { await router.push(`/${decidedAction.value}?${allURLSearchParams}`); } else if (decidedAction.type === "externalRedirectUrl") { window.parent.location.href = `${decidedAction.value}?${allURLSearchParams}`; } // We don't want to show this message as it doesn't look good in Embed. // showToast("Form submitted successfully! Redirecting now ...", "success"); }, onError: (e) => { if (e?.message) { return void showToast(e?.message, "error"); } if (e?.data?.code === "CONFLICT") { return void showToast("Form already submitted", "error"); } // We don't want to show this error as it doesn't look good in Embed. // showToast("Something went wrong", "error"); }, }); const handleOnSubmit = (e: FormEvent) => { e.preventDefault(); onSubmit(response); }; const { t } = useLocale(); return (
{!customPageMessage ? ( <> {`${form.name} | Cal.com Forms`}

{form.name}

{form.description ? (

{form.description}

) : null}
) : (
{customPageMessage}
)}
); } function getUrlSearchParamsToForward(response: Response, fields: NonNullable) { type Params = Record; const paramsFromResponse: Params = {}; const paramsFromCurrentUrl: Params = {}; // Build query params from response Object.entries(response).forEach(([key, fieldResponse]) => { const foundField = fields.find((f) => f.id === key); if (!foundField) { // If for some reason, the field isn't there, let's just return; } const valueAsStringOrStringArray = typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value; paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] = valueAsStringOrStringArray; }); // Build query params from current URL. It excludes route params // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore for (const [name, value] of new URLSearchParams(window.location.search).entries()) { const target = paramsFromCurrentUrl[name]; if (target instanceof Array) { target.push(value); } else { paramsFromCurrentUrl[name] = [value]; } } const allQueryParams: Params = { ...paramsFromCurrentUrl, // In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value. ...paramsFromResponse, }; const allQueryURLSearchParams = new URLSearchParams(); // Make serializable URLSearchParams instance Object.entries(allQueryParams).forEach(([param, value]) => { const valueArray = value instanceof Array ? value : [value]; valueArray.forEach((v) => { allQueryURLSearchParams.append(param, v); }); }); return allQueryURLSearchParams; } export default function RoutingLink(props: inferSSRProps) { return ; } RoutingLink.isBookingPage = true; export const getServerSideProps = async function getServerSideProps( context: AppGetServerSidePropsContext, prisma: AppPrisma ) { const { params } = context; if (!params) { return { notFound: true, }; } const formId = params.appPages[0]; if (!formId || params.appPages.length > 2) { return { notFound: true, }; } const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const isEmbed = params.appPages[1] === "embed"; const form = await prisma.app_RoutingForms_Form.findFirst({ where: { id: formId, user: { organization: isValidOrgDomain ? { slug: currentOrgDomain, } : null, }, }, include: { user: { select: { username: true, theme: true, brandColor: true, darkBrandColor: true, }, }, }, }); if (!form || form.disabled) { return { notFound: true, }; } return { props: { isEmbed, themeBasis: form.user.username, profile: { theme: form.user.theme, brandColor: form.user.brandColor, darkBrandColor: form.user.darkBrandColor, }, form: await getSerializableForm({ form }), }, }; }; const usePrefilledResponse = (form: Props["form"]) => { const searchParams = useSearchParams(); const prefillResponse: Response = {}; // Prefill the form from query params form.fields?.forEach((field) => { const valuesFromQuery = searchParams?.getAll(getFieldIdentifier(field)).filter(Boolean); // We only want to keep arrays if the field is a multi-select const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0]; prefillResponse[field.id] = { value: value || "", label: field.label, }; }); const [response, setResponse] = useState(prefillResponse); return [response, setResponse] as const; };