2022-07-18 12:52:50 +00:00
import Head from "next/head" ;
2023-08-02 09:35:48 +00:00
import { useRouter , useSearchParams } from "next/navigation" ;
2023-02-16 22:39:57 +00:00
import type { FormEvent } from "react" ;
import { useEffect , useRef , useState } from "react" ;
2022-07-14 12:40:53 +00:00
import { Toaster } from "react-hot-toast" ;
import { v4 as uuidv4 } from "uuid" ;
2022-10-19 21:25:03 +00:00
import { sdkActionManager , useIsEmbed } from "@calcom/embed-core/embed-iframe" ;
2023-07-25 09:28:57 +00:00
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains" ;
2022-07-28 10:50:25 +00:00
import classNames from "@calcom/lib/classNames" ;
2023-04-05 18:14:46 +00:00
import useGetBrandingColours from "@calcom/lib/getBrandColours" ;
2022-09-08 17:35:32 +00:00
import { useLocale } from "@calcom/lib/hooks/useLocale" ;
2022-07-28 19:58:26 +00:00
import useTheme from "@calcom/lib/hooks/useTheme" ;
2022-07-22 17:27:06 +00:00
import { trpc } from "@calcom/trpc/react" ;
2023-02-16 22:39:57 +00:00
import type { AppGetServerSidePropsContext , AppPrisma } from "@calcom/types/AppGetServerSideProps" ;
import type { inferSSRProps } from "@calcom/types/inferSSRProps" ;
2023-04-05 18:14:46 +00:00
import { Button , showToast , useCalcomTheme } from "@calcom/ui" ;
2022-07-14 12:40:53 +00:00
2022-11-10 12:58:07 +00:00
import FormInputFields from "../../components/FormInputFields" ;
2023-05-17 08:47:48 +00:00
import getFieldIdentifier from "../../lib/getFieldIdentifier" ;
2022-08-13 11:04:57 +00:00
import { getSerializableForm } from "../../lib/getSerializableForm" ;
import { processRoute } from "../../lib/processRoute" ;
2023-08-23 15:12:51 +00:00
import transformResponse from "../../lib/transformResponse" ;
2023-02-16 22:39:57 +00:00
import type { Response , Route } from "../../types/types" ;
2022-07-14 12:40:53 +00:00
2023-05-17 08:47:48 +00:00
type Props = inferSSRProps < typeof getServerSideProps > ;
2023-04-05 18:14:46 +00:00
const useBrandColors = ( {
brandColor ,
darkBrandColor ,
} : {
brandColor? : string | null ;
darkBrandColor? : string | null ;
} ) = > {
const brandTheme = useGetBrandingColours ( {
lightVal : brandColor ,
darkVal : darkBrandColor ,
} ) ;
useCalcomTheme ( brandTheme ) ;
} ;
2023-05-17 08:47:48 +00:00
function RoutingForm ( { form , profile , . . . restProps } : Props ) {
2022-07-14 12:40:53 +00:00
const [ customPageMessage , setCustomPageMessage ] = useState < Route [ " action " ] [ " value " ] > ( "" ) ;
const formFillerIdRef = useRef ( uuidv4 ( ) ) ;
2022-10-19 21:25:03 +00:00
const isEmbed = useIsEmbed ( restProps . isEmbed ) ;
2022-07-28 10:50:25 +00:00
useTheme ( profile . theme ) ;
2023-04-05 18:14:46 +00:00
useBrandColors ( {
brandColor : profile.brandColor ,
darkBrandColor : profile.darkBrandColor ,
} ) ;
2023-05-17 08:47:48 +00:00
const [ response , setResponse ] = usePrefilledResponse ( form ) ;
2022-07-14 12:40:53 +00:00
// 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 ;
2023-05-17 08:47:48 +00:00
const decidedActionWithFormResponseRef = useRef < { action : Route [ "action" ] ; response : Response } > ( ) ;
2022-07-14 12:40:53 +00:00
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 ,
} ) ;
2023-05-17 08:47:48 +00:00
decidedActionWithFormResponseRef . current = {
action : decidedAction ,
response ,
} ;
2022-07-14 12:40:53 +00:00
} ;
2022-10-19 21:25:03 +00:00
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 ] ) ;
2022-11-10 23:40:01 +00:00
const responseMutation = trpc . viewer . appRoutingForms . public . response . useMutation ( {
2023-06-05 09:50:34 +00:00
onSuccess : async ( ) = > {
2023-05-17 08:47:48 +00:00
const decidedActionWithFormResponse = decidedActionWithFormResponseRef . current ;
if ( ! decidedActionWithFormResponse ) {
2022-07-14 12:40:53 +00:00
return ;
}
2023-05-17 08:47:48 +00:00
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 ;
2022-07-14 12:40:53 +00:00
//TODO: Maybe take action after successful mutation
if ( decidedAction . type === "customPageMessage" ) {
setCustomPageMessage ( decidedAction . value ) ;
} else if ( decidedAction . type === "eventTypeRedirectUrl" ) {
2023-06-05 09:50:34 +00:00
await router . push ( ` / ${ decidedAction . value } ? ${ allURLSearchParams } ` ) ;
2022-07-14 12:40:53 +00:00
} else if ( decidedAction . type === "externalRedirectUrl" ) {
2023-05-17 08:47:48 +00:00
window . parent . location . href = ` ${ decidedAction . value } ? ${ allURLSearchParams } ` ;
2022-07-14 12:40:53 +00:00
}
2023-05-17 08:47:48 +00:00
// We don't want to show this message as it doesn't look good in Embed.
2022-07-28 10:50:25 +00:00
// showToast("Form submitted successfully! Redirecting now ...", "success");
2022-07-14 12:40:53 +00:00
} ,
onError : ( e ) = > {
if ( e ? . message ) {
return void showToast ( e ? . message , "error" ) ;
}
if ( e ? . data ? . code === "CONFLICT" ) {
return void showToast ( "Form already submitted" , "error" ) ;
}
2023-05-17 08:47:48 +00:00
// We don't want to show this error as it doesn't look good in Embed.
2022-07-28 10:50:25 +00:00
// showToast("Something went wrong", "error");
2022-07-14 12:40:53 +00:00
} ,
} ) ;
const handleOnSubmit = ( e : FormEvent < HTMLFormElement > ) = > {
e . preventDefault ( ) ;
onSubmit ( response ) ;
} ;
2022-09-08 17:35:32 +00:00
const { t } = useLocale ( ) ;
2022-07-28 10:50:25 +00:00
return (
< div >
< div >
{ ! customPageMessage ? (
< >
< Head >
2022-09-22 17:23:43 +00:00
< title > { ` ${ form . name } | Cal.com Forms ` } < / title >
2022-07-28 10:50:25 +00:00
< / Head >
< div className = { classNames ( "mx-auto my-0 max-w-3xl" , isEmbed ? "" : "md:my-24" ) } >
< div className = "w-full max-w-4xl ltr:mr-2 rtl:ml-2" >
2023-04-13 18:26:31 +00:00
< div className = "main border-booker md:border-booker-width dark:bg-muted bg-default mx-0 rounded-md p-4 py-6 sm:-mx-4 sm:px-8 " >
2022-07-28 10:50:25 +00:00
< Toaster position = "bottom-right" / >
< form onSubmit = { handleOnSubmit } >
< div className = "mb-8" >
2023-04-05 18:14:46 +00:00
< h1 className = "font-cal text-emphasis mb-1 text-xl font-bold tracking-wide" >
2022-07-28 10:50:25 +00:00
{ form . name }
< / h1 >
{ form . description ? (
2023-04-05 18:14:46 +00:00
< p className = "min-h-10 text-subtle text-sm ltr:mr-4 rtl:ml-4" > { form . description } < / p >
2022-07-28 10:50:25 +00:00
) : null }
2022-07-18 12:52:50 +00:00
< / div >
2022-11-10 12:58:07 +00:00
< FormInputFields form = { form } response = { response } setResponse = { setResponse } / >
2022-07-28 10:50:25 +00:00
< div className = "mt-4 flex justify-end space-x-2 rtl:space-x-reverse" >
2022-10-10 18:50:43 +00:00
< Button
className = "dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast dark:border-transparent"
loading = { responseMutation . isLoading }
type = "submit"
color = "primary" >
2022-09-08 17:35:32 +00:00
{ t ( "submit" ) }
2022-07-28 10:50:25 +00:00
< / Button >
2022-07-18 12:52:50 +00:00
< / div >
2022-07-28 10:50:25 +00:00
< / form >
< / div >
2022-07-18 12:52:50 +00:00
< / div >
2022-07-28 10:50:25 +00:00
< / div >
< / >
) : (
< div className = "mx-auto my-0 max-w-3xl md:my-24" >
< div className = "w-full max-w-4xl ltr:mr-2 rtl:ml-2" >
2023-04-12 08:42:41 +00:00
< div className = "main dark:bg-darkgray-100 sm:border-subtle bg-default -mx-4 rounded-md border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8" >
2023-04-05 18:14:46 +00:00
< div className = "text-emphasis" > { customPageMessage } < / div >
2022-07-28 10:50:25 +00:00
< / div >
< / div >
2022-07-18 12:52:50 +00:00
< / div >
2022-07-28 10:50:25 +00:00
) }
2022-07-14 12:40:53 +00:00
< / div >
< / div >
) ;
}
2023-05-17 08:47:48 +00:00
function getUrlSearchParamsToForward ( response : Response , fields : NonNullable < Props [ " form " ] [ " fields " ] > ) {
type Params = Record < string , string | string [ ] > ;
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 ;
}
2023-06-01 20:29:13 +00:00
const valueAsStringOrStringArray =
typeof fieldResponse . value === "number" ? String ( fieldResponse . value ) : fieldResponse . value ;
2023-05-17 08:47:48 +00:00
paramsFromResponse [ getFieldIdentifier ( foundField ) as keyof typeof paramsFromResponse ] =
2023-06-01 20:29:13 +00:00
valueAsStringOrStringArray ;
2023-05-17 08:47:48 +00:00
} ) ;
// 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 ;
}
2022-10-19 21:25:03 +00:00
export default function RoutingLink ( props : inferSSRProps < typeof getServerSideProps > ) {
return < RoutingForm { ...props } / > ;
2022-07-14 12:40:53 +00:00
}
2023-04-17 12:16:54 +00:00
RoutingLink . isBookingPage = true ;
2022-07-26 08:27:57 +00:00
2022-07-14 12:40:53 +00:00
export const getServerSideProps = async function getServerSideProps (
context : AppGetServerSidePropsContext ,
prisma : AppPrisma
) {
const { params } = context ;
if ( ! params ) {
return {
notFound : true ,
} ;
}
const formId = params . appPages [ 0 ] ;
2022-10-19 21:25:03 +00:00
if ( ! formId || params . appPages . length > 2 ) {
2022-07-14 12:40:53 +00:00
return {
notFound : true ,
} ;
}
2023-07-25 09:28:57 +00:00
const { currentOrgDomain , isValidOrgDomain } = orgDomainConfig ( context . req . headers . host ? ? "" ) ;
2022-10-19 21:25:03 +00:00
const isEmbed = params . appPages [ 1 ] === "embed" ;
2023-07-25 09:28:57 +00:00
const form = await prisma . app_RoutingForms_Form . findFirst ( {
2022-07-14 12:40:53 +00:00
where : {
id : formId ,
2023-07-25 09:28:57 +00:00
user : {
organization : isValidOrgDomain
? {
slug : currentOrgDomain ,
}
: null ,
} ,
2022-07-14 12:40:53 +00:00
} ,
2022-07-28 10:50:25 +00:00
include : {
user : {
select : {
2023-05-16 19:41:47 +00:00
username : true ,
2022-07-28 10:50:25 +00:00
theme : true ,
brandColor : true ,
darkBrandColor : true ,
} ,
} ,
} ,
2022-07-14 12:40:53 +00:00
} ) ;
if ( ! form || form . disabled ) {
return {
notFound : true ,
} ;
}
return {
props : {
2022-10-19 21:25:03 +00:00
isEmbed ,
2023-05-16 19:41:47 +00:00
themeBasis : form.user.username ,
2022-07-28 10:50:25 +00:00
profile : {
theme : form.user.theme ,
brandColor : form.user.brandColor ,
darkBrandColor : form.user.darkBrandColor ,
} ,
2023-06-15 08:58:07 +00:00
form : await getSerializableForm ( { form } ) ,
2022-07-14 12:40:53 +00:00
} ,
} ;
} ;
2023-05-17 08:47:48 +00:00
const usePrefilledResponse = ( form : Props [ "form" ] ) = > {
2023-08-02 09:35:48 +00:00
const searchParams = useSearchParams ( ) ;
2023-05-17 08:47:48 +00:00
const prefillResponse : Response = { } ;
// Prefill the form from query params
form . fields ? . forEach ( ( field ) = > {
2023-08-02 09:35:48 +00:00
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 ] ;
2023-08-23 15:12:51 +00:00
2023-05-17 08:47:48 +00:00
prefillResponse [ field . id ] = {
2023-08-23 15:12:51 +00:00
value : transformResponse ( { field , value } ) ,
2023-05-17 08:47:48 +00:00
label : field.label ,
} ;
} ) ;
const [ response , setResponse ] = useState < Response > ( prefillResponse ) ;
return [ response , setResponse ] as const ;
} ;