From 02b1393ff428cfbc7b8a9f7ebac3a74ab5f9e70c Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 7 Nov 2025 08:54:55 +0000 Subject: [PATCH] chore: Update personal and team onboarding flows (remove profile/video steps, add settings, improve team invites) (#24947) ## What does this PR do? - Redesigns and streamlines the onboarding flow for personal, team, and organization accounts - Consolidates shared components and improves code organization - Adds browser preview for better user experience during onboarding - Simplifies the personal onboarding flow by removing the video integration step - Enhances team onboarding with CSV upload functionality ## Visual Demo (For contributors especially) #### Image Demo: - The PR adds a new browser preview component that shows users how their profile/team will look during onboarding - Redesigned UI with a more consistent layout across all onboarding steps - Improved mobile responsiveness with better component organization ## Mandatory Tasks (DO NOT REMOVE) - [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. If N/A, write N/A here and check the checkbox. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Test the complete onboarding flow for personal accounts: - Start at `/onboarding/getting-started` - Proceed through personal details and calendar setup - Verify the flow completes successfully 2. Test the team onboarding flow: - Start at `/onboarding/getting-started` and select team - Complete team details - Test the invite options including CSV upload - Verify team creation works correctly 3. Test the organization onboarding flow: - Start at `/onboarding/getting-started` and select organization - Complete organization details and branding - Test member invitations - Verify organization creation works correctly 4. Verify browser preview functionality: - Check that the preview updates in real-time as you enter information - Confirm it displays correctly on different screen sizes ## 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 --- .../onboarding/getting-started/page.tsx | 4 +- .../onboarding/personal/profile/page.tsx | 4 +- .../onboarding/personal/video/page.tsx | 34 -- .../onboarding/teams/brand/page.tsx | 34 -- .../onboarding-calendar-browser-view.tsx | 92 ++++++ .../getting-started/onboarding-view.tsx | 112 ++++--- .../hooks/useSubmitPersonalOnboarding.ts | 80 +++++ .../brand/organization-brand-view.tsx | 71 +---- .../details/organization-details-view.tsx | 9 +- .../invite/organization-invite-view.tsx | 297 ++++++++--------- .../calendar/personal-calendar-view.tsx | 41 ++- .../profile/personal-profile-view.tsx | 182 ----------- .../settings/personal-settings-view.tsx | 212 ++++++++---- .../personal/video/personal-video-view.tsx | 162 ---------- .../onboarding/store/onboarding-store.ts | 2 + .../teams/brand/team-brand-view.tsx | 258 --------------- .../teams/details/team-details-view.tsx | 182 ++++++----- .../teams/details/validated-team-slug.tsx | 11 + .../teams/invite/csv-upload-modal.tsx | 245 ++++++++++++++ .../invite/email/team-invite-email-view.tsx | 188 +++++++++++ .../teams/invite/team-invite-view.tsx | 301 ++++++++---------- .../shell/hooks/useWelcomeToCalcomModal.ts | 51 +++ .../routers/viewer/teams/create.handler.ts | 3 +- .../routers/viewer/teams/create.schema.ts | 1 + 24 files changed, 1312 insertions(+), 1264 deletions(-) delete mode 100644 apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx delete mode 100644 apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx create mode 100644 apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx create mode 100644 apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts delete mode 100644 apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx delete mode 100644 apps/web/modules/onboarding/personal/video/personal-video-view.tsx delete mode 100644 apps/web/modules/onboarding/teams/brand/team-brand-view.tsx create mode 100644 apps/web/modules/onboarding/teams/invite/csv-upload-modal.tsx create mode 100644 apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx create mode 100644 packages/features/shell/hooks/useWelcomeToCalcomModal.ts diff --git a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx index 29ec2170dc..e96495d6a8 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx @@ -26,11 +26,9 @@ const ServerPage = async () => { return redirect("/auth/login"); } - // Hello {username} || there. Not sure how to do this nicely with i18n just yet - const userName = session.user.name || "there"; const userEmail = session.user.email || ""; - return ; + return ; }; export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx index dc70908423..da56b01820 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/personal/profile/page.tsx @@ -7,7 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; -import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view"; +import { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view"; export const generateMetadata = async () => { return await _generateMetadata( @@ -28,7 +28,7 @@ const ServerPage = async () => { const userEmail = session.user.email || ""; - return ; + return ; }; export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx deleted file mode 100644 index 1ec119859f..0000000000 --- a/apps/web/app/(use-page-wrapper)/onboarding/personal/video/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { _generateMetadata } from "app/_utils"; -import { cookies, headers } from "next/headers"; -import { redirect } from "next/navigation"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { APP_NAME } from "@calcom/lib/constants"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - -import { PersonalVideoView } from "~/onboarding/personal/video/personal-video-view"; - -export const generateMetadata = async () => { - return await _generateMetadata( - (t) => `${APP_NAME} - ${t("connect_video")}`, - () => "", - true, - undefined, - "/onboarding/personal/video" - ); -}; - -const ServerPage = async () => { - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - - if (!session?.user?.id) { - return redirect("/auth/login"); - } - - const userEmail = session.user.email || ""; - - return ; -}; - -export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx deleted file mode 100644 index 72f40aaa21..0000000000 --- a/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { _generateMetadata } from "app/_utils"; -import { cookies, headers } from "next/headers"; -import { redirect } from "next/navigation"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { APP_NAME } from "@calcom/lib/constants"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - -import { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view"; - -export const generateMetadata = async () => { - return await _generateMetadata( - (t) => `${APP_NAME} - ${t("team_brand")}`, - () => "", - true, - undefined, - "/onboarding/teams/brand" - ); -}; - -const ServerPage = async () => { - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - - if (!session?.user?.id) { - return redirect("/auth/login"); - } - - const userEmail = session.user.email || ""; - - return ; -}; - -export default ServerPage; diff --git a/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx new file mode 100644 index 0000000000..f45e562c50 --- /dev/null +++ b/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Icon, type IconName } from "@calcom/ui/components/icon"; + +export const OnboardingCalendarBrowserView = () => { + const { t } = useLocale(); + const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, ""); + + const calendarIntegrations: Array<{ + name: string; + description: string; + icon: IconName; + }> = [ + { + name: t("google_calendar"), + description: t("onboarding_calendar_browser_view_google_description"), + icon: "calendar", + }, + { + name: t("outlook_calendar"), + description: t("onboarding_calendar_browser_view_outlook_description"), + icon: "mail", + }, + { + name: t("apple_calendar"), + description: t("onboarding_calendar_browser_view_apple_description"), + icon: "calendar-days", + }, + ]; + + return ( +
+ {/* Browser header */} +
+ {/* Navigation buttons */} +
+ + + +
+
+ +

{webappUrl}/settings/calendars

+
+ +
+ {/* Content */} +
+
+ {/* Header */} +
+
+

+ {t("connect_your_calendar")} +

+

+ {t("onboarding_calendar_browser_view_subtitle")} +

+
+
+ + {/* Calendar Integrations List */} +
+ {calendarIntegrations.map((integration, index) => ( +
+ {index > 0 &&
} +
+
+
+ +
+
+

{integration.name}

+

+ {integration.description} +

+
+
+
+ {t("connected")} +
+
+
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx index 0fd6c51ebf..b41cbf0168 100644 --- a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx +++ b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx @@ -1,6 +1,8 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import { useRouter } from "next/navigation"; +import { useEffect, useRef } from "react"; import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -10,20 +12,43 @@ import { Button } from "@calcom/ui/components/button"; import { type IconName } from "@calcom/ui/components/icon"; import { RadioAreaGroup } from "@calcom/ui/components/radio"; +import { OnboardingCard } from "../components/OnboardingCard"; import { OnboardingLayout } from "../components/OnboardingLayout"; import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt"; import { PlanIcon } from "../components/plan-icon"; import { useOnboardingStore, type PlanType } from "../store/onboarding-store"; type OnboardingViewProps = { - userName: string; userEmail: string; }; -export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => { +export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { const router = useRouter(); const { t } = useLocale(); const { selectedPlan, setSelectedPlan } = useOnboardingStore(); + const previousPlanRef = useRef(null); + + // Plan order mapping for determining direction + const planOrder: Record = { + personal: 0, + team: 1, + organization: 2, + }; + + // Calculate animation direction synchronously + const getDirection = (): "up" | "down" => { + if (!selectedPlan || !previousPlanRef.current) return "down"; + const previousOrder = planOrder[previousPlanRef.current]; + const currentOrder = planOrder[selectedPlan]; + return currentOrder > previousOrder ? "down" : "up"; + }; + + const direction = getDirection(); + + // Update previous plan ref after render + useEffect(() => { + previousPlanRef.current = selectedPlan; + }, [selectedPlan]); const handleContinue = () => { if (selectedPlan === "organization") { @@ -37,7 +62,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => const planIconByType: Record = { personal: "user", - team: "users", + team: "user", organization: "users", }; @@ -56,7 +81,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => badge: t("onboarding_plan_team_badge"), description: t("onboarding_plan_team_description"), icon: planIconByType.team, - variant: "single" as const, + variant: "team" as const, }, { id: "organization" as PlanType, @@ -76,26 +101,26 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => return true; }); + const selectedPlanData = plans.find((plan) => plan.id === selectedPlan); + return ( <> -
+ {/* Left column - Main content */} + + +
+ }> {/* Card */} -
+
- {/* Card Header */} -
-
-

- {t("onboarding_welcome_message", { userName })} -

-

- {t("onboarding_welcome_question")} -

-
-
- {/* Plan options */} "pr-12 [&>button]:left-auto [&>button]:right-6 [&>button]:mt-0 [&>button]:transform" )} classNames={{ - container: "flex w-full items-center gap-4 p-4 pl-5 pr-12 md:p-5 md:pr-14", + container: "flex w-full items-center gap-3 p-5 pr-12", }}> -
- -
-
-

{plan.title}

- - - {plan.badge} - - -
+
+
+

{plan.title}

+ className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center"> {plan.badge} -

- {plan.description} -

+ + {plan.badge} + +

+ {plan.description} +

); })} - - {/* Footer */} -
- -
+ + + {/* Right column - Icon display */} +
+ + {selectedPlanData && ( + + )} +
diff --git a/apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts b/apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts new file mode 100644 index 0000000000..15c3b10c73 --- /dev/null +++ b/apps/web/modules/onboarding/hooks/useSubmitPersonalOnboarding.ts @@ -0,0 +1,80 @@ +import { useRouter } from "next/navigation"; + +import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/useWelcomeToCalcomModal"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useTelemetry } from "@calcom/lib/hooks/useTelemetry"; +import { telemetryEventTypes } from "@calcom/lib/telemetry"; +import { trpc } from "@calcom/trpc/react"; +import { showToast } from "@calcom/ui/components/toast"; + +const DEFAULT_EVENT_TYPES = [ + { + title: "15min_meeting", + slug: "15min", + length: 15, + }, + { + title: "30min_meeting", + slug: "30min", + length: 30, + }, + { + title: "secret_meeting", + slug: "secret", + length: 15, + hidden: true, + }, +]; + +export const useSubmitPersonalOnboarding = () => { + const router = useRouter(); + const { t } = useLocale(); + const telemetry = useTelemetry(); + const utils = trpc.useUtils(); + + const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery(); + const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation(); + + const mutation = trpc.viewer.me.updateProfile.useMutation({ + onSuccess: async () => { + try { + // Create default event types if user has none + if (eventTypes?.length === 0) { + await Promise.all( + DEFAULT_EVENT_TYPES.map(async (event) => { + return createEventType.mutateAsync({ + title: t(event.title), + slug: event.slug, + length: event.length, + hidden: event.hidden, + }); + }) + ); + } + } catch (error) { + console.error(error); + } + + await utils.viewer.me.get.refetch(); + // Set flag to show welcome modal after redirect + setShowWelcomeToCalcomModalFlag(); + router.push("/event-types?welcomeToCalcomModal=true"); + }, + onError: (error) => { + showToast(t("something_went_wrong"), "error"); + console.error(error); + }, + }); + + const submitPersonalOnboarding = () => { + telemetry.event(telemetryEventTypes.onboardingFinished); + mutation.mutate({ + completedOnboarding: true, + }); + }; + + return { + submitPersonalOnboarding, + isSubmitting: mutation.isPending, + }; +}; diff --git a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx index 6868e24e86..66df2dbc2e 100644 --- a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx +++ b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx @@ -8,8 +8,9 @@ import { HexColorPicker } from "react-colorful"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; -import { OnboardingCard } from "../../personal/_components/OnboardingCard"; -import { OnboardingLayout } from "../../personal/_components/OnboardingLayout"; +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { useOnboardingStore } from "../../store/onboarding-store"; type OrganizationBrandViewProps = { @@ -118,6 +119,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) return ( + {/* Left column - Main content */}
-

{t("onboarding_logo_size_hint")}

+

+ {t("onboarding_logo_size_hint")} +

{/* Banner Upload */} @@ -225,69 +229,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)

- - {/* Right side - Preview */} -
-
-

{t("preview")}

-
- {/* Banner preview */} -
- {bannerPreview && ( - {t("onboarding_banner_preview_alt")} - )} -
- - {/* Content */} -
-
-
- {/* Logo preview */} -
- {logoPreview && ( - {t("onboarding_logo_preview_alt")} - )} -
-

- {organizationDetails.name || t("onboarding_preview_nameless")} -

-
-
-
-

- {t("onboarding_preview_example_title")} -

-

- {t("onboarding_preview_example_description")} -

-
-
-
-
- {[134, 104, 84, 104].map((width, i) => ( -
-
-
-
- ))} -
-
-
-
-
+ + {/* Right column - Browser view */} + ); }; diff --git a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx index be492a3a40..10542bfc8d 100644 --- a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx +++ b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx @@ -7,8 +7,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; import { Label, TextField, TextArea } from "@calcom/ui/components/form"; -import { OnboardingCard } from "../../personal/_components/OnboardingCard"; -import { OnboardingLayout } from "../../personal/_components/OnboardingLayout"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; import { useOnboardingStore } from "../../store/onboarding-store"; import { ValidatedOrganizationSlug } from "./validated-organization-slug"; @@ -77,6 +78,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr return ( + {/* Left column - Main content */} + + {/* Right column - Browser view */} + ); }; diff --git a/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx b/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx index 11f7a59008..c0cd796629 100644 --- a/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx +++ b/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx @@ -12,6 +12,7 @@ import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/componen import { Icon } from "@calcom/ui/components/icon"; import { Logo } from "@calcom/ui/components/logo"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding"; import { useOnboardingStore } from "../../store/onboarding-store"; @@ -117,135 +118,55 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp {/* Main content */}
-
- {/* Card */} -
-
- {/* Card Header */} -
-
-

- {t("onboarding_org_invite_title")} -

-

- {isEmailMode - ? t("onboarding_org_invite_subtitle_email") - : t("onboarding_org_invite_subtitle_full")} -

+
+ {/* Left column - Main content */} +
+ {/* Card */} +
+
+ {/* Card Header */} +
+
+

+ {t("onboarding_org_invite_title")} +

+

+ {isEmailMode + ? t("onboarding_org_invite_subtitle_email") + : t("onboarding_org_invite_subtitle_full")} +

+
-
- {/* Content */} -
-
- {!isEmailMode ? ( - // Coming soon - // Initial invite options view -
- - -
-
- {t("onboarding_or_divider")} -
-
- -
- - - -
- - {/* Role selector */} -
-
- {t("onboarding_invite_all_as")} - value && setInviteRole(value as "MEMBER" | "ADMIN")} - options={[ - { value: "ADMIN", label: t("onboarding_admins") }, - { value: "MEMBER", label: t("members") }, - ]} - /> -
- {t("onboarding_modify_roles_later")} -
-
- ) : ( - // Email invite form -
+ {/* Content */} +
+
+ {!isEmailMode ? ( + // Coming soon + // Initial invite options view
- {/* Email and Team inputs */} -
-
- - -
+ - {fields.map((field, index) => ( -
-
- - t.value === form.watch(`invites.${index}.team`))} + onChange={(option) => { + if (option) { + form.setValue(`invites.${index}.team`, option.value); + } + }} + placeholder={t("select_team")} + /> +
+ +
+ ))} + + {/* Add button */} + +
+ + {/* Role selector */} +
+
+ {t("onboarding_invite_all_as")} + value && setInviteRole(value as "MEMBER" | "ADMIN")} + options={[ + { value: "MEMBER", label: t("members") }, + { value: "ADMIN", label: t("onboarding_admins") }, + ]} + /> +
+ {t("onboarding_modify_roles_later")} +
+
+ + )} +
+
+ + {/* Footer */} +
+
+
- {/* Footer */} -
- -
+ {/* Skip button */} +
+
- {/* Skip button */} -
- -
+ {/* Right column - Browser view */} +
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 c7b76a37bd..f30674773e 100644 --- a/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx +++ b/apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx @@ -1,15 +1,14 @@ "use client"; -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 { OnboardingCalendarBrowserView } from "../../components/onboarding-calendar-browser-view"; +import { useSubmitPersonalOnboarding } from "../../hooks/useSubmitPersonalOnboarding"; import { InstallableAppCard } from "../_components/InstallableAppCard"; -import { OnboardingCard } from "../_components/OnboardingCard"; -import { OnboardingLayout } from "../_components/OnboardingLayout"; -import { SkipButton } from "../_components/SkipButton"; +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; import { useAppInstallation } from "../_components/useAppInstallation"; type PersonalCalendarViewProps = { @@ -17,9 +16,9 @@ type PersonalCalendarViewProps = { }; export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => { - const router = useRouter(); const { t } = useLocale(); const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation(); + const { submitPersonalOnboarding, isSubmitting } = useSubmitPersonalOnboarding(); const queryIntegrations = trpc.viewer.apps.integrations.useQuery({ variant: "calendar", @@ -29,23 +28,38 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) = }); const handleContinue = () => { - router.push("/onboarding/personal/video"); + submitPersonalOnboarding(); }; const handleSkip = () => { - router.push("/onboarding/personal/video"); + submitPersonalOnboarding(); }; return ( - + + {/* Left column - Main content */} - {t("continue")} - +
+ + +
}>
{queryIntegrations.data?.items?.map((app) => ( @@ -60,7 +74,8 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
- + {/* Right column - Browser view */} +
); }; diff --git a/apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx b/apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx deleted file mode 100644 index 67efa29b4f..0000000000 --- a/apps/web/modules/onboarding/personal/profile/personal-profile-view.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; - -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { md } from "@calcom/lib/markdownIt"; -import turndown from "@calcom/lib/turndownService"; -import { trpc } from "@calcom/trpc/react"; -import { UserAvatar } from "@calcom/ui/components/avatar"; -import { Button } from "@calcom/ui/components/button"; -import { Editor } from "@calcom/ui/components/editor"; -import { Label } from "@calcom/ui/components/form"; -import { ImageUploader } from "@calcom/ui/components/image-uploader"; -import { showToast } from "@calcom/ui/components/toast"; - -import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; - -import { OnboardingCard } from "../_components/OnboardingCard"; -import { OnboardingLayout } from "../_components/OnboardingLayout"; -import { useOnboardingStore } from "../../store/onboarding-store"; - -type PersonalProfileViewProps = { - userEmail: string; -}; - -type FormData = { - bio: string; -}; - -export const PersonalProfileView = ({ userEmail }: PersonalProfileViewProps) => { - const { data: user } = trpc.viewer.me.get.useQuery(); - const router = useRouter(); - const { t } = useLocale(); - const { personalDetails, setPersonalDetails } = useOnboardingStore(); - - const avatarRef = useRef(null); - const [imageSrc, setImageSrc] = useState(""); - const [firstRender, setFirstRender] = useState(true); - - // Update imageSrc when user loads - useEffect(() => { - if (user) { - setImageSrc(personalDetails.avatar || user.avatar || ""); - } - }, [user, personalDetails.avatar]); - - const { setValue, handleSubmit, getValues } = useForm({ - defaultValues: { bio: personalDetails.bio || user?.bio || "" }, - }); - - const utils = trpc.useUtils(); - - // Avatar mutation - const avatarMutation = trpc.viewer.me.updateProfile.useMutation({ - onSuccess: async (data) => { - showToast(t("your_user_profile_updated_successfully"), "success"); - setImageSrc(data.avatarUrl ?? ""); - setPersonalDetails({ avatar: data.avatarUrl ?? null }); - }, - onError: () => { - showToast(t("problem_saving_user_profile"), "error"); - }, - }); - - // Profile mutation - const mutation = trpc.viewer.me.updateProfile.useMutation({ - onSuccess: async () => { - await utils.viewer.me.invalidate(); - }, - }); - - const onSubmit = handleSubmit(async (data: { bio: string }) => { - const { bio } = data; - - // Save to store - setPersonalDetails({ - bio, - }); - - // Save to backend - await mutation.mutateAsync({ - bio, - }); - - router.push("/onboarding/personal/calendar"); - }); - - async function updateProfileHandler(newAvatar: string) { - avatarMutation.mutate({ - avatarUrl: newAvatar, - }); - } - - if (!user) { - return null; - } - - return ( - - - {t("continue")} - - }> - {/* Form */} -
-
-
-
-
- {/* Avatar */} -
- -
- {user && } - - { - if (avatarRef.current) { - avatarRef.current.value = newAvatar; - } - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value" - )?.set; - nativeInputValueSetter?.call(avatarRef.current, newAvatar); - const ev2 = new Event("input", { bubbles: true }); - avatarRef.current?.dispatchEvent(ev2); - updateProfileHandler(newAvatar); - }} - imageSrc={imageSrc} - /> -
-
- - {/* Username */} -
- -
- - {/* Bio */} -
- - md.render(getValues("bio") || user?.bio || "")} - setText={(value: string) => setValue("bio", turndown(value))} - excludedToolbarItems={["blockType"]} - firstRender={firstRender} - setFirstRender={setFirstRender} - /> -

- {t("few_sentences_about_yourself")} -

-
-
-
-
-
-
-
-
- ); -}; diff --git a/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx b/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx index 84b4638351..ba2ae42491 100644 --- a/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx +++ b/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx @@ -2,23 +2,26 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useEffect, useRef, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; -import dayjs from "@calcom/dayjs"; -import { useTimePreferences } from "@calcom/features/bookings/lib"; -import { TimezoneSelect } from "@calcom/features/components/timezone-select"; import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import { UserAvatar } from "@calcom/ui/components/avatar"; import { Button } from "@calcom/ui/components/button"; -import { Label, TextField } from "@calcom/ui/components/form"; +import { Label, TextArea, TextField } from "@calcom/ui/components/form"; +import { ImageUploader } from "@calcom/ui/components/image-uploader"; +import { showToast } from "@calcom/ui/components/toast"; +import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; + +import { OnboardingCard } from "../../components/OnboardingCard"; +import { OnboardingLayout } from "../../components/OnboardingLayout"; +import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt"; import { useOnboardingStore } from "../../store/onboarding-store"; -import { OnboardingCard } from "../_components/OnboardingCard"; -import { OnboardingLayout } from "../_components/OnboardingLayout"; type PersonalSettingsViewProps = { userEmail: string; @@ -28,17 +31,17 @@ type PersonalSettingsViewProps = { export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => { const router = useRouter(); const { t } = useLocale(); + const { data: user } = trpc.viewer.me.get.useQuery(); const { personalDetails, setPersonalDetails } = useOnboardingStore(); - const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences(); - const [name, setName] = useState(""); + const avatarRef = useRef(null); + const [imageSrc, setImageSrc] = useState(""); useEffect(() => { - setName(personalDetails.name || userName || ""); - if (personalDetails.timezone) { - setSelectedTimeZone(personalDetails.timezone); + if (user) { + setImageSrc(personalDetails.avatar || user.avatar || ""); } - }, [personalDetails, userName, setSelectedTimeZone]); + }, [personalDetails.avatar, user]); const formSchema = z.object({ name: z @@ -47,98 +50,169 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi .max(FULL_NAME_LENGTH_MAX_LIMIT, { message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }), }), + bio: z.string().optional(), }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { name: personalDetails.name || userName || "", + bio: personalDetails.bio || "", }, }); const utils = trpc.useUtils(); + + // Avatar mutation + const avatarMutation = trpc.viewer.me.updateProfile.useMutation({ + onSuccess: async (data) => { + showToast(t("your_user_profile_updated_successfully"), "success"); + setImageSrc(data.avatarUrl ?? ""); + setPersonalDetails({ avatar: data.avatarUrl ?? null }); + }, + onError: () => { + showToast(t("problem_saving_user_profile"), "error"); + }, + }); + + // Profile mutation const mutation = trpc.viewer.me.updateProfile.useMutation({ onSuccess: async () => { await utils.viewer.me.invalidate(); }, }); + async function updateProfileHandler(newAvatar: string) { + avatarMutation.mutate({ + avatarUrl: newAvatar, + }); + } + const handleContinue = form.handleSubmit(async (data) => { // Save to store setPersonalDetails({ name: data.name, - timezone: selectedTimeZone, + bio: data.bio || "", }); // Save to backend await mutation.mutateAsync({ name: data.name, - timeZone: selectedTimeZone, + bio: data.bio || "", }); - router.push("/onboarding/personal/profile"); + router.push("/onboarding/personal/calendar"); }); + if (!user) { + return null; + } + return ( <> + {/* Left column - Main content */} - {t("continue")} - - }> - {/* Form */} -
-
-
-
-
- {/* Name */} -
- { - setName(e.target.value); - form.setValue("name", e.target.value); - }} - placeholder="John Doe" - /> - {form.formState.errors.name && ( -

{form.formState.errors.name.message}

- )} -
- - {/* Timezone */} -
- - setSelectedTimeZone(value)} - /> -

- {t("current_time")}{" "} - {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()} -

-
-
-
-
+
+ +
-
+ }> + +
+ {/* Profile Picture */} +
+ +
+ {user && ( +
+ +
+ )} + + { + if (avatarRef.current) { + avatarRef.current.value = newAvatar; + } + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(avatarRef.current, newAvatar); + const ev2 = new Event("input", { bubbles: true }); + avatarRef.current?.dispatchEvent(ev2); + updateProfileHandler(newAvatar); + }} + imageSrc={imageSrc} + /> +
+

{t("onboarding_logo_size_hint")}

+
+ + {/* Name */} +
+ + {form.formState.errors.name && ( +

{form.formState.errors.name.message}

+ )} +
+ + {/* Username */} +
+ { + // Refetch user to get updated username and save to store + const updatedUser = await utils.viewer.me.get.fetch(); + if (updatedUser?.username) { + setPersonalDetails({ username: updatedUser.username }); + } + }} + /> +
+ + {/* Bio */} +
+ +