diff --git a/apps/ui-playground/app/global.css b/apps/ui-playground/app/global.css index cf8a5c6ec6..66ef957032 100644 --- a/apps/ui-playground/app/global.css +++ b/apps/ui-playground/app/global.css @@ -96,6 +96,9 @@ --cal-bg-visualization-7-subtle: hsla(0, 96%, 89%, 1); /* red-100 */ --cal-bg-visualization-7-emphasis: hsla(0, 84%, 60%, 1); /* red-500 */ + /* Launch Background */ + --cal-bg-launch-dark: hsla(242, 53%, 10%, 1); /* #0D0C27 - Launch card dark background */ + /* Legacy Background Components - Consider deprecating */ --cal-bg-info: hsla(221, 91%, 93%, 1); /* #dee9fc - info backgrounds */ --cal-bg-success: hsla(142, 76%, 94%, 1); /* #e2fbe8 - success backgrounds */ @@ -192,6 +195,9 @@ --cal-bg-visualization-7-subtle: hsla(0, 70%, 35%, 1); /* red-200 */ --cal-bg-visualization-7-emphasis: hsla(0, 84%, 60%, 1); /* red-500 */ + /* Launch Background */ + --cal-bg-launch-dark: hsla(242, 53%, 10%, 1); /* #0D0C27 - Launch card dark background */ + /* Legacy Background Components - Consider deprecating */ --cal-bg-info: hsla(228, 56%, 33%, 1); /* Keeping existing info background */ --cal-bg-success: hsla(133, 34%, 24%, 1); /* Keeping existing success background */ diff --git a/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx b/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx new file mode 100644 index 0000000000..849b59c0bd --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx @@ -0,0 +1,138 @@ +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { SENDER_NAME } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import type { Workflow } from "@calcom/prisma/client"; +import { + MembershipRole, + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +const WORKFLOW_TEMPLATES = { + "wf-10": { + name: "Cal AI No-show Follow-up Call", + trigger: WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED, + time: 0, + timeUnit: TimeUnit.MINUTE, + }, + "wf-11": { + name: "Cal AI 1-hour Meeting Reminder", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + }, +} as const; + +interface PageProps { + searchParams: Promise<{ + action?: string; + templateWorkflowId?: string; + teamId?: string; + name?: string; + }>; +} + +const Page = async ({ searchParams }: PageProps) => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const user = session?.user; + + const { action, templateWorkflowId, teamId, name } = await searchParams; + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: `createWorkflow:${user?.id}`, + }); + + if (!user?.id) { + const queryParams = new URLSearchParams(); + if (action) queryParams.set("action", action); + if (templateWorkflowId) queryParams.set("templateWorkflowId", templateWorkflowId); + if (teamId) queryParams.set("teamId", teamId); + if (name) queryParams.set("name", name); + + const callbackUrl = `/workflow/new${queryParams.toString() ? `?${queryParams.toString()}` : ""}`; + redirect(`/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}`); + } + + if (action !== "calAi" || !templateWorkflowId) { + redirect("/workflows"); + } + + if (templateWorkflowId !== "wf-10" && templateWorkflowId !== "wf-11") { + redirect("/workflows"); + } + + const userId = user.id; + const parsedTeamId = teamId ? parseInt(teamId, 10) : undefined; + + if (parsedTeamId) { + const permissionService = new PermissionCheckService(); + + const hasPermission = await permissionService.checkPermission({ + userId, + teamId: parsedTeamId, + permission: "workflow.create", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + if (!hasPermission) { + redirect("/workflows?error=unauthorized"); + } + } + + const template = WORKFLOW_TEMPLATES[templateWorkflowId as keyof typeof WORKFLOW_TEMPLATES]; + + try { + const workflow: Workflow = await prisma.workflow.create({ + data: { + name: name || template.name, + trigger: template.trigger, + time: template.time, + timeUnit: template.timeUnit, + userId, + teamId: parsedTeamId, + }, + }); + + await prisma.workflowStep.create({ + data: { + stepNumber: 1, + action: WorkflowActions.CAL_AI_PHONE_CALL, + template: WorkflowTemplates.CUSTOM, + workflowId: workflow.id, + sender: SENDER_NAME, + numberVerificationPending: false, + verifiedAt: new Date(), + includeCalendarEvent: false, + }, + }); + + const redirectUrl = `/workflows/${workflow.id}?autoCreateAgent=true&templateWorkflowId=${templateWorkflowId}`; + + redirect(redirectUrl); + } catch (error) { + if ( + error && + typeof error === "object" && + "digest" in error && + typeof error.digest === "string" && + error.digest.startsWith("NEXT_REDIRECT") + ) { + throw error; + } + + console.error("Failed to create Cal AI workflow:", error); + redirect("/workflows?error=failed-to-create-workflow"); + } +}; + +export default Page; diff --git a/apps/web/playwright/fixtures/workflows.ts b/apps/web/playwright/fixtures/workflows.ts index 41ae025e01..32ed78908a 100644 --- a/apps/web/playwright/fixtures/workflows.ts +++ b/apps/web/playwright/fixtures/workflows.ts @@ -24,6 +24,10 @@ export function createWorkflowPageFixture(page: Page) { } else { await page.getByTestId("create-button").click(); } + + await page.getByTestId(`workflow-option-card-scratch`).click(); + await page.getByTestId("continue-button").click(); + if (name) { await fillNameInput(name); } diff --git a/apps/web/playwright/icons.e2e.ts b/apps/web/playwright/icons.e2e.ts index 834304fe0d..f5c460f17d 100644 --- a/apps/web/playwright/icons.e2e.ts +++ b/apps/web/playwright/icons.e2e.ts @@ -4,7 +4,7 @@ import { test } from "./lib/fixtures"; // Set a consistent viewport size across all environments test.use({ - viewport: { width: 1265, height: 1320 }, // Match the expected dimensions + viewport: { width: 1265, height: 1464 }, // Match the expected dimensions }); test("Icons render properly", async ({ page }) => { diff --git a/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png b/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png index 495b51414f..ee49ead308 100644 Binary files a/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png and b/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png differ diff --git a/apps/web/public/cal-ai-banner.svg b/apps/web/public/cal-ai-banner.svg new file mode 100644 index 0000000000..8c8ef9c931 --- /dev/null +++ b/apps/web/public/cal-ai-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/cal-ai-workflow-sidebar.jpg b/apps/web/public/cal-ai-workflow-sidebar.jpg new file mode 100644 index 0000000000..da7a3c9a0e Binary files /dev/null and b/apps/web/public/cal-ai-workflow-sidebar.jpg differ diff --git a/apps/web/public/call-outgoing.svg b/apps/web/public/call-outgoing.svg new file mode 100644 index 0000000000..89ca8e605d --- /dev/null +++ b/apps/web/public/call-outgoing.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index beefee2cdd..805a734f80 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -221,6 +221,11 @@ + + + + + @@ -520,6 +525,11 @@ + + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 31e858bb57..51ad59bc0a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -819,6 +819,8 @@ "workflow_validation_failed": "Workflow validation failed", "workflow_validation_empty_fields": "One or more workflow steps have empty message content", "workflow_validation_unverified_contacts": "One or more phone numbers or email addresses are not verified", + "supercharge_your_workflows_with_cal_ai": "Supercharge your Workflows with Cal.ai", + "supercharge_your_workflows_with_cal_ai_description": "Lifelike AI agents that book meetings, send reminders, and follow up with your customers.", "phone_number_imported_successfully": "Phone number imported and linked to agent successfully", "phone_number_deleted_successfully": "Phone number deleted successfully", "delete_phone_number_confirmation": "Are you sure you want to delete this phone number? This action cannot be undone.", @@ -1208,6 +1210,7 @@ "categories": "Categories", "pricing": "Pricing", "learn_more": "Learn more", + "try_now": "Try now", "privacy_policy": "Privacy Policy", "terms_of_service": "Terms of Service", "remove": "Remove", @@ -1441,6 +1444,7 @@ "whatsapp_attendee_action": "send WhatsApp message to attendee", "workflows": "Workflows", "new_workflow_btn": "New Workflow", + "how_would_you_like_to_start": "How would you like to start?", "add_new_workflow": "Add a new workflow", "reschedule_event_trigger": "when event is rescheduled", "trigger": "Trigger", @@ -1756,6 +1760,7 @@ "team_url": "Team URL", "team_members": "Team members", "more": "More", + "cal_ai_workflows": "Cal.ai Workflows", "and_count_more": "and {{count}} more", "more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complicated actions, please refer back to the web application.", "workflow_example_1": "Send SMS reminder 24 hours before event starts to attendee", @@ -1764,6 +1769,18 @@ "workflow_example_4": "Send email reminder 1 hour before events starts to attendee", "workflow_example_5": "Send custom email when event is rescheduled to host", "workflow_example_6": "Send custom SMS when new event is booked to host", + "send_sms_reminder": "Send SMS reminder", + "send_sms_reminder_description": "24 hours before event starts", + "follow_up_with_no_shows": "Follow up with no shows", + "follow_up_with_no_shows_description": "30m after event ends", + "remind_attendees_to_bring_id": "Remind attendees to bring ID", + "remind_attendees_to_bring_id_description": "1 day before event starts", + "email_to_remind_booking": "Email Reminder", + "email_to_remind_booking_description": "1 hour before event starts", + "custom_sms_reminder": "Custom SMS Reminder", + "custom_sms_reminder_description": "When event is scheduled", + "custom_email_reminder": "Custom Email Reminder", + "custom_email_reminder_description": "Event is rescheduled to host", "count_managed_to_limit": "Include booking counts from managed event types", "welcome_to_cal_header": "Welcome to {{appName}}!", "edit_form_later_subtitle": "You'll be able to edit this later.", @@ -2187,6 +2204,8 @@ "show_on_booking_page": "Show on booking page", "visit_cancelled_booking": "You can visit the canceled booking page", "get_started_zapier_templates": "Get started with Zapier templates", + "standard_templates": "Standard Templates", + "cal_ai_templates": "Cal.ai Templates", "team_is_unpublished": "{{team}} is unpublished", "org_is_unpublished_description": "This organization link is currently not available. Please contact the organization owner or ask them to publish it.", "team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them to publish it.", @@ -2754,6 +2773,8 @@ "account_already_linked": "Account is already linked", "send_email": "Send email", "cal_ai_phone_call_action": "Call attendee using Cal.ai Voice Agent", + "call_to_confirm_booking": "Call to confirm booking", + "cal_ai_phone_call_action_description": "2 hrs before event starts", "cal_ai_agent_configuration": "Cal.ai Agent Configuration", "choose_at_least_one_event_type_test_call": "Please choose at least one event type to make a test call.", "mark_as_no_show": "Mark as no-show", @@ -3658,5 +3679,9 @@ "cancel_booking_acknowledge_no_show_fee": "I acknowledge that by cancelling the booking within {{timeValue}} {{timeUnit}} of the start time I will be charged the no show fee of {{amount, currency}}", "contact_organizer": "If you have any questions, please contact the organizer.", "testing_sms_workflow_info_message": "When testing this workflow, be aware that SMS need to be scheduled at least 15 minutes in advance", + "start_from_scratch_title": "Start from scratch", + "start_from_scratch_description": "Create your own workflow from scratch.", + "cal_ai_template_title": "Cal.ai template", + "cal_ai_template_description": "AI agents that book meetings, send reminders, and follow up!", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 5446ff5955..5939d70e78 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -106,6 +106,8 @@ --cal-bg-error: hsla(3, 66%, 93%, 1); /* #f9e3e2 - error backgrounds */ --cal-bg-dark-error: hsla(2, 55%, 30%, 1); /* Keeping existing dark error */ + --cal-bg-launch-dark: hsla(242, 53%, 10%, 1); /* #0D0C27 - Launch card dark background */ + /* Borders */ --cal-border-emphasis: hsla(218, 11%, 65%, 1); /* gray.400 - input:hover */ --cal-border: hsla(216, 12%, 84%, 1); /* gray.300 - inputs */ @@ -196,6 +198,8 @@ --cal-bg-visualization-7-subtle: hsla(0, 70%, 35%, 1); /* red-200 */ --cal-bg-visualization-7-emphasis: hsla(0, 84%, 60%, 1); /* red-500 */ + --cal-bg-launch-dark: hsla(242, 53%, 10%, 1); /* #0D0C27 - Launch card dark background */ + /* Legacy Background Components - Consider deprecating */ --cal-bg-info: hsla(228, 56%, 33%, 1); /* Keeping existing info background */ --cal-bg-success: hsla(133, 34%, 24%, 1); /* Keeping existing success background */ diff --git a/packages/config/tailwind-preset.js b/packages/config/tailwind-preset.js index 5cdebad567..b0c04a3ba1 100644 --- a/packages/config/tailwind-preset.js +++ b/packages/config/tailwind-preset.js @@ -148,6 +148,9 @@ module.exports = { error: "var(--cal-bg-error)", darkerror: "var(--cal-bg-dark-error)", + // Launch colors + "launch-dark": "var(--cal-bg-launch-dark)", + // Base colors black: "#111111", gray: colors.gray, diff --git a/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx b/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx index b0ccc118b7..5d1101bbb1 100644 --- a/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx +++ b/packages/features/ee/workflows/components/AgentConfigurationSheet.tsx @@ -708,11 +708,41 @@ export function AgentConfigurationSheet({ )} - + + + + { + setIsWebCallDialogOpen(true); + }}> + {t("web_call")} + + + + + + + + + + + ); +} + +export function useWorkflowCreation() { + const [showDialog, setShowDialog] = useState(false); + const [pendingTeamId, setPendingTeamId] = useState(undefined); + + const openDialog = (teamId?: number) => { + setPendingTeamId(teamId); + setShowDialog(true); + }; + + return { + showDialog, + setShowDialog, + pendingTeamId, + openDialog, + }; +} diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 835c5e2e08..d15399ef78 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -1,7 +1,7 @@ import { type TFunction } from "i18next"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import type { UseFormReturn } from "react-hook-form"; import { Controller } from "react-hook-form"; import "react-phone-number-input/style.css"; @@ -190,6 +190,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { 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); @@ -250,7 +255,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { const senderNeeded = step?.action === WorkflowActions.SMS_NUMBER || step?.action === WorkflowActions.SMS_ATTENDEE; - const [, setIsSenderIsNeeded] = useState(senderNeeded); + const [_isSenderIsNeeded, setIsSenderIsNeeded] = useState(senderNeeded); const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState( step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false @@ -265,65 +270,79 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { ); const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(form.getValues("trigger"), t)); - const [autoAgentCreationAttempted, setAutoAgentCreationAttempted] = useState(false); + const isCreatingAgent = useRef(false); + const hasAutoCreated = useRef(false); + + const handleCreateAgent = useCallback( + async (templateWorkflowId?: string) => { + if (isCreatingAgent.current || createAgentMutation.isPending) { + console.log("Agent creation already in progress, skipping..."); + 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 autoCreateAgent = searchParams?.get("autoCreateAgent"); - const templateWorkflowId = searchParams?.get("templateWorkflowId"); - - if ( + const shouldAutoCreate = autoCreateAgent === "true" && - !autoAgentCreationAttempted && templateWorkflowId && - step && + step?.id && step.action === WorkflowActions.CAL_AI_PHONE_CALL && !stepAgentId && - step.id && - onSaveWorkflow - ) { - setAutoAgentCreationAttempted(true); + !hasAutoCreated.current && + !createAgentMutation.isPending && + !createAgentMutation.isSuccess; - const createAgent = async () => { - try { - await onSaveWorkflow?.(); - - const updatedSteps = form.getValues("steps"); - const currentStepIndex = step.stepNumber - 1; - const updatedStep = updatedSteps[currentStepIndex]; - - if (updatedStep?.id) { - createAgentMutation.mutate({ - teamId, - workflowStepId: updatedStep.id, - templateWorkflowId, - }); - - const url = new URL(window.location.href); - url.searchParams.delete("autoCreateAgent"); - url.searchParams.delete("templateWorkflowId"); - router.replace(url.pathname + url.search); - } else { - showToast(t("failed_to_get_workflow_step_id"), "error"); - } - } catch (error) { - console.error("Failed to auto-create agent:", error); - showToast(t("failed_to_create_agent"), "error"); - } - }; - - createAgent(); + if (shouldAutoCreate && onSaveWorkflow) { + hasAutoCreated.current = true; + handleCreateAgent(templateWorkflowId); } }, [ - searchParams, - autoAgentCreationAttempted, - step, + autoCreateAgent, + templateWorkflowId, + step?.id, + step?.action, stepAgentId, - teamId, + createAgentMutation.isPending, + createAgentMutation.isSuccess, onSaveWorkflow, - createAgentMutation, - form, - t, - router, + handleCreateAgent, ]); const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); @@ -619,6 +638,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: step.action, needsCredits: isSMSOrWhatsappAction(step.action), + isCalAi: isCalAIAction(step.action), creditsTeamId: teamId ?? creditsTeamId, isOrganization: props.isOrganization, }; @@ -743,7 +763,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { {isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && (
-
+

{t("cal_ai_agent")} @@ -757,34 +777,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {

diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index 9e4d47ad7f..f7cf285604 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -25,6 +25,7 @@ export function getWorkflowActionOptions(t: TFunction, isOrgsPlan?: boolean) { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: action, needsCredits: (!isOrgsPlan && isSMSOrWhatsappAction(action)) || isCalAIAction(action), + isCalAi: isCalAIAction(action), }; }); } diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index a18fba663b..a18ca7a098 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -10,13 +10,11 @@ import Shell, { ShellMain } from "@calcom/features/shell/Shell"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; -import { HttpError } from "@calcom/lib/http-error"; import type { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Avatar } from "@calcom/ui/components/avatar"; import { AnimatedPopover } from "@calcom/ui/components/popover"; -import { showToast } from "@calcom/ui/components/toast"; import { FilterResults } from "../../../filters/components/FilterResults"; import { TeamsFilter } from "../../../filters/components/TeamsFilter"; @@ -24,6 +22,7 @@ import { getTeamsFiltersFromQuery } from "../../../filters/lib/getTeamsFiltersFr import LicenseRequired from "../../common/components/LicenseRequired"; import EmptyScreen from "../components/EmptyScreen"; import SkeletonLoader from "../components/SkeletonLoaderList"; +import { WorkflowCreationDialog, useWorkflowCreation } from "../components/WorkflowCreationDialog"; import WorkflowList from "../components/WorkflowListPage"; type PageProps = { @@ -36,6 +35,7 @@ function WorkflowsPage({ filteredList }: PageProps) { const router = useRouter(); const routerQuery = useRouterQuery(); const filters = getTeamsFiltersFromQuery(routerQuery); + const { showDialog, setShowDialog, pendingTeamId, openDialog } = useWorkflowCreation(); const { data, isPending: _isPending } = trpc.viewer.workflows.filteredList.useQuery( { @@ -48,23 +48,6 @@ function WorkflowsPage({ filteredList }: PageProps) { const filteredWorkflows = filteredList ?? data; const isPending = filteredList ? false : _isPending; - const createMutation = trpc.viewer.workflows.create.useMutation({ - onSuccess: async ({ workflow }) => { - await router.replace(`/workflows/${workflow.id}`); - }, - onError: (err) => { - if (err instanceof HttpError) { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - } - - if (err.data?.code === "UNAUTHORIZED") { - const message = `${err.data.code}: ${t("error_workflow_unauthorized_create")}`; - showToast(message, "error"); - } - }, - }); - return ( @@ -77,10 +60,7 @@ function WorkflowsPage({ filteredList }: PageProps) { session.data?.hasValidLicense ? ( { - createMutation.mutate({ teamId }); - }} - isPending={createMutation.isPending} + createFunction={openDialog} disableMobileButton={true} onlyShowWithNoTeams={true} includeOrg={true} @@ -98,8 +78,7 @@ function WorkflowsPage({ filteredList }: PageProps) {
createMutation.mutate({ teamId })} - isPending={createMutation.isPending} + createFunction={openDialog} disableMobileButton={true} onlyShowWithTeams={true} includeOrg={true} @@ -120,6 +99,7 @@ function WorkflowsPage({ filteredList }: PageProps) { + ); diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index f909ebd93a..d9f753ad22 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useSession } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { Toaster } from "sonner"; @@ -66,6 +67,7 @@ function WorkflowPage({ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [nameValue, setNameValue] = useState(""); + const searchParams = useSearchParams(); const form = useForm({ mode: "onBlur", @@ -283,12 +285,16 @@ function WorkflowPage({ onSuccess: async ({ workflow }) => { utils.viewer.workflows.get.setData({ id: +workflow.id }, workflow); setFormData(workflow); - showToast( - t("workflow_updated_successfully", { - workflowName: workflow.name, - }), - "success" - ); + + const autoCreateAgent = searchParams?.get("autoCreateAgent"); + if (!autoCreateAgent) { + showToast( + t("workflow_updated_successfully", { + workflowName: workflow.name, + }), + "success" + ); + } }, onError: (err) => { if (err instanceof HttpError) { diff --git a/packages/features/shell/CalAiBanner.tsx b/packages/features/shell/CalAiBanner.tsx new file mode 100644 index 0000000000..521886ff12 --- /dev/null +++ b/packages/features/shell/CalAiBanner.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { localStorage } from "@calcom/lib/webstorage"; +import { Icon } from "@calcom/ui/components/icon"; + +export function CalAiBanner() { + const { t } = useLocale(); + const pathname = usePathname(); + const [isVisible, setIsVisible] = useState(() => { + if (typeof window === "undefined") return true; + return !localStorage.getItem("calai-banner-dismissed"); + }); + + const handleDismiss = () => { + localStorage.setItem("calai-banner-dismissed", "true"); + setIsVisible(false); + }; + + const handleTryNow = () => { + window.open("/workflow/new?action=calAi&templateWorkflowId=wf-11", "_blank"); + }; + + if (!isVisible) return null; + + if (!(pathname === "/workflows" || pathname === "/event-types" || pathname === "/bookings/upcoming")) + return null; + + return ( +
+ + +
+
+

{t("supercharge_your_workflows_with_cal_ai")}

+

+ {t("supercharge_your_workflows_with_cal_ai_description")} +

+ + +
+ + Cal.ai Banner +
+
+ ); +} diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 82a43ee8c9..d12057b85a 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -17,6 +17,7 @@ import { Button } from "@calcom/ui/components/button"; import { ErrorBoundary } from "@calcom/ui/components/errorBoundary"; import { SkeletonText } from "@calcom/ui/components/skeleton"; +import { CalAiBanner } from "./CalAiBanner"; import { SideBarContainer } from "./SideBar"; import { TopNavContainer } from "./TopNav"; import { BannerContainer } from "./banners/LayoutBanner"; @@ -205,6 +206,7 @@ function MainContainer({ {/* show top navigation for md and smaller (tablet and phones) */} {TopNavContainerProp}
+ {!props.withoutMain ? {props.children} : props.children} diff --git a/packages/features/shell/navigation/NavigationItem.tsx b/packages/features/shell/navigation/NavigationItem.tsx index f58dc522ad..7b59c7ed0f 100644 --- a/packages/features/shell/navigation/NavigationItem.tsx +++ b/packages/features/shell/navigation/NavigationItem.tsx @@ -5,6 +5,7 @@ import React, { Fragment, useState, useEffect } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import classNames from "@calcom/ui/classNames"; +import { Badge } from "@calcom/ui/components/badge"; import { Icon } from "@calcom/ui/components/icon"; import type { IconName } from "@calcom/ui/components/icon"; import { SkeletonText } from "@calcom/ui/components/skeleton"; @@ -215,6 +216,11 @@ export const NavigationItem: React.FC<{ ) : ( )} + {item.name === "workflows" && ( + + Cal.ai + + )} )} diff --git a/packages/features/tips/Tips.tsx b/packages/features/tips/Tips.tsx index 7163410885..adfeca740b 100644 --- a/packages/features/tips/Tips.tsx +++ b/packages/features/tips/Tips.tsx @@ -6,7 +6,18 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; import { Card } from "@calcom/ui/components/card"; -export const tips = [ +type Tip = { + id: number; + thumbnailUrl: string; + mediaLink?: string; + title: string; + description: string; + href: string; + coverPhoto?: string; + variant?: "SidebarCard" | "NewLaunchSidebarCard"; +}; + +export const tips: Tip[] = [ { id: 17, thumbnailUrl: "https://img.youtube.com/vi/fMHW6jYPIb8/0.jpg", @@ -145,12 +156,22 @@ export const tips = [ }, ]; -const reversedTips = shuffle(tips).slice(0).reverse(); +const CalAIWorkflowsTip: Tip = { + id: 18, + thumbnailUrl: "https://img.youtube.com/vi/fMHW6jYPIb8/0.jpg", + title: "cal_ai_workflows", + description: "supercharge_your_workflows_with_cal_ai_description", + href: "/workflows", + coverPhoto: "/cal-ai-workflow-sidebar.jpg", + variant: "NewLaunchSidebarCard", +}; + +const reversedTips = [...shuffle(tips).slice(0).reverse(), CalAIWorkflowsTip]; function Tips() { const { t } = useLocale(); - const [list, setList] = useState(() => { + const [list, setList] = useState(() => { if (typeof window === "undefined") { return reversedTips; } @@ -186,49 +207,63 @@ function Tips() { const baseOriginalList = list.slice(0).reverse(); return ( -
- {list.map((tip) => { - const isTopTip = baseOriginalList.indexOf(tip) === 0; - return ( -
+ <> +
+ {list.map((tip) => { + const isTopTip = baseOriginalList.indexOf(tip) === 0; + const isCalAIWorkflowsTip = tip.title === "cal_ai_workflows"; + return (
- handleRemoveItem(tip.id), child: t("dismiss") } : undefined - } - containerProps={{ - tabIndex: isTopTip ? undefined : -1, - "aria-hidden": isTopTip ? undefined : "true", - }} - /> + gridRowStart: 1, + gridColumnStart: 1, + }} + key={tip.id}> +
+ handleRemoveItem(tip.id), child: t("dismiss") } : undefined + } + containerProps={{ + tabIndex: isTopTip ? undefined : -1, + "aria-hidden": isTopTip ? undefined : "true", + }} + buttonClassName={isCalAIWorkflowsTip ? "text-white" : undefined} + /> +
-
- ); - })} -
+ ); + })} +
+ ); } diff --git a/packages/ui/components/card/Card.tsx b/packages/ui/components/card/Card.tsx index b9d42dba88..2c3e3d6657 100644 --- a/packages/ui/components/card/Card.tsx +++ b/packages/ui/components/card/Card.tsx @@ -17,6 +17,7 @@ const cvaCardTypeByVariant = cva("", { basic: "", ProfileCard: "", SidebarCard: "", + NewLaunchSidebarCard: "", }, structure: { image: "", @@ -35,7 +36,7 @@ const cvaCardTypeByVariant = cva("", { { variant: "basic", structure: "card", - className: "p-5", + className: "p-5 bg-default", }, { variant: "basic", @@ -57,7 +58,7 @@ const cvaCardTypeByVariant = cva("", { { variant: "ProfileCard", structure: "card", - className: "w-80 p-4 hover:bg-subtle", + className: "w-80 p-4 hover:bg-subtle bg-default", }, { variant: "ProfileCard", @@ -79,7 +80,7 @@ const cvaCardTypeByVariant = cva("", { { variant: "SidebarCard", structure: "card", - className: "w-full p-3 border border-subtle", + className: "w-full p-3 border border-subtle bg-default", }, { variant: "SidebarCard", @@ -91,6 +92,28 @@ const cvaCardTypeByVariant = cva("", { structure: "description", className: "text-xs text-default line-clamp-2", }, + + // Style for NewLaunchSidebarCard Variant Types + { + variant: "NewLaunchSidebarCard", + structure: "image", + className: "w-9 h-auto rounded-full mb-4s", + }, + { + variant: "NewLaunchSidebarCard", + structure: "card", + className: "w-full p-3 border border-subtle bg-launch-dark text-white", + }, + { + variant: "NewLaunchSidebarCard", + structure: "title", + className: "text-sm font-cal text-white", + }, + { + variant: "NewLaunchSidebarCard", + structure: "description", + className: "text-xs text-white", + }, ], }); @@ -116,6 +139,8 @@ export interface BaseCardProps extends CVACardType { mediaLink?: string; thumbnailUrl?: string; structure?: string; + coverPhoto?: string; + buttonClassName?: string; } export function Card({ @@ -130,14 +155,16 @@ export function Card({ mediaLink, thumbnailUrl, learnMore, + coverPhoto, + buttonClassName, }: BaseCardProps) { const LinkComponent = learnMore && learnMore.href.startsWith("https") ? "a" : Link; return (
@@ -158,8 +185,8 @@ export function Card({
{title}
@@ -171,11 +198,11 @@ export function Card({

)}
- {variant === "SidebarCard" && ( + {variant === "SidebarCard" && mediaLink && ( @@ -201,6 +228,9 @@ export function Card({ play feature video )} + {variant === "NewLaunchSidebarCard" && coverPhoto && ( + cover + )} {/* TODO: this should be CardActions https://mui.com/material-ui/api/card-actions/ */} {variant === "basic" && actionButton && ( @@ -216,21 +246,23 @@ export function Card({
)} - {variant === "SidebarCard" && ( + {(variant === "SidebarCard" || variant === "NewLaunchSidebarCard") && (
{learnMore && ( + className={classNames("text-default text-xs font-medium", buttonClassName)}> {learnMore.text} )} {actionButton?.child && (