Skip to content

Commit 6ddc666

Browse files
authored
feat: onboarding v3 - app install flow personal (calcom#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
1 parent 01346ab commit 6ddc666

7 files changed

Lines changed: 307 additions & 195 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck";
2+
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
3+
import { useLocale } from "@calcom/lib/hooks/useLocale";
4+
import type { App } from "@calcom/types/App";
5+
import { Button } from "@calcom/ui/components/button";
6+
7+
type AppData = {
8+
slug: string;
9+
name: string;
10+
description: string;
11+
logo: string;
12+
type: App["type"];
13+
userCredentialIds: number[];
14+
};
15+
16+
type InstallableAppCardProps = {
17+
app: AppData;
18+
isInstalling: boolean;
19+
onInstallClick: (appSlug: string) => void;
20+
installOptions: UseAddAppMutationOptions;
21+
};
22+
23+
export const InstallableAppCard = ({
24+
app,
25+
isInstalling,
26+
onInstallClick,
27+
installOptions,
28+
}: InstallableAppCardProps) => {
29+
const { t } = useLocale();
30+
const isInstalled = app.userCredentialIds && app.userCredentialIds.length > 0;
31+
32+
return (
33+
<div className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
34+
{isInstalled && (
35+
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
36+
{t("connected")}
37+
</span>
38+
)}
39+
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
40+
<p
41+
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
42+
title={app.name}>
43+
{app.name}
44+
</p>
45+
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">{app.description}</p>
46+
<InstallAppButtonWithoutPlanCheck
47+
type={app.type}
48+
options={installOptions}
49+
render={(buttonProps) => (
50+
<Button
51+
{...buttonProps}
52+
color="secondary"
53+
disabled={isInstalled}
54+
type="button"
55+
loading={isInstalling || buttonProps?.loading}
56+
className="mt-auto w-full items-center justify-center rounded-[10px]"
57+
onClick={(event) => {
58+
// Save cookie to return to this page after OAuth
59+
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
60+
onInstallClick(app.slug);
61+
buttonProps?.onClick?.(event);
62+
}}>
63+
{isInstalled ? t("connected") : t("connect")}
64+
</Button>
65+
)}
66+
/>
67+
</div>
68+
);
69+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ReactNode } from "react";
2+
3+
import { SkeletonText } from "@calcom/ui/components/skeleton";
4+
5+
type OnboardingCardProps = {
6+
title: string;
7+
subtitle: string;
8+
children: ReactNode;
9+
footer: ReactNode;
10+
isLoading?: boolean;
11+
};
12+
13+
export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }: OnboardingCardProps) => {
14+
return (
15+
<div className="bg-muted border-muted relative rounded-xl border p-1">
16+
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
17+
{/* Card Header */}
18+
<div className="flex w-full gap-1.5 px-5 py-4">
19+
<div className="flex w-full flex-col gap-1">
20+
<h1 className="font-cal text-xl font-semibold leading-6">{title}</h1>
21+
<p className="text-subtle text-sm font-medium leading-tight">{subtitle}</p>
22+
</div>
23+
</div>
24+
25+
{/* Content */}
26+
<div className="flex w-full flex-col gap-4 px-5 py-5">
27+
{isLoading ? (
28+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
29+
<SkeletonText className="h-40 w-full" />
30+
<SkeletonText className="h-40 w-full" />
31+
</div>
32+
) : (
33+
children
34+
)}
35+
</div>
36+
37+
{/* Footer */}
38+
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">{footer}</div>
39+
</div>
40+
</div>
41+
);
42+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { ReactNode } from "react";
2+
3+
import { Logo } from "@calcom/ui/components/logo";
4+
5+
type OnboardingLayoutProps = {
6+
userEmail: string;
7+
currentStep: 1 | 2 | 3 | 4;
8+
children: ReactNode;
9+
};
10+
11+
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
12+
return (
13+
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
14+
{/* Header */}
15+
<div className="flex w-full items-center justify-between px-6 py-4">
16+
<Logo className="h-5 w-auto" />
17+
18+
{/* Progress dots - centered */}
19+
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
20+
{[1, 2, 3, 4].map((step) => (
21+
<div
22+
key={step}
23+
className={`bg-${step <= currentStep ? "emphasis" : "subtle"} ${
24+
step === currentStep ? "h-1.5 w-1.5" : "h-1 w-1"
25+
} rounded-full`}
26+
/>
27+
))}
28+
</div>
29+
30+
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
31+
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
32+
</div>
33+
</div>
34+
35+
{/* Main content */}
36+
<div className="flex h-full w-full items-start justify-center px-6 py-8">
37+
<div className="flex w-full max-w-[600px] flex-col gap-4">{children}</div>
38+
</div>
39+
</div>
40+
);
41+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useLocale } from "@calcom/lib/hooks/useLocale";
2+
3+
type SkipButtonProps = {
4+
onClick: () => void;
5+
disabled?: boolean;
6+
};
7+
8+
export const SkipButton = ({ onClick, disabled }: SkipButtonProps) => {
9+
const { t } = useLocale();
10+
11+
return (
12+
<div className="flex w-full justify-center">
13+
<button
14+
onClick={onClick}
15+
disabled={disabled}
16+
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
17+
{t("ill_do_this_later")}
18+
</button>
19+
</div>
20+
);
21+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useState } from "react";
2+
3+
import { useLocale } from "@calcom/lib/hooks/useLocale";
4+
import { trpc } from "@calcom/trpc/react";
5+
import { showToast } from "@calcom/ui/components/toast";
6+
7+
type InstallSuccessCallback = (appSlug: string) => void;
8+
9+
export const useAppInstallation = () => {
10+
const { t } = useLocale();
11+
const utils = trpc.useUtils();
12+
const [installingAppSlug, setInstallingAppSlug] = useState<string | null>(null);
13+
14+
const createInstallHandlers = (appSlug: string, onSuccess?: InstallSuccessCallback) => {
15+
return {
16+
onSuccess: () => {
17+
setInstallingAppSlug(null);
18+
utils.viewer.apps.integrations.invalidate();
19+
showToast(t("app_successfully_installed"), "success");
20+
21+
// Call custom success callback if provided
22+
onSuccess?.(appSlug);
23+
},
24+
onError: (error: unknown) => {
25+
setInstallingAppSlug(null);
26+
if (error instanceof Error) {
27+
showToast(error.message || t("app_could_not_be_installed"), "error");
28+
}
29+
},
30+
};
31+
};
32+
33+
return {
34+
installingAppSlug,
35+
setInstallingAppSlug,
36+
createInstallHandlers,
37+
};
38+
};

apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx

Lines changed: 30 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import { useRouter } from "next/navigation";
55
import { useLocale } from "@calcom/lib/hooks/useLocale";
66
import { trpc } from "@calcom/trpc/react";
77
import { Button } from "@calcom/ui/components/button";
8-
import { Logo } from "@calcom/ui/components/logo";
9-
import { SkeletonText } from "@calcom/ui/components/skeleton";
8+
9+
import { InstallableAppCard } from "../_components/InstallableAppCard";
10+
import { OnboardingCard } from "../_components/OnboardingCard";
11+
import { OnboardingLayout } from "../_components/OnboardingLayout";
12+
import { SkipButton } from "../_components/SkipButton";
13+
import { useAppInstallation } from "../_components/useAppInstallation";
1014

1115
type PersonalCalendarViewProps = {
1216
userEmail: string;
@@ -15,6 +19,7 @@ type PersonalCalendarViewProps = {
1519
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
1620
const router = useRouter();
1721
const { t } = useLocale();
22+
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
1823

1924
const queryIntegrations = trpc.viewer.apps.integrations.useQuery({
2025
variant: "calendar",
@@ -32,93 +37,30 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
3237
};
3338

3439
return (
35-
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
36-
{/* Header */}
37-
<div className="flex w-full items-center justify-between px-6 py-4">
38-
<Logo className="h-5 w-auto" />
39-
40-
{/* Progress dots - centered */}
41-
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
42-
<div className="bg-emphasis h-1 w-1 rounded-full" />
43-
<div className="bg-emphasis h-1 w-1 rounded-full" />
44-
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
45-
<div className="bg-subtle h-1 w-1 rounded-full" />
46-
</div>
47-
48-
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
49-
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
40+
<OnboardingLayout userEmail={userEmail} currentStep={3}>
41+
<OnboardingCard
42+
title={t("connect_your_calendar")}
43+
subtitle={t("connect_calendar_to_prevent_conflicts")}
44+
isLoading={queryIntegrations.isPending}
45+
footer={
46+
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
47+
{t("continue")}
48+
</Button>
49+
}>
50+
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
51+
{queryIntegrations.data?.items?.map((app) => (
52+
<InstallableAppCard
53+
key={app.slug}
54+
app={app}
55+
isInstalling={installingAppSlug === app.slug}
56+
onInstallClick={setInstallingAppSlug}
57+
installOptions={createInstallHandlers(app.slug)}
58+
/>
59+
))}
5060
</div>
51-
</div>
61+
</OnboardingCard>
5262

53-
{/* Main content */}
54-
<div className="flex h-full w-full items-start justify-center px-6 py-8">
55-
<div className="flex w-full max-w-[600px] flex-col gap-4">
56-
{/* Card */}
57-
<div className="bg-muted border-muted relative rounded-xl border p-1">
58-
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
59-
{/* Card Header */}
60-
<div className="flex w-full gap-1.5 px-5 py-4">
61-
<div className="flex w-full flex-col gap-1">
62-
<h1 className="font-cal text-xl font-semibold leading-6">{t("connect_your_calendar")}</h1>
63-
<p className="text-subtle text-sm font-medium leading-tight">
64-
{t("connect_calendar_to_prevent_conflicts")}
65-
</p>
66-
</div>
67-
</div>
68-
69-
{/* Content */}
70-
<div className="flex w-full flex-col gap-4 px-5 py-5">
71-
{queryIntegrations.isPending ? (
72-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
73-
<SkeletonText className="h-40 w-full" />
74-
<SkeletonText className="h-40 w-full" />
75-
</div>
76-
) : (
77-
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
78-
{queryIntegrations.data?.items.map((app) => (
79-
<div
80-
key={app.slug}
81-
className="border-subtle bg-default flex flex-col items-start gap-4 rounded-xl border p-5">
82-
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
83-
<p
84-
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
85-
title={app.name}>
86-
{app.name}
87-
</p>
88-
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
89-
{app.description}
90-
</p>
91-
<Button
92-
color="secondary"
93-
href={`/apps/${app.slug}`}
94-
className="mt-auto w-full items-center justify-center rounded-[10px]">
95-
{t("connect")}
96-
</Button>
97-
</div>
98-
))}
99-
</div>
100-
)}
101-
</div>
102-
103-
{/* Footer */}
104-
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
105-
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
106-
{t("continue")}
107-
</Button>
108-
</div>
109-
</div>
110-
</div>
111-
112-
{/* Skip button */}
113-
<div className="flex w-full justify-center">
114-
<button
115-
onClick={handleSkip}
116-
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
117-
{t("ill_do_this_later")}
118-
</button>
119-
</div>
120-
</div>
121-
</div>
122-
</div>
63+
<SkipButton onClick={handleSkip} />
64+
</OnboardingLayout>
12365
);
12466
};

0 commit comments

Comments
 (0)