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
This commit is contained in:
sean-brydon authored and GitHub committed 2025-10-27 15:01:09 +00:00
1 parent 01346abb85
commit 6ddc666827
7 files changed
+306 -194

No files matched your search

@@ -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 (
<div className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
{isInstalled && (
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
{t("connected")}
</span>
)}
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
<p
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
title={app.name}>
{app.name}
</p>
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">{app.description}</p>
<InstallAppButtonWithoutPlanCheck
type={app.type}
options={installOptions}
render={(buttonProps) => (
<Button
{...buttonProps}
color="secondary"
disabled={isInstalled}
type="button"
loading={isInstalling || buttonProps?.loading}
className="mt-auto w-full items-center justify-center rounded-[10px]"
onClick={(event) => {
// Save cookie to return to this page after OAuth
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
onInstallClick(app.slug);
buttonProps?.onClick?.(event);
}}>
{isInstalled ? t("connected") : t("connect")}
</Button>
)}
/>
</div>
);
};
@@ -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 (
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{title}</h1>
<p className="text-subtle text-sm font-medium leading-tight">{subtitle}</p>
</div>
</div>
{/* Content */}
<div className="flex w-full flex-col gap-4 px-5 py-5">
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
</div>
) : (
children
)}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">{footer}</div>
</div>
</div>
);
};
@@ -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 (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={`bg-${step <= currentStep ? "emphasis" : "subtle"} ${
step === currentStep ? "h-1.5 w-1.5" : "h-1 w-1"
} rounded-full`}
/>
))}
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">{children}</div>
</div>
</div>
);
};
@@ -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 (
<div className="flex w-full justify-center">
<button
onClick={onClick}
disabled={disabled}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
{t("ill_do_this_later")}
</button>
</div>
);
};
@@ -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<string | null>(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,
};
};
@@ -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 (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingCard
title={t("connect_your_calendar")}
subtitle={t("connect_calendar_to_prevent_conflicts")}
isLoading={queryIntegrations.isPending}
footer={
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
}>
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryIntegrations.data?.items?.map((app) => (
<InstallableAppCard
key={app.slug}
app={app}
isInstalling={installingAppSlug === app.slug}
onInstallClick={setInstallingAppSlug}
installOptions={createInstallHandlers(app.slug)}
/>
))}
</div>
</OnboardingCard>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("connect_your_calendar")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("connect_calendar_to_prevent_conflicts")}
</p>
</div>
</div>
{/* Content */}
<div className="flex w-full flex-col gap-4 px-5 py-5">
{queryIntegrations.isPending ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
</div>
) : (
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryIntegrations.data?.items.map((app) => (
<div
key={app.slug}
className="border-subtle bg-default flex flex-col items-start gap-4 rounded-xl border p-5">
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
<p
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
title={app.name}>
{app.name}
</p>
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
{app.description}
</p>
<Button
color="secondary"
href={`/apps/${app.slug}`}
className="mt-auto w-full items-center justify-center rounded-[10px]">
{t("connect")}
</Button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</div>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
{t("ill_do_this_later")}
</button>
</div>
</div>
</div>
</div>
<SkipButton onClick={handleSkip} />
</OnboardingLayout>
);
};
@@ -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 (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
<OnboardingLayout userEmail={userEmail} currentStep={4}>
<OnboardingCard
title={t("connect_video_app")}
subtitle={t("video_app_connection_subtitle")}
isLoading={isPending}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("finish_and_start")}
</Button>
}>
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryConnectedVideoApps?.items
.filter((app) => app.slug !== "daily-video")
.map((app) => {
const shouldAutoSetDefault =
!hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic";
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
return (
<InstallableAppCard
key={app.slug}
app={app}
isInstalling={installingAppSlug === app.slug}
onInstallClick={setInstallingAppSlug}
installOptions={createInstallHandlers(app.slug, (appSlug) => {
// Auto-set as default if it's the first connected video app
if (shouldAutoSetDefault) {
setDefaultConferencingApp.mutate({ slug: appSlug });
}
})}
/>
);
})}
</div>
</OnboardingCard>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("connect_video_app")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("video_app_connection_subtitle")}
</p>
</div>
</div>
{/* Content */}
<div className="flex w-full flex-col gap-4 px-5 py-5">
{isPending ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
</div>
) : (
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryConnectedVideoApps?.items
.filter((app) => app.slug !== "daily-video")
.map((app) => (
<div
key={app.slug}
className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
{app.userCredentialIds.length > 0 && (
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
{t("connected")}
</span>
)}
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
<p
className="text-default max-w- line-clamp-1 break-words text-left text-sm font-medium leading-none"
title={app.name}>
{app.name}
</p>
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
{app.description}
</p>
<Button
color="secondary"
href={`/apps/${app.slug}`}
className="mt-auto w-full items-center justify-center rounded-[10px]">
{t("connect")}
</Button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("finish_and_start")}
</Button>
</div>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
disabled={mutation.isPending}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
{t("ill_do_this_later")}
</button>
</div>
</div>
</div>
</div>
<SkipButton onClick={handleSkip} disabled={mutation.isPending} />
</OnboardingLayout>
);
};