From 6ddc666827a6477e8d6213b1d9f6dff18f394f33 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:01:09 +0000 Subject: [PATCH] feat: onboarding v3 - app install flow personal (#24710) ## What does this PR do? Refactors the personal onboarding flow by creating reusable components for better code organization and maintainability. The changes include: - Creates shared components for the onboarding experience: `OnboardingLayout`, `OnboardingCard`, `InstallableAppCard`, and `SkipButton` - Implements a custom hook `useAppInstallation` to manage app installation state and callbacks - Enhances the video app connection step to automatically set the first connected video app as default - Improves the calendar connection flow with proper installation handling - Adds visual indicators for connected apps ## Visual Demo (For contributors especially) The refactored components maintain the same visual appearance while improving code structure and reusability. ## Mandatory Tasks - [x] I have self-reviewed the code - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? - Go through the personal onboarding flow - Test connecting calendar apps in step 3 - Test connecting video apps in step 4 - Verify that the first connected video app is automatically set as default - Check that connected apps show the "connected" indicator - Verify that skipping steps works correctly ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings --- .../_components/InstallableAppCard.tsx | 69 +++++++ .../personal/_components/OnboardingCard.tsx | 42 +++++ .../personal/_components/OnboardingLayout.tsx | 41 +++++ .../personal/_components/SkipButton.tsx | 21 +++ .../_components/useAppInstallation.ts | 38 ++++ .../calendar/personal-calendar-view.tsx | 118 +++--------- .../personal/video/personal-video-view.tsx | 171 +++++++----------- 7 files changed, 306 insertions(+), 194 deletions(-) create mode 100644 apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx create mode 100644 apps/web/modules/onboarding/personal/_components/OnboardingCard.tsx create mode 100644 apps/web/modules/onboarding/personal/_components/OnboardingLayout.tsx create mode 100644 apps/web/modules/onboarding/personal/_components/SkipButton.tsx create mode 100644 apps/web/modules/onboarding/personal/_components/useAppInstallation.ts diff --git a/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx b/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx new file mode 100644 index 0000000000..3c06e6d7d6 --- /dev/null +++ b/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx @@ -0,0 +1,69 @@ +import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck"; +import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { App } from "@calcom/types/App"; +import { Button } from "@calcom/ui/components/button"; + +type AppData = { + slug: string; + name: string; + description: string; + logo: string; + type: App["type"]; + userCredentialIds: number[]; +}; + +type InstallableAppCardProps = { + app: AppData; + isInstalling: boolean; + onInstallClick: (appSlug: string) => void; + installOptions: UseAddAppMutationOptions; +}; + +export const InstallableAppCard = ({ + app, + isInstalling, + onInstallClick, + installOptions, +}: InstallableAppCardProps) => { + const { t } = useLocale(); + const isInstalled = app.userCredentialIds && app.userCredentialIds.length > 0; + + return ( +
+ {isInstalled && ( + + {t("connected")} + + )} + {app.logo && {app.name}} +

+ {app.name} +

+

{app.description}

+ ( + + )} + /> +
+ ); +}; diff --git a/apps/web/modules/onboarding/personal/_components/OnboardingCard.tsx b/apps/web/modules/onboarding/personal/_components/OnboardingCard.tsx new file mode 100644 index 0000000000..ea97f9e8b2 --- /dev/null +++ b/apps/web/modules/onboarding/personal/_components/OnboardingCard.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; + +import { SkeletonText } from "@calcom/ui/components/skeleton"; + +type OnboardingCardProps = { + title: string; + subtitle: string; + children: ReactNode; + footer: ReactNode; + isLoading?: boolean; +}; + +export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }: OnboardingCardProps) => { + return ( +
+
+ {/* Card Header */} +
+
+

{title}

+

{subtitle}

+
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ + +
+ ) : ( + children + )} +
+ + {/* Footer */} +
{footer}
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/personal/_components/OnboardingLayout.tsx b/apps/web/modules/onboarding/personal/_components/OnboardingLayout.tsx new file mode 100644 index 0000000000..23fa842640 --- /dev/null +++ b/apps/web/modules/onboarding/personal/_components/OnboardingLayout.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +import { Logo } from "@calcom/ui/components/logo"; + +type OnboardingLayoutProps = { + userEmail: string; + currentStep: 1 | 2 | 3 | 4; + children: ReactNode; +}; + +export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => { + return ( +
+ {/* Header */} +
+ + + {/* Progress dots - centered */} +
+ {[1, 2, 3, 4].map((step) => ( +
+ ))} +
+ +
+

{userEmail}

+
+
+ + {/* Main content */} +
+
{children}
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/personal/_components/SkipButton.tsx b/apps/web/modules/onboarding/personal/_components/SkipButton.tsx new file mode 100644 index 0000000000..b2d39ae99f --- /dev/null +++ b/apps/web/modules/onboarding/personal/_components/SkipButton.tsx @@ -0,0 +1,21 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +type SkipButtonProps = { + onClick: () => void; + disabled?: boolean; +}; + +export const SkipButton = ({ onClick, disabled }: SkipButtonProps) => { + const { t } = useLocale(); + + return ( +
+ +
+ ); +}; diff --git a/apps/web/modules/onboarding/personal/_components/useAppInstallation.ts b/apps/web/modules/onboarding/personal/_components/useAppInstallation.ts new file mode 100644 index 0000000000..798b070514 --- /dev/null +++ b/apps/web/modules/onboarding/personal/_components/useAppInstallation.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { showToast } from "@calcom/ui/components/toast"; + +type InstallSuccessCallback = (appSlug: string) => void; + +export const useAppInstallation = () => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [installingAppSlug, setInstallingAppSlug] = useState(null); + + const createInstallHandlers = (appSlug: string, onSuccess?: InstallSuccessCallback) => { + return { + onSuccess: () => { + setInstallingAppSlug(null); + utils.viewer.apps.integrations.invalidate(); + showToast(t("app_successfully_installed"), "success"); + + // Call custom success callback if provided + onSuccess?.(appSlug); + }, + onError: (error: unknown) => { + setInstallingAppSlug(null); + if (error instanceof Error) { + showToast(error.message || t("app_could_not_be_installed"), "error"); + } + }, + }; + }; + + return { + installingAppSlug, + setInstallingAppSlug, + createInstallHandlers, + }; +}; diff --git a/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx b/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx index 094dfbb9e9..c7b76a37bd 100644 --- a/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx +++ b/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx @@ -5,8 +5,12 @@ import { useRouter } from "next/navigation"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; -import { Logo } from "@calcom/ui/components/logo"; -import { SkeletonText } from "@calcom/ui/components/skeleton"; + +import { InstallableAppCard } from "../_components/InstallableAppCard"; +import { OnboardingCard } from "../_components/OnboardingCard"; +import { OnboardingLayout } from "../_components/OnboardingLayout"; +import { SkipButton } from "../_components/SkipButton"; +import { useAppInstallation } from "../_components/useAppInstallation"; type PersonalCalendarViewProps = { userEmail: string; @@ -15,6 +19,7 @@ type PersonalCalendarViewProps = { export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => { const router = useRouter(); const { t } = useLocale(); + const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation(); const queryIntegrations = trpc.viewer.apps.integrations.useQuery({ variant: "calendar", @@ -32,93 +37,30 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) = }; return ( -
- {/* Header */} -
- - - {/* Progress dots - centered */} -
-
-
-
-
+ + + {t("continue")} + + }> +
+ {queryIntegrations.data?.items?.map((app) => ( + + ))}
+
-
-

{userEmail}

-
-
- - {/* Main content */} -
-
- {/* Card */} -
-
- {/* Card Header */} -
-
-

{t("connect_your_calendar")}

-

- {t("connect_calendar_to_prevent_conflicts")} -

-
-
- - {/* Content */} -
- {queryIntegrations.isPending ? ( -
- - -
- ) : ( -
- {queryIntegrations.data?.items.map((app) => ( -
- {app.logo && {app.name}} -

- {app.name} -

-

- {app.description} -

- -
- ))} -
- )} -
- - {/* Footer */} -
- -
-
-
- - {/* Skip button */} -
- -
-
-
-
+ + ); }; diff --git a/apps/web/modules/onboarding/personal/video/personal-video-view.tsx b/apps/web/modules/onboarding/personal/video/personal-video-view.tsx index 8a1809ac70..1fda826eb0 100644 --- a/apps/web/modules/onboarding/personal/video/personal-video-view.tsx +++ b/apps/web/modules/onboarding/personal/video/personal-video-view.tsx @@ -5,10 +5,16 @@ import { useRouter } from "next/navigation"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useTelemetry } from "@calcom/lib/hooks/useTelemetry"; import { telemetryEventTypes } from "@calcom/lib/telemetry"; +import { userMetadata } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; -import { Logo } from "@calcom/ui/components/logo"; -import { SkeletonText } from "@calcom/ui/components/skeleton"; +import { showToast } from "@calcom/ui/components/toast"; + +import { InstallableAppCard } from "../_components/InstallableAppCard"; +import { OnboardingCard } from "../_components/OnboardingCard"; +import { OnboardingLayout } from "../_components/OnboardingLayout"; +import { SkipButton } from "../_components/SkipButton"; +import { useAppInstallation } from "../_components/useAppInstallation"; type PersonalVideoViewProps = { userEmail: string; @@ -38,6 +44,8 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => { const router = useRouter(); const { t } = useLocale(); const telemetry = useTelemetry(); + const utils = trpc.useUtils(); + const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation(); const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({ variant: "conferencing", @@ -46,10 +54,19 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => { sortByInstalledFirst: true, }); + const setDefaultConferencingApp = trpc.viewer.apps.setDefaultConferencingApp.useMutation({ + onSuccess: async () => { + await utils.viewer.me.invalidate(); + }, + onError: (error) => { + showToast(t("something_went_wrong"), "error"); + console.error(error); + }, + }); + const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery(); const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation(); - const utils = trpc.useUtils(); const mutation = trpc.viewer.me.updateProfile.useMutation({ onSuccess: async () => { try { @@ -77,8 +94,6 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => { const handleContinue = async () => { telemetry.event(telemetryEventTypes.onboardingFinished); - - // Complete onboarding mutation.mutate({ completedOnboarding: true, }); @@ -86,118 +101,62 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => { const handleSkip = async () => { telemetry.event(telemetryEventTypes.onboardingFinished); - - // Complete onboarding mutation.mutate({ completedOnboarding: true, }); }; if (!user) { - return null; // or a loading spinner + return null; } + // Check if user has a default conferencing app + const result = userMetadata.safeParse(user.metadata); + const metadata = result?.success ? result.data : null; + const hasDefaultConferencingApp = !!metadata?.defaultConferencingApp?.appSlug; + return ( -
- {/* Header */} -
- + + + {t("finish_and_start")} + + }> +
+ {queryConnectedVideoApps?.items + .filter((app) => app.slug !== "daily-video") + .map((app) => { + const shouldAutoSetDefault = + !hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic"; - {/* Progress dots - centered */} -
-
-
-
-
+ return ( + { + // Auto-set as default if it's the first connected video app + if (shouldAutoSetDefault) { + setDefaultConferencingApp.mutate({ slug: appSlug }); + } + })} + /> + ); + })}
+ -
-

{userEmail}

-
-
- - {/* Main content */} -
-
- {/* Card */} -
-
- {/* Card Header */} -
-
-

{t("connect_video_app")}

-

- {t("video_app_connection_subtitle")} -

-
-
- - {/* Content */} -
- {isPending ? ( -
- - -
- ) : ( -
- {queryConnectedVideoApps?.items - .filter((app) => app.slug !== "daily-video") - .map((app) => ( -
- {app.userCredentialIds.length > 0 && ( - - {t("connected")} - - )} - {app.logo && {app.name}} -

- {app.name} -

-

- {app.description} -

- -
- ))} -
- )} -
- - {/* Footer */} -
- -
-
-
- - {/* Skip button */} -
- -
-
-
-
+ + ); };