import { type TFunction } from "i18next"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import type { Dispatch, SetStateAction } from "react"; import { useEffect, useRef, useState, useCallback } from "react"; import type { UseFormReturn } from "react-hook-form"; import { Controller, useWatch } from "react-hook-form"; import "react-phone-number-input/style.css"; import type { RetellAgentWithDetails } from "@calcom/features/calAIPhone/providers/retellAI"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import PhoneInput from "@calcom/features/components/phone-input"; import { isSMSAction, isWhatsappAction, getTemplateBodyForAction, shouldScheduleEmailReminder, isSMSOrWhatsappAction, isCalAIAction, isFormTrigger, hasCalAIAction, } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import { DYNAMIC_TEXT_VARIABLES } from "@calcom/features/ee/workflows/lib/constants"; import { getWorkflowTemplateOptions, getWorkflowTriggerOptions, } from "@calcom/features/ee/workflows/lib/getOptions"; import emailRatingTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailRatingTemplate"; import emailReminderTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailReminderTemplate"; import type { FormValues } from "@calcom/features/ee/workflows/lib/types"; import "@calcom/features/ee/workflows/style/styles.css"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { HttpError } from "@calcom/lib/http-error"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import type { WorkflowStep } from "@calcom/prisma/client"; import { MembershipRole, PhoneNumberSubscriptionStatus, TimeUnit, WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents, } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Badge, InfoBadge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; import { AddVariablesDropdown } from "@calcom/ui/components/editor"; import { Editor } from "@calcom/ui/components/editor"; import { CheckboxField } from "@calcom/ui/components/form"; import { EmailField } from "@calcom/ui/components/form"; import { TextArea } from "@calcom/ui/components/form"; import { Input } from "@calcom/ui/components/form"; import { Label } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; import { Select } from "@calcom/ui/components/form"; import { MultiSelectCheckbox } from "@calcom/ui/components/form"; import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { SkeletonText } from "@calcom/ui/components/skeleton"; import { showToast } from "@calcom/ui/components/toast"; import { useHasPaidPlan, useHasActiveTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan"; import { TestPhoneCallDialog } from "./TestPhoneCallDialog"; import { TimeTimeUnitInput } from "./TimeTimeUnitInput"; import { WebCallDialog } from "./WebCallDialog"; import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet"; type User = RouterOutputs["viewer"]["me"]["get"]; type WorkflowStepProps = { step?: WorkflowStep; form: UseFormReturn; user: User; reload?: boolean; setReload?: Dispatch>; teamId?: number; readOnly: boolean; // Props for trigger section selectedOptions?: Option[]; setSelectedOptions?: Dispatch>; isOrganization?: boolean; allOptions?: Option[]; eventTypeOptions?: Option[]; onSaveWorkflow?: () => Promise; setIsDeleteStepDialogOpen?: Dispatch>; isDeleteStepDialogOpen?: boolean; agentData?: RetellAgentWithDetails; isAgentLoading?: boolean; actionOptions: { label: string; value: WorkflowActions; needsCredits: boolean; creditsTeamId?: number; isOrganization: boolean; isCalAi: boolean; needsTeamsUpgrade?: boolean; }[]; updateTemplate: boolean; setUpdateTemplate: Dispatch>; inboundAgentData?: RetellAgentWithDetails; isInboundAgentLoading?: boolean; }; const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => { const triggerMap: Partial> = { [WorkflowTriggerEvents.AFTER_EVENT]: "how_long_after", [WorkflowTriggerEvents.BEFORE_EVENT]: "how_long_before", [WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: "how_long_after_hosts_no_show", [WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: "how_long_after_guests_no_show", [WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT]: "how_long_after_form_submitted_no_event", }; return triggerMap[trigger] ? t(triggerMap[trigger]) : null; }; const CalAIAgentDataSkeleton = () => { return (
); }; const getActivePhoneNumbers = ( phoneNumbers?: Array<{ subscriptionStatus?: string; phoneNumber: string }> ) => { return ( phoneNumbers?.filter( (phone) => phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus ) || [] ); }; export default function WorkflowStepContainer(props: WorkflowStepProps) { const { t, i18n } = useLocale(); const utils = trpc.useUtils(); const params = useParams(); const router = useRouter(); const searchParams = useSearchParams(); const { step, form, reload, setReload, teamId, selectedOptions, setSelectedOptions, isOrganization, allOptions, agentData, isAgentLoading, inboundAgentData, isInboundAgentLoading: _isInboundAgentLoading, isDeleteStepDialogOpen, setIsDeleteStepDialogOpen, onSaveWorkflow, actionOptions, updateTemplate, setUpdateTemplate, } = props; const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( { teamId }, { enabled: !!teamId } ); const isMobile = useMediaQuery("(max-width: 569px)"); const { data: userTeams } = trpc.viewer.teams.list.useQuery({}, { enabled: !teamId }); const [agentConfigurationSheet, setAgentConfigurationSheet] = useState<{ open: boolean; activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls"; }>({ open: false, activeTab: "outgoingCalls", }); const creditsTeamId = userTeams?.find( (team) => team.accepted && (team.role === MembershipRole.ADMIN || team.role === MembershipRole.OWNER) )?.id; const { hasPaidPlan } = useHasPaidPlan(); const { hasActiveTeamPlan, isTrial } = useHasActiveTeamPlan(); const planState = { hasPaidPlan, hasActiveTeamPlan, isTrial }; const { data: _verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ teamId }); const timeFormat = getTimeFormatStringFromUserTimeFormat(props.user.timeFormat); const createAgentMutation = trpc.viewer.aiVoiceAgent.create.useMutation({ onSuccess: async (data) => { showToast(t("agent_created_successfully"), "success"); const url = new URL(window.location.href); url.searchParams.delete("autoCreateAgent"); url.searchParams.delete("templateWorkflowId"); router.replace(url.pathname + url.search); if (step) { const stepIndex = step.stepNumber - 1; form.setValue(`steps.${stepIndex}.agentId`, data.id); await utils.viewer.aiVoiceAgent.get.invalidate({ id: data.id }); } setAgentConfigurationSheet((prev) => ({ ...prev, open: true })); }, onError: (error: { message: string }) => { showToast(error.message, "error"); }, }); const stepAgentId = step?.agentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.agentId`) || null; const stepInboundAgentId = step?.inboundAgentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.inboundAgentId`) || null; const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({ onSuccess: async () => { showToast(t("agent_updated_successfully"), "success"); const currentAgentId = stepAgentId || stepInboundAgentId; if (currentAgentId) { utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId }); } }, onError: (error: { message: string }) => { showToast(error.message, "error"); }, }); const unsubscribePhoneNumberMutation = trpc.viewer.phoneNumber.update.useMutation({ onSuccess: async () => { showToast(t("phone_number_unsubscribed_successfully"), "success"); setIsUnsubscribeDialogOpen(false); const currentAgentId = stepAgentId || stepInboundAgentId; if (currentAgentId) { utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId }); } }, onError: (error: { message: string }) => { showToast(error.message, "error"); }, }); const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber) || []; const verifiedEmails = _verifiedEmails || []; const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false); const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = useState(false); const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false); const [isUnsubscribeDialogOpen, setIsUnsubscribeDialogOpen] = useState(false); const [verificationCode, setVerificationCode] = useState(""); const action = step?.action; const requirePhoneNumber = WorkflowActions.SMS_NUMBER === action || WorkflowActions.WHATSAPP_NUMBER === action; const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(requirePhoneNumber); const [firstRender, setFirstRender] = useState(true); const senderNeeded = step?.action === WorkflowActions.SMS_NUMBER || step?.action === WorkflowActions.SMS_ATTENDEE; const [_isSenderIsNeeded, setIsSenderIsNeeded] = useState(senderNeeded); const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState( step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false ); const [isEmailSubjectNeeded, setIsEmailSubjectNeeded] = useState( step?.action === WorkflowActions.EMAIL_ATTENDEE || step?.action === WorkflowActions.EMAIL_HOST || step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false ); const trigger = useWatch({ control: form.control, name: "trigger", }); const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(trigger, t)); const isCreatingAgent = useRef(false); const hasAutoCreated = useRef(false); const handleCreateAgent = useCallback( async (templateWorkflowId?: string) => { if (isCreatingAgent.current || createAgentMutation.isPending) { return; } isCreatingAgent.current = true; try { // Save workflow first to ensure we have step ID if (onSaveWorkflow) { await onSaveWorkflow(); } const updatedSteps = form.getValues("steps"); const currentStepIndex = step ? step.stepNumber - 1 : 0; const updatedStep = updatedSteps[currentStepIndex]; if (updatedStep?.action !== WorkflowActions.CAL_AI_PHONE_CALL) { form.setValue(`steps.${currentStepIndex}.action`, WorkflowActions.CAL_AI_PHONE_CALL); } if (updatedStep?.id) { createAgentMutation.mutate({ teamId, workflowStepId: updatedStep.id, ...(templateWorkflowId && { templateWorkflowId }), }); } else { showToast(t("failed_to_get_workflow_step_id"), "error"); } } catch (error) { console.error("Failed to create agent:", error); showToast(t("failed_to_create_agent"), "error"); } finally { isCreatingAgent.current = false; } }, [createAgentMutation, onSaveWorkflow, form, step, teamId, t] ); const autoCreateAgent = searchParams?.get("autoCreateAgent"); const templateWorkflowId = searchParams?.get("templateWorkflowId"); useEffect(() => { const shouldAutoCreate = autoCreateAgent === "true" && templateWorkflowId && step?.id && step.action === WorkflowActions.CAL_AI_PHONE_CALL && !stepAgentId && !hasAutoCreated.current && !createAgentMutation.isPending && !createAgentMutation.isSuccess; if (shouldAutoCreate && onSaveWorkflow) { hasAutoCreated.current = true; handleCreateAgent(templateWorkflowId); } }, [ autoCreateAgent, templateWorkflowId, step?.id, step?.action, stepAgentId, createAgentMutation.isPending, createAgentMutation.isSuccess, onSaveWorkflow, handleCreateAgent, ]); const triggerOptions = getWorkflowTriggerOptions(t, planState); const templateOptions = getWorkflowTemplateOptions(t, step?.action, planState, trigger); const steps = useWatch({ control: form.control, name: "steps", }); const hasEmailToHostAction = steps.some((s) => s.action === WorkflowActions.EMAIL_HOST); const hasWhatsappAction = steps.some((s) => isWhatsappAction(s.action)); const disallowFormTriggers = hasEmailToHostAction || hasWhatsappAction; const filteredTriggerOptions = triggerOptions.filter( (option) => !(isFormTrigger(option.value) && disallowFormTriggers) ); const { ref: emailSubjectFormRef, ...restEmailSubjectForm } = step ? form.register(`steps.${step.stepNumber - 1}.emailSubject`) : { ref: null, name: "" }; const refEmailSubject = useRef(null); const getNumberVerificationStatus = () => !!step && !!verifiedNumbers.find( (number: string) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`) ); const getEmailVerificationStatus = () => !!step && !!verifiedEmails.find((email: string) => email === form.getValues(`steps.${step.stepNumber - 1}.sendTo`)); const [numberVerified, setNumberVerified] = useState(getNumberVerificationStatus()); const [emailVerified, setEmailVerified] = useState(getEmailVerificationStatus()); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only run when verifiedNumbers.length changes useEffect(() => setNumberVerified(getNumberVerificationStatus()), [verifiedNumbers.length]); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only run when verifiedEmails.length changes useEffect(() => setEmailVerified(getEmailVerificationStatus()), [verifiedEmails.length]); const addVariableEmailSubject = (variable: string) => { if (step) { const currentEmailSubject = refEmailSubject?.current?.value || ""; const cursorPosition = refEmailSubject?.current?.selectionStart || currentEmailSubject.length; const subjectWithAddedVariable = `${currentEmailSubject.substring(0, cursorPosition)}{${variable .toUpperCase() .replace(/ /g, "_")}}${currentEmailSubject.substring(cursorPosition)}`; form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectWithAddedVariable); } }; const sendVerificationCodeMutation = trpc.viewer.workflows.sendVerificationCode.useMutation({ onSuccess: async () => { showToast(t("verification_code_sent"), "success"); }, onError: async (error) => { showToast(error.message, "error"); }, }); const verifyPhoneNumberMutation = trpc.viewer.workflows.verifyPhoneNumber.useMutation({ onSuccess: async (isVerified) => { showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success"); setNumberVerified(isVerified); if ( step && form?.formState?.errors?.steps && form.formState.errors.steps[step.stepNumber - 1]?.sendTo && isVerified ) { form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`); } utils.viewer.workflows.getVerifiedNumbers.invalidate(); }, onError: (err) => { if (err instanceof HttpError) { const message = `${err.statusCode}: ${err.message}`; showToast(message, "error"); setNumberVerified(false); } }, }); const sendEmailVerificationCodeMutation = trpc.viewer.auth.sendVerifyEmailCode.useMutation({ onSuccess() { showToast(t("email_sent"), "success"); }, onError: () => { showToast(t("email_not_sent"), "error"); }, }); const verifyEmailCodeMutation = trpc.viewer.workflows.verifyEmailCode.useMutation({ onSuccess: (isVerified) => { showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success"); setEmailVerified(true); if ( step && form?.formState?.errors?.steps && form.formState.errors.steps[step.stepNumber - 1]?.sendTo && isVerified ) { form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`); } utils.viewer.workflows.getVerifiedEmails.invalidate(); }, onError: (err) => { if (err.message === "invalid_code") { showToast(t("code_provided_invalid"), "error"); setEmailVerified(false); } }, }); /* const testActionMutation = trpc.viewer.workflows.testAction.useMutation({ onSuccess: async () => { showToast(t("notification_sent"), "success"); }, onError: (err) => { let message = t("unexpected_error_try_again"); if (err instanceof TRPCClientError) { if (err.message === "rate-limit-exceeded") { message = t("rate_limit_exceeded"); } else { message = err.message; } } if (err instanceof HttpError) { message = `${err.statusCode}: ${err.message}`; } showToast(message, "error"); }, }); */ //trigger if (!step) { const triggerString = t(`${trigger.toLowerCase()}_trigger`); const selectedTrigger = { label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1), value: trigger, }; return ( <>
{ return ( { if (val) { const oldValue = form.getValues(`steps.${step.stepNumber - 1}.action`); const template = getTemplateBodyForAction({ action: val.value, locale: i18n.language, t, template: WorkflowTemplates.REMINDER, timeFormat, }); form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template); const setNumberRequiredConfigs = ( phoneNumberIsNeeded: boolean, senderNeeded = true ) => { setIsSenderIsNeeded(senderNeeded); setIsEmailAddressNeeded(false); setIsPhoneNumberNeeded(phoneNumberIsNeeded); setNumberVerified(getNumberVerificationStatus()); }; if (isSMSAction(val.value)) { setNumberRequiredConfigs(val.value === WorkflowActions.SMS_NUMBER); // email action changes to sms action if (!isSMSAction(oldValue)) { form.setValue(`steps.${step.stepNumber - 1}.sender`, SENDER_ID); } setIsEmailSubjectNeeded(false); form.setValue(`steps.${step.stepNumber - 1}.agentId`, null); } else if (isWhatsappAction(val.value)) { setNumberRequiredConfigs(val.value === WorkflowActions.WHATSAPP_NUMBER, false); if (!isWhatsappAction(oldValue)) { form.setValue(`steps.${step.stepNumber - 1}.sender`, ""); } setIsEmailSubjectNeeded(false); form.setValue(`steps.${step.stepNumber - 1}.agentId`, null); } else if (isCalAIAction(val.value)) { setIsPhoneNumberNeeded(false); setIsSenderIsNeeded(false); setIsEmailAddressNeeded(false); setIsEmailSubjectNeeded(false); form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, null); form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, null); form.setValue(`steps.${step.stepNumber - 1}.sender`, null); } else { setIsPhoneNumberNeeded(false); setIsSenderIsNeeded(false); setIsEmailAddressNeeded(val.value === WorkflowActions.EMAIL_ADDRESS); setIsEmailSubjectNeeded(true); form.setValue(`steps.${step.stepNumber - 1}.agentId`, null); } form.setValue(`steps.${step.stepNumber - 1}.sendTo`, null); form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`); form.setValue(`steps.${step.stepNumber - 1}.action`, val.value); setUpdateTemplate(!updateTemplate); } }} defaultValue={selectedAction} options={actionOptions.map((option) => ({ ...option, creditsTeamId: teamId ?? creditsTeamId, }))} /> ); }} />
{!isWhatsappAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
{_isSenderIsNeeded ? ( <>
{t("sender_id_info")}
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sender && (

{t("sender_id_error_message")}

)} ) : ( <>
)}
)} {isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && (

{t("cal_ai_agent")} {t("set_up_required")}

{t("no_phone_number_connected")}.

)} {stepAgentId && isAgentLoading && } {stepAgentId && agentData && (
{ const target = e.target as HTMLElement; if ( target.closest("button") || target.closest('[role="menu"]') || target.closest('[role="menuitem"]') ) { return; } if (!props.readOnly) { setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" }); } }}>

{t("cal_ai_agent")}

{arePhoneNumbersActive.length > 0 ? (
{formatPhoneNumber(arePhoneNumbersActive[0].phoneNumber)} {t("active")}
) : (
{t("no_phone_number_connected")}
)}
e.stopPropagation()}> {arePhoneNumbersActive.length > 0 ? ( setIsTestAgentDialogOpen(true)}> {t("phone_call")} setIsWebCallDialogOpen(true)}> {t("web_call")} ) : !isMobile ? ( <> ) : null}
)} {isPhoneNumberNeeded && (
( { const isAlreadyVerified = !!verifiedNumbers ?.concat([]) .find((number) => number.replace(/\s/g, "") === val?.replace(/\s/g, "")); setNumberVerified(isAlreadyVerified); onChange(val); }} /> )} />
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} {numberVerified ? (
{t("number_verified")}
) : ( !props.readOnly && ( <>
{ setVerificationCode(e.target.value); }} required />
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} ) )}
)} {canRequirePhoneNumber(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
( form.setValue(`steps.${step.stepNumber - 1}.numberRequired`, e.target.checked) } /> )} />
)} {isEmailAddressNeeded && !isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
( { const isAlreadyVerified = !!verifiedEmails ?.concat([]) .find((email) => email === val.target.value); setEmailVerified(isAlreadyVerified); onChange(val); }} /> )} />
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} {emailVerified ? (
{t("email_verified")}
) : ( !props.readOnly && ( <>
{ setVerificationCode(e.target.value); }} required />
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} ) )}
)} {!isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
{ return (