feat: cal ai promo design (#23651)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Udit Takkar
2025-09-14 12:00:12 -03:00
committed by GitHub
co-authored by Peer Richelsen CarinaWolli
parent 3aab13b4da
commit cfadd2e892
28 changed files with 884 additions and 214 deletions
+6
View File
@@ -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 */
@@ -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;
@@ -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);
}
+1 -1
View File
@@ -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 }) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 248 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+63
View File
@@ -0,0 +1,63 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7367_19010)">
<g filter="url(#filter0_ddiiii_7367_19010)">
<path d="M3.83886 2.25C3.02282 2.25 2.30939 2.92142 2.36335 3.7963C2.75857 10.2041 7.8848 15.3303 14.2926 15.7255C15.1674 15.7795 15.8389 15.066 15.8389 14.25V11.958C15.8389 11.2956 15.4044 10.7116 14.7699 10.5213L12.7334 9.91035C12.2049 9.75179 11.632 9.89623 11.2418 10.2864L10.68 10.8482C9.24287 10.0389 8.04994 8.84599 7.24071 7.40881L7.80243 6.84709C8.19263 6.45689 8.33707 5.88396 8.17851 5.35541L7.56758 3.31898C7.37724 2.6845 6.79325 2.25 6.13084 2.25H3.83886Z" fill="url(#paint0_linear_7367_19010)"/>
<path d="M12.09 2.25C11.6758 2.25 11.34 2.58579 11.34 3C11.34 3.41421 11.6758 3.75 12.09 3.75H13.2793L10.8096 6.21967C10.5167 6.51256 10.5167 6.98744 10.8096 7.28033C11.1025 7.57322 11.5774 7.57322 11.8703 7.28033L14.34 4.81066V6C14.34 6.41421 14.6758 6.75 15.09 6.75C15.5042 6.75 15.84 6.41421 15.84 6V3C15.84 2.58579 15.5042 2.25 15.09 2.25H12.09Z" fill="url(#paint1_linear_7367_19010)"/>
</g>
</g>
<defs>
<filter id="filter0_ddiiii_7367_19010" x="-2.58388" y="-1.03125" width="23.3682" height="23.9514" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2.24743"/>
<feGaussianBlur stdDeviation="2.47217"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7367_19010"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.9375"/>
<feGaussianBlur stdDeviation="2.10938"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_7367_19010" result="effect2_dropShadow_7367_19010"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7367_19010" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.898973"/>
<feGaussianBlur stdDeviation="1.5732"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_7367_19010"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1.64062"/>
<feGaussianBlur stdDeviation="5.50781"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.6775 0 0 0 0 0.625 0 0 0 0 1 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect3_innerShadow_7367_19010" result="effect4_innerShadow_7367_19010"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="0.9375" operator="erode" in="SourceAlpha" result="effect5_innerShadow_7367_19010"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.40625"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="effect4_innerShadow_7367_19010" result="effect5_innerShadow_7367_19010"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="0.9375" operator="erode" in="SourceAlpha" result="effect6_innerShadow_7367_19010"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.05469"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="effect5_innerShadow_7367_19010" result="effect6_innerShadow_7367_19010"/>
</filter>
<linearGradient id="paint0_linear_7367_19010" x1="9.13623" y1="2.25" x2="9.13623" y2="15.7284" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#E3E0F4"/>
</linearGradient>
<linearGradient id="paint1_linear_7367_19010" x1="9.13623" y1="2.25" x2="9.13623" y2="15.7284" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#E3E0F4"/>
</linearGradient>
<clipPath id="clip0_7367_19010">
<rect width="18" height="18" fill="white" transform="translate(0.0899658)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+10
View File
@@ -221,6 +221,11 @@
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" ></path>
<path d="M12 17h.01" ></path>
</symbol>
<symbol class="lucide lucide-circle-plus" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="circle-plus">
<circle cx="12" cy="12" r="10" ></circle>
<path d="M8 12h8" ></path>
<path d="M12 8v8" ></path>
</symbol>
<symbol class="lucide lucide-circle-x" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="circle-x">
<circle cx="12" cy="12" r="10" ></circle>
<path d="m15 9-6 6" ></path>
@@ -520,6 +525,11 @@
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" ></path>
<line x1="22" x2="2" y1="2" y2="22" ></line>
</symbol>
<symbol class="lucide lucide-phone-outgoing" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone-outgoing">
<polyline points="22 8 22 2 16 2" ></polyline>
<line x1="16" x2="22" y1="8" y2="2" ></line>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
</symbol>
<symbol class="lucide lucide-phone" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
</symbol>

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+4
View File
@@ -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 */
+3
View File
@@ -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,
@@ -708,11 +708,41 @@ export function AgentConfigurationSheet({
)}
</SheetBody>
<SheetFooter>
<Button type="button" color="secondary" onClick={() => onOpenChange(false)}>
<div className="mr-auto">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
color="secondary"
className="rounded-[10px]"
disabled={readOnly}
EndIcon="chevron-down">
{t("test_agent")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="monitor"
onClick={() => {
setIsWebCallDialogOpen(true);
}}>
{t("web_call")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
<Button
className="justify-center"
type="button"
color="secondary"
onClick={() => onOpenChange(false)}>
{t("cancel")}
</Button>
<Button
type="button"
className="justify-center"
onClick={agentForm.handleSubmit(handleAgentUpdate)}
disabled={!agentForm.formState.isDirty || readOnly || updateAgentMutation.isPending}
loading={updateAgentMutation.isPending}>
@@ -877,7 +907,7 @@ export function AgentConfigurationSheet({
</div>
{showAdvancedFields && (
<div className="space-y-5 rounded-lg bg-white p-4">
<div className="bg-default space-y-5 rounded-lg p-4">
<Controller
name="sipTrunkAuthUsername"
control={phoneNumberForm.control}
@@ -1,32 +1,48 @@
import { useRouter } from "next/navigation";
import { CreateButtonWithTeamsList } from "@calcom/features/ee/teams/components/createButton/CreateButtonWithTeamsList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import cn from "@calcom/ui/classNames";
import { EmptyScreen as ClassicEmptyScreen } from "@calcom/ui/components/empty-screen";
import { Icon } from "@calcom/ui/components/icon";
import type { IconName } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { WorkflowCreationDialog, useWorkflowCreation } from "./WorkflowCreationDialog";
type WorkflowExampleType = {
Icon: IconName;
text: string;
icon: IconName;
iconWrapperClassName?: string;
title: string;
description: string;
image?: string;
};
function WorkflowExample(props: WorkflowExampleType) {
const { Icon: iconName, text } = props;
const { icon: iconName, title, description, iconWrapperClassName, image } = props;
return (
<div className="border-subtle mx-2 my-2 max-h-24 max-w-[600px] rounded-md border border-solid p-6">
<div className="flex ">
<div className="bg-default border-subtle max-h-24 max-w-[600px] rounded-xl border border-solid p-2.5">
<div className="flex">
<div className="flex items-center justify-center">
<div className="bg-emphasis dark:bg-default mr-4 flex h-10 w-10 items-center justify-center rounded-full">
<Icon name={iconName} className="text-default h-6 w-6 stroke-[2px]" />
<div
className={cn(
"bg-emphasis dark:bg-default mr-4 flex h-10 w-10 items-center justify-center rounded-[10px]",
iconWrapperClassName
)}>
{image ? (
<img
src={image}
alt=""
role="presentation"
aria-hidden="true"
className={cn("text-default h-5 w-5")}
/>
) : (
<Icon name={iconName} className={cn("text-default h-5 w-5")} aria-hidden="true" />
)}
</div>
</div>
<div className="m-auto w-full flex-grow items-center justify-center ">
<div className="text-semibold text-emphasis line-clamp-2 w-full text-sm font-medium">{text}</div>
<div className="text-semibold text-emphasis line-clamp-2 w-full text-sm font-semibold">{title}</div>
<div className="text-default text-sm font-normal leading-none">{description}</div>
</div>
</div>
</div>
@@ -35,33 +51,60 @@ function WorkflowExample(props: WorkflowExampleType) {
export default function EmptyScreen(props: { isFilteredView: boolean }) {
const { t } = useLocale();
const router = useRouter();
const { showDialog, setShowDialog, pendingTeamId, openDialog } = useWorkflowCreation();
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace(`/workflows/${workflow.id}`);
const workflowsExamples: WorkflowExampleType[] = [
{ icon: "smartphone", title: t("send_sms_reminder"), description: t("send_sms_reminder_description") },
{
icon: "smartphone",
title: t("follow_up_with_no_shows"),
description: t("follow_up_with_no_shows_description"),
},
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("unauthorized_create_workflow")}`;
showToast(message, "error");
}
{
icon: "mail",
title: t("remind_attendees_to_bring_id"),
description: t("remind_attendees_to_bring_id_description"),
},
});
{
icon: "mail",
title: t("email_to_remind_booking"),
description: t("email_to_remind_booking_description"),
},
{
icon: "mail",
title: t("custom_email_reminder"),
description: t("custom_email_reminder_description"),
},
{
icon: "smartphone",
title: t("custom_sms_reminder"),
description: t("custom_sms_reminder_description"),
},
];
const workflowsExamples = [
{ icon: "smartphone", text: t("workflow_example_1") },
{ icon: "smartphone", text: t("workflow_example_2") },
{ icon: "mail", text: t("workflow_example_3") },
{ icon: "mail", text: t("workflow_example_4") },
{ icon: "mail", text: t("workflow_example_5") },
{ icon: "smartphone", text: t("workflow_example_6") },
] as const;
const calAITemplates: WorkflowExampleType[] = [
{
icon: "phone-outgoing",
title: t("call_to_confirm_booking"),
description: t("cal_ai_phone_call_action_description"),
iconWrapperClassName: "bg-[#2A2947]",
image: "/call-outgoing.svg",
},
{
icon: "phone-outgoing",
title: t("follow_up_with_no_shows"),
description: t("follow_up_with_no_shows_description"),
iconWrapperClassName: "bg-[#2A2947]",
image: "/call-outgoing.svg",
},
{
icon: "phone-outgoing",
title: t("remind_attendees_to_bring_id"),
description: t("remind_attendees_to_bring_id_description"),
iconWrapperClassName: "bg-[#2A2947]",
image: "/call-outgoing.svg",
},
];
// new workflow example when 'after meetings ends' trigger is implemented: Send custom thank you email to attendee after event (Smile icon),
if (props.isFilteredView) {
@@ -82,21 +125,42 @@ export default function EmptyScreen(props: { isFilteredView: boolean }) {
<div className="mt-8 ">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
createFunction={openDialog}
buttonText={t("create_workflow")}
isPending={createMutation.isPending}
includeOrg={true}
/>
</div>
</div>
</div>
<div className="flex flex-row items-center justify-center">
<div className="grid-cols-none items-center lg:grid lg:grid-cols-3 xl:mx-20">
{workflowsExamples.map((example, index) => (
<WorkflowExample key={index} Icon={example.icon} text={example.text} />
))}
</div>
<div className="flex flex-col gap-4">
<TemplateSection title={t("cal_ai_templates")} examples={calAITemplates} />
<TemplateSection title={t("standard_templates")} examples={workflowsExamples} />
</div>
<WorkflowCreationDialog open={showDialog} onOpenChange={setShowDialog} teamId={pendingTeamId} />
</>
);
}
const TemplateSection = ({ title, examples }: { title: string; examples: WorkflowExampleType[] }) => {
return (
<div className="flex items-center justify-center">
<div className="bg-muted flex max-w-5xl flex-col rounded-md p-2">
<h2 className="text-emphasis mb-2 text-base font-semibold">{title}</h2>
<div className="grid-cols-none items-center gap-2 lg:grid lg:grid-cols-3">
{examples.map((example: WorkflowExampleType, index: number) => (
<WorkflowExample
key={index}
icon={example.icon}
title={example.title}
description={example.description}
iconWrapperClassName={example.iconWrapperClassName}
image={example.image}
/>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,193 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import cn from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import type { IconName } from "@calcom/ui/components/icon";
import { RadioGroup, Radio, RadioIndicator } from "@calcom/ui/components/radio";
import { showToast } from "@calcom/ui/components/toast";
type WorkflowOptionCardProps = {
title: string;
description: string;
icon?: IconName;
isSelected: boolean;
onClick: () => void;
iconWrapperClassName?: string;
image?: string;
value: "scratch" | "calai";
};
function WorkflowOptionCard(props: WorkflowOptionCardProps) {
const { title, description, icon, isSelected, onClick, image, iconWrapperClassName, value } = props;
return (
<div
data-testid={`workflow-option-card-${value}`}
onClick={onClick}
className={cn(
`relative flex flex-1 cursor-pointer flex-col rounded-lg border p-4 transition-colors`,
isSelected ? "border-brand-default" : "border-subtle hover:border-emphasis"
)}>
<div className="absolute right-3 top-3">
<Radio value={value} aria-label={title}>
<RadioIndicator />
</Radio>
</div>
<div className="flex flex-col">
<div
className={cn(
"mb-5 flex h-10 w-10 items-center justify-center rounded-lg border",
iconWrapperClassName
)}>
{image ? (
<img
src={image}
alt=""
role="presentation"
aria-hidden="true"
className={cn("text-default h-5 w-5")}
/>
) : (
<Icon name={icon || "circle-plus"} className={cn("text-default h-5 w-5")} aria-hidden="true" />
)}
</div>
<h3 className="text-emphasis text-sm font-semibold">{title}</h3>
<p className="text-subtle text-sm">{description}</p>
</div>
</div>
);
}
type WorkflowCreationDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId?: number;
onSuccess?: () => void;
};
export function WorkflowCreationDialog({
open,
onOpenChange,
teamId,
onSuccess,
}: WorkflowCreationDialogProps) {
const { t } = useLocale();
const router = useRouter();
const [selectedOption, setSelectedOption] = useState<"scratch" | "calai">("scratch");
const [isLoading, setIsLoading] = useState(false);
const handleOptionChange = (value: string) => {
if (value === "scratch" || value === "calai") {
setSelectedOption(value);
}
};
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
onOpenChange(false);
if (onSuccess) {
onSuccess();
} else {
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("unauthorized_create_workflow")}`;
showToast(message, "error");
}
},
});
const handleContinue = () => {
if (selectedOption === "scratch") {
createMutation.mutate({ teamId });
} else if (selectedOption === "calai") {
setIsLoading(true);
const queryParams = new URLSearchParams({
action: "calAi",
templateWorkflowId: "wf-11",
...(teamId && { teamId: teamId.toString() }),
});
router.push(`/workflow/new?${queryParams.toString()}`);
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
title={t("new_workflow_btn")}
description={t("how_would_you_like_to_start")}
type="creation"
enableOverflow
Icon="zap">
<RadioGroup
value={selectedOption}
onValueChange={handleOptionChange}
aria-label={t("new_workflow_btn")}>
<div className="mt-6 flex gap-4">
<WorkflowOptionCard
title={t("start_from_scratch_title")}
description={t("start_from_scratch_description")}
icon="circle-plus"
isSelected={selectedOption === "scratch"}
onClick={() => setSelectedOption("scratch")}
iconWrapperClassName="bg-muted"
value="scratch"
/>
<WorkflowOptionCard
title={t("cal_ai_template_title")}
description={t("cal_ai_template_description")}
image="/call-outgoing.svg"
isSelected={selectedOption === "calai"}
onClick={() => setSelectedOption("calai")}
iconWrapperClassName="bg-[#2A2947]"
value="calai"
/>
</div>
</RadioGroup>
<DialogFooter className="relative" showDivider>
<Button type="button" color="minimal" onClick={() => onOpenChange(false)}>
{t("cancel")}
</Button>
<Button
data-testid="continue-button"
type="button"
onClick={handleContinue}
loading={createMutation.isPending || isLoading}>
{t("continue")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function useWorkflowCreation() {
const [showDialog, setShowDialog] = useState(false);
const [pendingTeamId, setPendingTeamId] = useState<number | undefined>(undefined);
const openDialog = (teamId?: number) => {
setPendingTeamId(teamId);
setShowDialog(true);
};
return {
showDialog,
setShowDialog,
pendingTeamId,
openDialog,
};
}
@@ -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) {
</div>
{isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && (
<div className="bg-muted border-muted mt-2 rounded-2xl border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
<div>
<h2 className="text-emphasis text-sm font-medium leading-none">
{t("cal_ai_agent")}
@@ -757,34 +777,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
<Button
color="primary"
disabled={props.readOnly}
onClick={async () => {
// save the workflow first to get the step id
if (onSaveWorkflow) {
await onSaveWorkflow();
// After saving, get the updated step ID from the form
const updatedSteps = form.getValues("steps");
const currentStepIndex = step.stepNumber - 1;
const updatedStep = updatedSteps[currentStepIndex];
// Ensure the action is still set correctly after save
if (updatedStep.action !== WorkflowActions.CAL_AI_PHONE_CALL) {
form.setValue(`steps.${currentStepIndex}.action`, WorkflowActions.CAL_AI_PHONE_CALL);
}
if (updatedStep && updatedStep.id) {
// Create agent with the workflow step ID
createAgentMutation.mutate({
teamId,
workflowStepId: updatedStep.id,
});
} else {
showToast(t("failed_to_get_workflow_step_id"), "error");
}
}
}}
loading={createAgentMutation.isPending}>
disabled={props.readOnly || isCreatingAgent.current || hasAutoCreated.current}
className="flex items-center justify-center"
onClick={() => handleCreateAgent()}
loading={createAgentMutation.isPending || isCreatingAgent.current}>
{t("set_up_agent")}
</Button>
</div>
@@ -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),
};
});
}
+5 -25
View File
@@ -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 (
<Shell withoutMain>
<LicenseRequired>
@@ -77,10 +60,7 @@ function WorkflowsPage({ filteredList }: PageProps) {
session.data?.hasValidLicense ? (
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => {
createMutation.mutate({ teamId });
}}
isPending={createMutation.isPending}
createFunction={openDialog}
disableMobileButton={true}
onlyShowWithNoTeams={true}
includeOrg={true}
@@ -98,8 +78,7 @@ function WorkflowsPage({ filteredList }: PageProps) {
<div className="mb-4 ml-auto">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isPending={createMutation.isPending}
createFunction={openDialog}
disableMobileButton={true}
onlyShowWithTeams={true}
includeOrg={true}
@@ -120,6 +99,7 @@ function WorkflowsPage({ filteredList }: PageProps) {
</FilterResults>
</>
</ShellMain>
<WorkflowCreationDialog open={showDialog} onOpenChange={setShowDialog} teamId={pendingTeamId} />
</LicenseRequired>
</Shell>
);
@@ -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<FormValues>({
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) {
+59
View File
@@ -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 (
<div className="bg-launch-dark relative mb-6 w-full overflow-hidden rounded-lg text-white">
<button
onClick={handleDismiss}
className="bg-brand-default absolute right-2 top-2 z-10 rounded-[10px] p-2"
aria-label="Close banner">
<Icon name="x" className="h-4 w-4 text-white" />
</button>
<div className="flex flex-col md:flex-row">
<div className="flex-1 p-6 pr-6 lg:p-8">
<h2 className="font-cal font-subtle text-lg">{t("supercharge_your_workflows_with_cal_ai")}</h2>
<p className="text-subtle mb-4 text-sm">
{t("supercharge_your_workflows_with_cal_ai_description")}
</p>
<button
onClick={handleTryNow}
className="rounded-[10px] bg-gradient-to-b from-[#6349EA] to-[#875FE0] px-5 py-2 text-sm font-medium text-white hover:from-[#5A42D1] hover:to-[#7B6FD7]">
{t("try_now")}
</button>
</div>
<img src="/cal-ai-banner.svg" alt="Cal.ai Banner" className="mt-auto hidden h-40 md:block" />
</div>
</div>
);
}
+2
View File
@@ -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}
<div className="max-w-full p-2 sm:py-4 lg:px-6">
<CalAiBanner />
<ErrorBoundary>
{!props.withoutMain ? <ShellMain {...props}>{props.children}</ShellMain> : props.children}
</ErrorBoundary>
@@ -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<{
) : (
<SkeletonText className="h-[20px] w-full" />
)}
{item.name === "workflows" && (
<Badge startIcon="sparkles" variant="purple">
Cal.ai
</Badge>
)}
</Link>
</Tooltip>
)}
+77 -42
View File
@@ -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<typeof tips>(() => {
const [list, setList] = useState<Tip[]>(() => {
if (typeof window === "undefined") {
return reversedTips;
}
@@ -186,49 +207,63 @@ function Tips() {
const baseOriginalList = list.slice(0).reverse();
return (
<div
className="hidden pb-4 pt-8 lg:grid"
/* ref={animationRef} */
style={{
gridTemplateColumns: "1fr",
}}>
{list.map((tip) => {
const isTopTip = baseOriginalList.indexOf(tip) === 0;
return (
<div
className="relative"
style={{
gridRowStart: 1,
gridColumnStart: 1,
}}
key={tip.id}>
<>
<div
className="hidden pb-4 pt-8 lg:grid"
/* ref={animationRef} */
style={{
gridTemplateColumns: "1fr",
}}>
{list.map((tip) => {
const isTopTip = baseOriginalList.indexOf(tip) === 0;
const isCalAIWorkflowsTip = tip.title === "cal_ai_workflows";
return (
<div
className="relative"
style={{
transform: `scale(${1 - baseOriginalList.indexOf(tip) / 20})`,
top: -baseOriginalList.indexOf(tip) * 10,
opacity: `${1 - baseOriginalList.indexOf(tip) / 7}`,
}}>
<Card
variant="SidebarCard"
thumbnailUrl={tip.thumbnailUrl}
mediaLink={isTopTip ? tip.mediaLink : undefined}
title={tip.title}
description={tip.description}
learnMore={isTopTip ? { href: tip.href, text: t("learn_more") } : undefined}
actionButton={
isTopTip ? { onClick: () => handleRemoveItem(tip.id), child: t("dismiss") } : undefined
}
containerProps={{
tabIndex: isTopTip ? undefined : -1,
"aria-hidden": isTopTip ? undefined : "true",
}}
/>
gridRowStart: 1,
gridColumnStart: 1,
}}
key={tip.id}>
<div
className="relative"
style={{
transform: `scale(${1 - baseOriginalList.indexOf(tip) / 20})`,
top: -baseOriginalList.indexOf(tip) * 10,
opacity: `${1 - baseOriginalList.indexOf(tip) / 7}`,
}}>
<Card
variant={tip.variant ?? "SidebarCard"}
thumbnailUrl={tip.thumbnailUrl}
coverPhoto={tip.coverPhoto}
mediaLink={isTopTip ? tip.mediaLink : undefined}
title={t(tip.title)}
description={t(tip.description)}
learnMore={
isTopTip
? {
href: isCalAIWorkflowsTip
? "/workflow/new?action=calAi&templateWorkflowId=wf-11"
: tip.href,
text: isCalAIWorkflowsTip ? t("try_now") : t("learn_more"),
}
: undefined
}
actionButton={
isTopTip ? { onClick: () => handleRemoveItem(tip.id), child: t("dismiss") } : undefined
}
containerProps={{
tabIndex: isTopTip ? undefined : -1,
"aria-hidden": isTopTip ? undefined : "true",
}}
buttonClassName={isCalAIWorkflowsTip ? "text-white" : undefined}
/>
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
</>
);
}
+45 -13
View File
@@ -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 (
<div
className={classNames(
containerProps?.className,
cvaCardTypeByVariant({ variant, structure: "card" }),
"bg-default border-subtle text-default flex flex-col justify-between rounded-md border"
"border-subtle text-default bg-default flex flex-col justify-between rounded-md border",
cvaCardTypeByVariant({ variant, structure: "card" })
)}
data-testid="card-container"
{...containerProps}>
@@ -158,8 +185,8 @@ export function Card({
<h5
title={title}
className={classNames(
cvaCardTypeByVariant({ variant, structure: "title" }),
"text-emphasis line-clamp-1 font-bold leading-5"
"text-emphasis line-clamp-1 font-bold leading-5",
cvaCardTypeByVariant({ variant, structure: "title" })
)}>
{title}
</h5>
@@ -171,11 +198,11 @@ export function Card({
</p>
)}
</div>
{variant === "SidebarCard" && (
{variant === "SidebarCard" && mediaLink && (
<a
onClick={actionButton?.onClick}
target="_blank"
rel="noreferrer"
rel="noreferrer noopener"
href={mediaLink}
data-testid={actionButton?.["data-testid"]}
className="group relative my-3 flex aspect-video items-center overflow-hidden rounded">
@@ -201,6 +228,9 @@ export function Card({
<img alt="play feature video" src={thumbnailUrl} />
</a>
)}
{variant === "NewLaunchSidebarCard" && coverPhoto && (
<img alt="cover" className="mt-3 w-full" src={coverPhoto} />
)}
{/* TODO: this should be CardActions https://mui.com/material-ui/api/card-actions/ */}
{variant === "basic" && actionButton && (
@@ -216,21 +246,23 @@ export function Card({
</div>
)}
{variant === "SidebarCard" && (
{(variant === "SidebarCard" || variant === "NewLaunchSidebarCard") && (
<div className="mt-2 flex items-center justify-between">
{learnMore && (
<LinkComponent
href={learnMore.href}
onClick={actionButton?.onClick}
target="_blank"
rel="noreferrer"
className="text-default text-xs font-medium">
className={classNames("text-default text-xs font-medium", buttonClassName)}>
{learnMore.text}
</LinkComponent>
)}
{actionButton?.child && (
<button
className="text-default hover:text-emphasis p-0 text-xs font-normal"
className={classNames(
"text-default hover:text-emphasis p-0 text-xs font-normal",
buttonClassName
)}
color="minimal"
data-testid={actionButton?.["data-testid"]}
onClick={actionButton?.onClick}>
@@ -3,7 +3,7 @@ import { components as reactSelectComponents } from "react-select";
import classNames from "@calcom/ui/classNames";
import { CreditsBadge, UpgradeTeamsBadge } from "../../badge";
import { Badge, CreditsBadge, UpgradeTeamsBadge } from "../../badge";
import { Icon } from "../../icon";
import type { SelectProps } from "./types";
@@ -32,6 +32,7 @@ type ExtendedOption = {
label: string;
needsTeamsUpgrade?: boolean;
needsCredits?: boolean;
isCalAi?: boolean;
creditsTeamId?: number;
isOrganization?: boolean;
};
@@ -48,6 +49,13 @@ export const OptionComponent = <
<reactSelectComponents.Option {...props}>
<div className="flex items-center justify-between">
<span className="w-full" data-testid={`select-option-${(props as unknown as ExtendedOption).value}`}>
{(props.data as unknown as ExtendedOption).isCalAi ? (
<Badge startIcon="sparkles" variant="purple" className="mr-1 hidden md:inline-flex">
Cal.ai
</Badge>
) : (
<></>
)}
{props.label || <>&nbsp;</>}
</span>
{(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? (
@@ -42,6 +42,7 @@ export const lucideIconList = new Set([
"circle-check-big",
"circle-check",
"circle-help",
"circle-plus",
"circle-x",
"circle",
"clipboard-check",
@@ -99,6 +100,7 @@ export const lucideIconList = new Set([
"paperclip",
"pencil",
"phone-call",
"phone-outgoing",
"phone",
"plus",
"refresh-ccw",
@@ -45,6 +45,7 @@ export type IconName =
| "circle-check-big"
| "circle-check"
| "circle-help"
| "circle-plus"
| "circle-x"
| "circle"
| "clipboard-check"
@@ -107,6 +108,7 @@ export type IconName =
| "pencil"
| "phone-call"
| "phone-off"
| "phone-outgoing"
| "phone"
| "plus"
| "refresh-ccw"
+1 -1
View File
@@ -153,7 +153,7 @@ const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
return (
<div
className={classNames(
"border-muted x flex flex-col-reverse border-t pt-4 sm:flex-row sm:justify-end sm:space-x-2",
"border-muted flex flex-col-reverse gap-2 border-t pt-4 sm:flex-row sm:justify-end",
className
)}
{...props}