Skip to content

Commit 19641ff

Browse files
authored
feat: organization v3 redesign onboarding (calcom#24967)
## What does this PR do? - Redesigns the organization onboarding flow by merging the brand and details pages - Improves the organization details page with a scrollable interface and visual previews - Adds a new organization-specific browser preview component ## Visual Demo (For contributors especially) #### Image Demo: - The PR replaces the separate brand page with an integrated details page that includes logo and banner uploads - The new organization browser view shows a preview of the organization profile with the selected branding ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code. - [ ] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox. - [ ] 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 organization onboarding flow - Test uploading logos and banners - Verify that the organization browser preview updates in real-time with the form inputs - Confirm that the form validation works correctly for organization name and slug - Check that the scrollable interface works properly with fade effects at top and bottom ## 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 is an auto-generated description by cubic. --> --- ## Summary by cubic Split organization brand from the details step and added live previews for organizations and teams. Revamped org/team invites with reusable components, a dedicated email-invite page, and a CSV upload modal. - **New Features** - Separate Brand step with logo, banner, and color; instant org preview via OnboardingOrganizationBrowserView. - Teams browser preview added; invites include email substep (/onboarding/organization/invite/email, /onboarding/teams/invite/email), CSV upload (template + parsing), and Google Workspace (behind flag). - Shared components (EmailInviteForm, InviteOptions, RoleSelector) used across org and team invites. - **Refactors** - Updated org flow: Details → Brand → Teams → Invites; OnboardingLayout now supports dynamic step counts (org=4, team=3, personal=2). - UI polish (OnboardingCard header padding) and org-specific previews now replace generic views across details/brand/invites/teams; ensured org welcome modal takes precedence over personal. <sup>Written for commit d9b55c0. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
1 parent ea80e8c commit 19641ff

26 files changed

Lines changed: 1399 additions & 638 deletions

apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createRouterCaller } from "app/_trpc/context";
21
import { _generateMetadata } from "app/_utils";
32
import { cookies, headers } from "next/headers";
43
import { redirect } from "next/navigation";
@@ -33,3 +32,4 @@ const ServerPage = async () => {
3332
};
3433

3534
export default ServerPage;
35+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { _generateMetadata } from "app/_utils";
2+
import { cookies, headers } from "next/headers";
3+
import { redirect } from "next/navigation";
4+
5+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
6+
import { APP_NAME } from "@calcom/lib/constants";
7+
8+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
9+
10+
import { OrganizationInviteEmailView } from "~/onboarding/organization/invite/email/organization-invite-email-view";
11+
12+
export const generateMetadata = async () => {
13+
return await _generateMetadata(
14+
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
15+
() => "",
16+
true,
17+
undefined,
18+
"/onboarding/organization/invite/email"
19+
);
20+
};
21+
22+
const ServerPage = async () => {
23+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
24+
25+
if (!session?.user?.id) {
26+
return redirect("/auth/login");
27+
}
28+
29+
const userEmail = session.user.email || "";
30+
31+
return <OrganizationInviteEmailView userEmail={userEmail} />;
32+
};
33+
34+
export default ServerPage;
35+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { _generateMetadata } from "app/_utils";
2+
import { cookies, headers } from "next/headers";
3+
import { redirect } from "next/navigation";
4+
5+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
6+
import { APP_NAME } from "@calcom/lib/constants";
7+
8+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
9+
10+
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
11+
12+
export const generateMetadata = async () => {
13+
return await _generateMetadata(
14+
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
15+
() => "",
16+
true,
17+
undefined,
18+
"/onboarding/teams/invite/email"
19+
);
20+
};
21+
22+
const ServerPage = async () => {
23+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
24+
25+
if (!session?.user?.id) {
26+
return redirect("/auth/login");
27+
}
28+
29+
const userEmail = session.user.email || "";
30+
31+
return <TeamInviteEmailView userEmail={userEmail} />;
32+
};
33+
34+
export default ServerPage;
35+
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"use client";
2+
3+
import { useFormContext } from "react-hook-form";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { Button } from "@calcom/ui/components/button";
7+
import { Label, TextField, Select } from "@calcom/ui/components/form";
8+
import { Icon } from "@calcom/ui/components/icon";
9+
10+
import type { InviteRole } from "../store/onboarding-store";
11+
12+
type BaseInviteFormData = {
13+
email: string;
14+
role: InviteRole;
15+
};
16+
17+
type InviteFormDataWithOptionalTeam = BaseInviteFormData & {
18+
team?: string;
19+
};
20+
21+
type InviteFormDataWithRequiredTeam = BaseInviteFormData & {
22+
team: string;
23+
};
24+
25+
type InviteFormData = InviteFormDataWithOptionalTeam | InviteFormDataWithRequiredTeam;
26+
27+
type EmailInviteFormProps = {
28+
fields: Array<{ id: string }>;
29+
append: (value: InviteFormData) => void;
30+
remove: (index: number) => void;
31+
defaultRole: InviteRole;
32+
showTeamSelect?: boolean;
33+
teams?: Array<{ value: string; label: string }>;
34+
emailPlaceholder?: string;
35+
};
36+
37+
export function EmailInviteForm({
38+
fields,
39+
append,
40+
remove,
41+
defaultRole,
42+
showTeamSelect = false,
43+
teams = [],
44+
emailPlaceholder,
45+
}: EmailInviteFormProps) {
46+
const { t } = useLocale();
47+
const { register, watch, setValue } = useFormContext();
48+
49+
return (
50+
<div className="flex w-full flex-col gap-4">
51+
<div className="flex flex-col gap-2">
52+
{showTeamSelect ? (
53+
<div className="grid grid-cols-2">
54+
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
55+
{t("email")}
56+
</Label>
57+
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.team">
58+
{t("team")}
59+
</Label>
60+
</div>
61+
) : (
62+
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
63+
)}
64+
65+
<div
66+
className={
67+
showTeamSelect ? "flex flex-col gap-2" : "scroll-bar flex max-h-72 flex-col gap-1 overflow-y-auto"
68+
}>
69+
{fields.map((field, index) => (
70+
<div key={field.id} className="flex items-start gap-0.5 p-0.5">
71+
<div className={showTeamSelect ? "grid flex-1 items-start gap-2 md:grid-cols-2" : "flex-1"}>
72+
<TextField
73+
labelSrOnly
74+
{...register(`invites.${index}.email`)}
75+
placeholder={emailPlaceholder || `rick@cal.com`}
76+
type="email"
77+
size="sm"
78+
/>
79+
{showTeamSelect && (
80+
<Select
81+
size="sm"
82+
options={teams}
83+
value={teams.find((t) => t.value === watch(`invites.${index}.team`))}
84+
onChange={(option) => {
85+
if (option) {
86+
setValue(`invites.${index}.team`, option.value);
87+
}
88+
}}
89+
placeholder={t("select_team")}
90+
/>
91+
)}
92+
</div>
93+
<Button
94+
type="button"
95+
color="minimal"
96+
variant="icon"
97+
size="sm"
98+
className="h-7 w-7"
99+
disabled={fields.length === 1}
100+
onClick={() => remove(index)}>
101+
<Icon name="x" className="h-4 w-4" />
102+
</Button>
103+
</div>
104+
))}
105+
</div>
106+
107+
<Button
108+
type="button"
109+
color="secondary"
110+
size="sm"
111+
StartIcon="plus"
112+
className={showTeamSelect ? "mt-2 w-fit" : "w-fit"}
113+
onClick={() =>
114+
append(
115+
showTeamSelect ? { email: "", team: "", role: defaultRole } : { email: "", role: defaultRole }
116+
)
117+
}>
118+
{t("add")}
119+
</Button>
120+
</div>
121+
</div>
122+
);
123+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"use client";
2+
3+
import { useFlags } from "@calcom/features/flags/hooks";
4+
import { useLocale } from "@calcom/lib/hooks/useLocale";
5+
import { Button } from "@calcom/ui/components/button";
6+
import { Icon } from "@calcom/ui/components/icon";
7+
8+
const GoogleIcon = () => (
9+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
10+
<g clipPath="url(#clip0_4178_176214)">
11+
<path
12+
d="M8.31875 15.36C4.26 15.36 0.9575 12.0588 0.9575 8.00001C0.9575 3.94126 4.26 0.640015 8.31875 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.2525 4.07376 8.31875 4.07376C6.15375 4.07376 4.39125 5.83501 4.39125 8.00001C4.39125 10.165 6.15375 11.9263 8.31875 11.9263C9.88 11.9263 11.1138 11.1288 11.695 9.77001H7.99875V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31875 15.36Z"
13+
fill="#6B7280"
14+
/>
15+
</g>
16+
<defs>
17+
<clipPath id="clip0_4178_176214">
18+
<rect width="16" height="16" fill="white" />
19+
</clipPath>
20+
</defs>
21+
</svg>
22+
);
23+
24+
type InviteOptionsProps = {
25+
onInviteViaEmail: () => void;
26+
onUploadCSV?: () => void;
27+
onCopyInviteLink?: () => void;
28+
onConnectGoogleWorkspace?: () => void;
29+
isSubmitting?: boolean;
30+
};
31+
32+
export const InviteOptions = ({
33+
onInviteViaEmail,
34+
onUploadCSV,
35+
onCopyInviteLink,
36+
onConnectGoogleWorkspace,
37+
isSubmitting = false,
38+
}: InviteOptionsProps) => {
39+
const { t } = useLocale();
40+
const flags = useFlags();
41+
const googleWorkspaceEnabled = flags["google-workspace-directory"];
42+
43+
return (
44+
<div className="flex h-full w-full flex-1 flex-col gap-6 ">
45+
{googleWorkspaceEnabled && onConnectGoogleWorkspace && (
46+
<>
47+
<Button
48+
color="secondary"
49+
className="h-8 w-full rounded-[10px]"
50+
CustomStartIcon={<GoogleIcon />}
51+
onClick={onConnectGoogleWorkspace}
52+
disabled={isSubmitting}>
53+
{t("connect_google_workspace")}
54+
</Button>
55+
56+
<div className="flex w-full items-center gap-2">
57+
<div className="border-subtle h-px flex-1 border-t" />
58+
<span className="text-subtle text-sm font-medium">{t("or")}</span>
59+
<div className="border-subtle h-px flex-1 border-t" />
60+
</div>
61+
</>
62+
)}
63+
64+
<div className="flex w-full flex-1 flex-col gap-4">
65+
<Button
66+
color="primary"
67+
className="h-8 w-full justify-center rounded-[10px]"
68+
onClick={onInviteViaEmail}
69+
disabled={isSubmitting}>
70+
<div className="flex items-center gap-1">
71+
<Icon name="mail" className="h-4 w-4" />
72+
<span>{t("invite_via_email")}</span>
73+
</div>
74+
</Button>
75+
76+
{onUploadCSV && (
77+
<Button
78+
color="secondary"
79+
className="h-8 w-full justify-center rounded-[10px]"
80+
onClick={onUploadCSV}
81+
disabled={isSubmitting}>
82+
<div className="flex items-center gap-1">
83+
<Icon name="upload" className="h-4 w-4" />
84+
<span>{t("upload_csv_file")}</span>
85+
</div>
86+
</Button>
87+
)}
88+
89+
{onCopyInviteLink && (
90+
<Button
91+
color="secondary"
92+
className="h-8 w-full justify-center rounded-[10px]"
93+
onClick={onCopyInviteLink}
94+
disabled>
95+
<div className="flex items-center gap-1">
96+
<Icon name="link" className="h-4 w-4" />
97+
<span>{t("copy_invite_link")}</span>
98+
</div>
99+
</Button>
100+
)}
101+
</div>
102+
</div>
103+
);
104+
};

apps/web/modules/onboarding/components/OnboardingCard.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
2222
</div>
2323

2424
{/* Content */}
25-
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
25+
<div className="flex h-full min-h-0 w-full flex-1 flex-col gap-4">
2626
{isLoading ? (
2727
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
2828
<SkeletonText className="h-40 w-full" />
@@ -38,4 +38,3 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
3838
</div>
3939
);
4040
};
41-

apps/web/modules/onboarding/components/OnboardingLayout.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import classNames from "classnames";
34
import { signOut } from "next-auth/react";
45
import { Children, type ReactNode } from "react";
56

@@ -9,11 +10,12 @@ import { Logo } from "@calcom/ui/components/logo";
910

1011
type OnboardingLayoutProps = {
1112
userEmail: string;
12-
currentStep: 1 | 2 | 3;
13+
currentStep: number;
14+
totalSteps: number;
1315
children: ReactNode;
1416
};
1517

16-
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
18+
export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children }: OnboardingLayoutProps) => {
1719
const { t } = useLocale();
1820

1921
const handleSignOut = () => {
@@ -42,17 +44,31 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
4244

4345
{/* Footer with progress dots and sign out */}
4446
<div className="flex w-full flex-col items-center justify-center gap-4 px-10 py-8">
45-
<div className="flex items-center gap-2">
46-
{[1, 2, 3].map((step) => (
47-
<div key={step} className="relative flex h-2 w-2 shrink-0 items-center justify-center">
48-
<div
49-
className={`absolute inset-0 rounded-full ${
50-
step <= currentStep ? "bg-emphasis" : "bg-muted"
51-
}`}
52-
/>
53-
{step <= currentStep && <div className="bg-emphasis absolute h-1 w-1 rounded-full" />}
54-
</div>
55-
))}
47+
<div className="flex items-center gap-3">
48+
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => {
49+
const isCurrentStep = step === currentStep;
50+
const isCompleted = step < currentStep;
51+
return (
52+
<div key={step} className="relative flex shrink-0 items-center justify-center">
53+
<div
54+
className={classNames("absolute rounded-full transition-all", {
55+
"h-2 w-2": !isCurrentStep,
56+
"h-3 w-3": isCurrentStep,
57+
"bg-emphasis": isCompleted || isCurrentStep,
58+
"bg-muted": !isCompleted && !isCurrentStep,
59+
})}
60+
/>
61+
{(isCompleted || isCurrentStep) && (
62+
<div
63+
className={classNames("bg-emphasis absolute rounded-full", {
64+
"h-1 w-1": !isCurrentStep,
65+
"h-1.5 w-1.5": isCurrentStep,
66+
})}
67+
/>
68+
)}
69+
</div>
70+
);
71+
})}
5672
</div>
5773
<Button onClick={handleSignOut} color="minimal" className="text-subtle h-7">
5874
{t("sign_out")}
@@ -61,4 +77,3 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
6177
</div>
6278
);
6379
};
64-

0 commit comments

Comments
 (0)