diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx index bc0a42eaf2..aeba1acca1 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx @@ -26,6 +26,10 @@ const ServerPage = async () => { return redirect("/auth/login"); } + if (session.user.role !== "ADMIN") { + return redirect("/onboarding/teams/invite/email"); + } + const userEmail = session.user.email || ""; return ; diff --git a/apps/web/modules/onboarding/components/EmailInviteForm.tsx b/apps/web/modules/onboarding/components/EmailInviteForm.tsx index a1f3b47631..e454e2f141 100644 --- a/apps/web/modules/onboarding/components/EmailInviteForm.tsx +++ b/apps/web/modules/onboarding/components/EmailInviteForm.tsx @@ -47,7 +47,7 @@ export function EmailInviteForm({ const { register, watch, setValue } = useFormContext(); return ( -
+
{showTeamSelect ? (
@@ -59,7 +59,7 @@ export function EmailInviteForm({
) : ( - + )}
{ +export const OnboardingCard = ({ + title, + subtitle, + children, + footer, + isLoading, + floatingFooter = false, +}: OnboardingCardProps) => { + const pathname = usePathname(); + + // Animation variants for entry and exit + const containerVariants = { + initial: { + opacity: 0, + y: 20, + }, + animate: { + opacity: 1, + y: 0, + }, + exit: { + opacity: 0, + y: -20, + }, + }; + return (
- {/* Card Header */} -
-
-

{title}

-

{subtitle}

-
-
- - {/* Content */} -
- {isLoading ? ( -
- - + + + {/* Card Header */} +
+
+

{title}

+

{subtitle}

+
- ) : ( - children - )} -
+ + {/* Content */} +
+ {isLoading ? ( +
+ + +
+ ) : ( + children + )} +
+ + {/* Footer */} -
{footer}
+ {floatingFooter ? ( +
+ {footer} +
+ ) : ( +
{footer}
+ )}
); }; diff --git a/apps/web/modules/onboarding/components/OnboardingLayout.tsx b/apps/web/modules/onboarding/components/OnboardingLayout.tsx index 741c7d218b..395524b92b 100644 --- a/apps/web/modules/onboarding/components/OnboardingLayout.tsx +++ b/apps/web/modules/onboarding/components/OnboardingLayout.tsx @@ -10,8 +10,8 @@ import { Logo } from "@calcom/ui/components/logo"; type OnboardingLayoutProps = { userEmail: string; - currentStep: number; - totalSteps: number; + currentStep?: number; + totalSteps?: number; children: ReactNode; }; @@ -28,13 +28,13 @@ export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children const column2 = childrenArray[1]; return ( -
+
{/* Logo and container - centered */} -
- -
+
+ +
{/* Column 1 - Always visible, 40% on xl+ */} -
{column1}
+
{column1}
{/* Column 2 - Hidden on mobile, visible on xl+, 60% on xl+ */} {column2 && (
{column2}
@@ -44,31 +44,28 @@ export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children {/* Footer with progress dots and sign out */}
-
- {Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => { - const isCurrentStep = step === currentStep; - const isCompleted = step < currentStep; - return ( -
+
+ {totalSteps && totalSteps > 0 ? ( + Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => { + const isCurrent = step === currentStep; + const isPast = currentStep !== undefined && step < currentStep; + const isUpcoming = currentStep !== undefined && step > currentStep; + + return (
- {(isCompleted || isCurrentStep) && ( -
- )} -
- ); - })} + ); + }) + ) : ( + + + + {/* Profile Header */} +
+
+ +
+

+ {name || t("your_name")} +

+

+ {bio || t("onboarding_browser_view_default_bio")} +

- ))} -
-
+
+ + {/* Events List */} +
+ {events.map((event, index) => ( +
+ {index > 0 &&
} +
+
+
+

{event.title}

+
+ + + {event.duration} {t("minute_timeUnit")} + +
+
+

{event.description}

+
+ +
+
+ ))} +
+ +
); diff --git a/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx index 085e67eaad..32fdafe0d7 100644 --- a/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx +++ b/apps/web/modules/onboarding/components/onboarding-calendar-browser-view.tsx @@ -1,59 +1,109 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; +import { usePathname } from "next/navigation"; import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; import { Calendar } from "@calcom/features/calendars/weeklyview"; +import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state"; import { weekdayDates } from "@calcom/features/calendars/weeklyview/utils"; import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { useOnboardingCalendarEvents } from "../hooks/useOnboardingCalendarEvents"; +// Helper function to ensure a number is a valid Hours type +const toHours = (value: number): Hours => { + const clamped = Math.max(0, Math.min(23, value)); + return clamped as Hours; +}; + export const OnboardingCalendarBrowserView = () => { + const pathname = usePathname(); const { startDate, endDate } = useMemo(() => { return weekdayDates(0, new Date(), 6); }, []); const { events } = useOnboardingCalendarEvents({ startDate, endDate }); + // Calculate startHour and endHour based on current time + const hours = useMemo((): { startHour: Hours; endHour: Hours } => { + const currentHour = dayjs().tz(CURRENT_TIMEZONE).hour(); + return { + startHour: toHours(Math.max(0, currentHour - 2)), + endHour: toHours(Math.min(23, currentHour + 6)), + }; + }, []); + + // Animation variants for entry and exit + const containerVariants = { + initial: { + opacity: 0, + y: -20, + }, + animate: { + opacity: 1, + y: 0, + }, + exit: { + opacity: 0, + y: 20, + }, + }; + // Memoize calendar props to prevent unnecessary re-initializations const calendarProps = useMemo( - () => ({ - timezone: CURRENT_TIMEZONE, - startDate, - endDate, - events: events || [], - gridCellsPerHour: 4, - hoverEventDuration: 0, - showBackgroundPattern: false, - showBorder: false, - borderColor: "subtle" as const, - hideHeader: true, - eventsDisabled: true, - sortEvents: true, - isPending: false, // Explicitly set to false to prevent loading spinner - loading: false, // Also set loading to false just in case - onEventClick: () => {}, - onEmptyCellClick: () => {}, - onDateChange: () => {}, - showTimezone: true, - }), - [startDate, endDate, events] + () => + ({ + timezone: CURRENT_TIMEZONE, + startDate, + endDate, + events: events || [], + startHour: hours.startHour, + endHour: hours.endHour, + gridCellsPerHour: 4, + hoverEventDuration: 0, + showBackgroundPattern: false, + showBorder: false, + borderColor: "subtle" as const, + hideHeader: true, + eventsDisabled: true, + sortEvents: true, + isPending: false, // Explicitly set to false to prevent loading spinner + loading: false, // Also set loading to false just in case + onEventClick: () => {}, + onEmptyCellClick: () => {}, + onDateChange: () => {}, + showTimezone: true, + } satisfies CalendarComponentProps), + [startDate, endDate, events, hours] ); return ( -
-
- - {dayjs(startDate).format("MMM D")} - – - {dayjs(endDate).format("MMM D, YYYY")} - -
- {/* Calendar View */} -
- -
-
+ + +
+ + {dayjs(startDate).format("MMM D")} + – + {dayjs(endDate).format("MMM D, YYYY")} + +
+ {/* Calendar View */} +
+ +
+
+
); }; diff --git a/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx b/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx index fc505ee612..fa8ac03d8b 100644 --- a/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx +++ b/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; import { Icon } from "@calcom/ui/components/icon"; @@ -10,22 +11,50 @@ import { useOnboardingStore } from "../store/onboarding-store"; export const OnboardingContinuationPrompt = () => { const router = useRouter(); - const { organizationDetails, resetOnboarding } = useOnboardingStore(); + const { t } = useLocale(); + const { selectedPlan, organizationDetails, teamDetails, resetOnboarding } = useOnboardingStore(); const [isVisible, setIsVisible] = useState(false); + const [entityName, setEntityName] = useState(""); + const [entityType, setEntityType] = useState<"organization" | "team" | null>(null); useEffect(() => { - // Check if there's existing organization data - const hasExistingData = organizationDetails.name && organizationDetails.link; - setIsVisible(!!hasExistingData); - }, [organizationDetails]); + // Check for organization plan and data + const hasOrganizationPlan = selectedPlan === "organization"; + const hasOrganizationData = organizationDetails.name?.trim() && organizationDetails.link?.trim(); - if (!isVisible) { + // Check for team plan and data + const hasTeamPlan = selectedPlan === "team"; + const hasTeamData = teamDetails.name?.trim() && teamDetails.slug?.trim(); + + // Show if either organization or team has data + if (hasOrganizationPlan && hasOrganizationData) { + setIsVisible(true); + setEntityName(organizationDetails.name); + setEntityType("organization"); + } else if (hasTeamPlan && hasTeamData) { + setIsVisible(true); + setEntityName(teamDetails.name); + setEntityType("team"); + } else { + setIsVisible(false); + setEntityName(""); + setEntityType(null); + } + }, [selectedPlan, organizationDetails, teamDetails]); + + if (!isVisible || !entityName || !entityType) { return null; } const handleContinue = () => { - // Navigate to the next step in the flow (brand page) - router.push("/onboarding/organization/brand"); + // Navigate to the next step based on plan type + if (entityType === "organization") { + // Organization flow: details -> brand -> teams -> invite + router.push("/onboarding/organization/brand"); + } else if (entityType === "team") { + // Team flow: details -> invite + router.push("/onboarding/teams/invite"); + } }; const handleStartOver = () => { @@ -43,19 +72,20 @@ export const OnboardingContinuationPrompt = () => {
-

Continue onboarding?

+

+ {t("onboarding_continue_prompt_title")} +

- Would you like to carry on with onboarding for{" "} - {organizationDetails.name} or start over? + {t("onboarding_continue_prompt_description", { entityName: entityName })}

diff --git a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx index e9ce7747bc..718a766800 100644 --- a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx +++ b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx @@ -1,66 +1,183 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; +import { usePathname } from "next/navigation"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Avatar } from "@calcom/ui/components/avatar"; -import { useOnboardingStore } from "../store/onboarding-store"; +import { useOnboardingStore, type Invite } from "../store/onboarding-store"; type OnboardingInviteBrowserViewProps = { teamName?: string; + /** If true, use organization invites instead of team invites */ + useOrganizationInvites?: boolean; + /** Optional: Watched form invites to display in real-time. Takes precedence over store values. */ + watchedInvites?: Array<{ + email: string; + team?: string; + role?: string; + }>; }; -export const OnboardingInviteBrowserView = ({ teamName }: OnboardingInviteBrowserViewProps) => { +type DisplayItem = { + name: string; + email: string; + team: string; + isReal?: boolean; +}; + +export const OnboardingInviteBrowserView = ({ + teamName, + useOrganizationInvites = false, + watchedInvites, +}: OnboardingInviteBrowserViewProps) => { + const pathname = usePathname(); const { data: user } = trpc.viewer.me.get.useQuery(); - const { teamBrand } = useOnboardingStore(); + const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } = + useOnboardingStore(); + const { t } = useLocale(); + + // Animation variants for entry and exit + const containerVariants = { + initial: { + opacity: 0, + y: -20, + }, + animate: { + opacity: 1, + y: 0, + }, + exit: { + opacity: 0, + y: 20, + }, + }; // Use default values if not provided const rawInviterName = user?.name || user?.username || "Alex"; const displayInviterName = rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1); - const displayTeamName = teamName || "Deel"; - const teamAvatar = teamBrand.logo || null; + + // Use organization or team data based on context + const displayName = useOrganizationInvites + ? organizationDetails.name || teamName || "Deel" + : teamName || teamDetails.name || "Deel"; + const avatar = useOrganizationInvites ? organizationBrand.logo || null : teamBrand.logo || null; + + // Get invites based on context - use watched invites if provided, otherwise fall back to store + let actualInvites: Invite[] = []; + if (watchedInvites) { + // Transform watched invites to Invite format + actualInvites = watchedInvites.map((invite) => ({ + email: invite.email, + team: invite.team || (useOrganizationInvites ? "" : teamDetails.name), + role: (invite.role as Invite["role"]) || "MEMBER", + })); + } else { + // Fall back to store values + actualInvites = useOrganizationInvites ? invites : teamInvites; + } + + // Filter out empty invites (where email is empty or just whitespace) + const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0); + + // Create empty state items + const emptyStateItem = { + name: t("team_member"), + email: "name@example.com", + team: t("team"), + }; + + // Combine actual invites with empty state to fill up to 9 items + + const displayItems: DisplayItem[] = []; + const maxItems = 9; + + // Add actual invites first + for (let i = 0; i < validInvites.length && i < maxItems; i++) { + const invite = validInvites[i]; + displayItems.push({ + name: invite.email.split("@")[0] || t("team_member"), + email: invite.email, + team: invite.team || t("team"), + isReal: true, + }); + } + + // Fill remaining slots with empty state + const remainingSlots = maxItems - displayItems.length; + for (let i = 0; i < remainingSlots; i++) { + displayItems.push({ + name: emptyStateItem.name, + email: emptyStateItem.email, + team: emptyStateItem.team, + isReal: false, + }); + } return ( -
- {/* Browser header */} -
- {/* Navigation buttons */} -
-
-
-
-
-
-
-

mail.example.com

-
-
- +
{/* Content */} -
-
- {/* Email Header */} -
-
- -
-

- {displayInviterName} invited you to join {displayTeamName} -

-

- We're emailing you all the details -

+
+ + +
+
+ +
+

+ {displayInviterName} invited you to join {displayName} +

+

+ We're emailing you all the details +

+
-
- {/* Email Body */} -
-
+ {/* Email Body */} +
+ {displayItems.map((item, index) => ( +
+ +
+
+

+ {item.name} +

+

+ {item.email} +

+
+ {item.team && ( +
+ {item.team} +
+ )} +
+
+ ))} +
+ +
); diff --git a/apps/web/modules/onboarding/components/onboarding-organization-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-organization-browser-view.tsx index 5970460546..0512e5f026 100644 --- a/apps/web/modules/onboarding/components/onboarding-organization-browser-view.tsx +++ b/apps/web/modules/onboarding/components/onboarding-organization-browser-view.tsx @@ -1,6 +1,8 @@ "use client"; import classNames from "classnames"; +import { AnimatePresence, motion } from "framer-motion"; +import { usePathname } from "next/navigation"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -24,9 +26,26 @@ export const OnboardingOrganizationBrowserView = ({ bannerUrl, }: OnboardingOrganizationBrowserViewProps) => { const { t } = useLocale(); + const pathname = usePathname(); const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, ""); const displayUrl = `${webappUrl}/${slug || ""}`; + // Animation variants for entry and exit + const containerVariants = { + initial: { + opacity: 0, + y: -20, + }, + animate: { + opacity: 1, + y: 0, + }, + exit: { + opacity: 0, + y: 20, + }, + }; + const events: Array<{ title: string; description: string; @@ -60,87 +79,100 @@ export const OnboardingOrganizationBrowserView = ({ ]; return ( -
+
{/* Browser header */} -
+
{/* Navigation buttons */}
-
+
-

{displayUrl}

+

{displayUrl}

{/* Content */}
-
- {/* Organization Profile Header with Banner */} -
-
- {/* Banner Image */} -
- {bannerUrl ? ( - {name - ) : ( -
- )} -
+ + + {/* Organization Profile Header with Banner */} +
+
+ {/* Banner Image */} +
+ {bannerUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {name + ) : ( +
+ )} +
- {/* Organization Avatar - Overlaying the banner */} -
- -
-
- - {/* Organization Info */} -
-

- {name || t("organization_name")} -

-

- {bio || t("onboarding_browser_view_default_bio")} -

-
-
- - {/* Events List */} -
- {events.map((event, index) => ( -
-
-
-
-

{event.title}

-
- - - {event.duration} {t("minute_timeUnit")} - -
-
-

{event.description}

-
- + {/* Organization Avatar - Overlaying the banner */} +
+
- ))} -
-
+ + {/* Organization Info */} +
+

+ {name || t("organization_name")} +

+

+ {bio || t("onboarding_browser_view_default_bio")} +

+
+
+ + {/* Events List */} +
+ {events.map((event) => ( +
+
+
+
+

{event.title}

+
+ + + {event.duration} {t("minute_timeUnit")} + +
+
+

{event.description}

+
+ +
+
+ ))} +
+
+
); diff --git a/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx index ef72985074..fa69b136ab 100644 --- a/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx +++ b/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx @@ -1,5 +1,8 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; +import { usePathname } from "next/navigation"; + import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Avatar } from "@calcom/ui/components/avatar"; @@ -18,16 +21,33 @@ export const OnboardingTeamsBrowserView = ({ organizationName, organizationBanner, }: OnboardingTeamsBrowserViewProps) => { + const pathname = usePathname(); const { t } = useLocale(); const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, ""); // Show placeholder if no teams or all teams are empty const hasValidTeams = teams.some((team) => team.name && team.name.trim().length > 0); + // Animation variants for entry and exit + const containerVariants = { + initial: { + opacity: 0, + y: -20, + }, + animate: { + opacity: 1, + y: 0, + }, + exit: { + opacity: 0, + y: 20, + }, + }; + return ( -
+
{/* Browser header */} -
+
{/* Navigation buttons */}
@@ -43,93 +63,108 @@ export const OnboardingTeamsBrowserView = ({ {/* Content */}
-
- {/* Teams Header with Banner */} -
-
- {/* Banner Image */} -
- {organizationBanner ? ( - {organizationName - ) : ( -
+ + + {/* Teams Header with Banner */} +
+
+ {/* Banner Image */} +
+ {organizationBanner ? ( + // eslint-disable-next-line @next/next/no-img-element + {organizationName + ) : ( +
+ )} +
+ + {/* Organization Logo - Overlaying the banner */} + {organizationLogo && ( +
+ +
)}
- {/* Organization Logo - Overlaying the banner */} - {organizationLogo && ( -
- + {/* Teams Info */} +
+

{t("teams")}

+

+ {hasValidTeams + ? t("onboarding_teams_browser_view_subtitle") + : t("onboarding_teams_browser_view_placeholder_subtitle")} +

+
+
+ + {/* Teams List */} +
+ {hasValidTeams ? ( + teams + .filter((team) => team.name && team.name.trim().length > 0) + .map((team, index) => ( +
+ {index > 0 &&
} +
+ + + {team.name.charAt(0).toUpperCase()} + +
+ } + /> +
+

{team.name}

+

+ {t("onboarding_teams_browser_view_team_description")} +

+
+ +
+
+ )) + ) : ( +
+
+ +
+
+

+ {t("onboarding_teams_browser_view_no_teams_title")} +

+

+ {t("onboarding_teams_browser_view_no_teams_description")} +

+
)}
- - {/* Teams Info */} -
-

{t("teams")}

-

- {hasValidTeams - ? t("onboarding_teams_browser_view_subtitle") - : t("onboarding_teams_browser_view_placeholder_subtitle")} -

-
-
- - {/* Teams List */} -
- {hasValidTeams ? ( - teams - .filter((team) => team.name && team.name.trim().length > 0) - .map((team, index) => ( -
- {index > 0 &&
} -
- - {team.name.charAt(0).toUpperCase()} -
- } - /> -
-

{team.name}

-

- {t("onboarding_teams_browser_view_team_description")} -

-
- -
-
- )) - ) : ( -
-
- -
-
-

- {t("onboarding_teams_browser_view_no_teams_title")} -

-

- {t("onboarding_teams_browser_view_no_teams_description")} -

-
-
- )} -
-
+
+
); diff --git a/apps/web/modules/onboarding/components/plan-icon.tsx b/apps/web/modules/onboarding/components/plan-icon.tsx index 52e9cf8b99..3cebe64aaf 100644 --- a/apps/web/modules/onboarding/components/plan-icon.tsx +++ b/apps/web/modules/onboarding/components/plan-icon.tsx @@ -1,4 +1,4 @@ -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { Icon, type IconName } from "@calcom/ui/components/icon"; @@ -26,31 +26,11 @@ export function PlanIcon({ variant?: "single" | "organization" | "team"; animationDirection?: "up" | "down"; }) { - const renderIconContainer = (index: number) => { + const renderIconContainer = () => { return ( - - +
); }; @@ -89,7 +69,7 @@ export function PlanIcon({ return ( - renderSmallUserIcon(ringIndex, angle, index) - )} + + {variant === "team" || variant === "organization" + ? TEAM_ICON_POSITIONS.map(({ ringIndex, angle }, index) => + renderSmallUserIcon(ringIndex, angle, index) + ) + : null} + - {renderIconContainer(0)} + {renderIconContainer()}
); } diff --git a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx index 1b9425a0d7..3975222953 100644 --- a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx +++ b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx @@ -1,8 +1,8 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence } from "framer-motion"; import { useRouter } from "next/navigation"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useTransition } from "react"; import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -27,6 +27,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { const { t } = useLocale(); const { selectedPlan, setSelectedPlan } = useOnboardingStore(); const previousPlanRef = useRef(null); + const [isPending, startTransition] = useTransition(); // Plan order mapping for determining direction const planOrder: Record = { @@ -40,7 +41,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { if (!selectedPlan || !previousPlanRef.current) return "down"; const previousOrder = planOrder[previousPlanRef.current]; const currentOrder = planOrder[selectedPlan]; - return currentOrder > previousOrder ? "down" : "up"; + return currentOrder > previousOrder ? "up" : "down"; }; const direction = getDirection(); @@ -51,13 +52,15 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { }, [selectedPlan]); const handleContinue = () => { - if (selectedPlan === "organization") { - router.push("/onboarding/organization/details"); - } else if (selectedPlan === "team") { - router.push("/onboarding/teams/details"); - } else if (selectedPlan === "personal") { - router.push("/onboarding/personal/settings"); - } + startTransition(() => { + if (selectedPlan === "organization") { + router.push("/onboarding/organization/details"); + } else if (selectedPlan === "team") { + router.push("/onboarding/teams/details"); + } else if (selectedPlan === "personal") { + router.push("/onboarding/personal/settings"); + } + }); }; const planIconByType: Record = { @@ -106,15 +109,19 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { return ( <> - + {/* Left column - Main content */} -
}> diff --git a/apps/web/modules/onboarding/hooks/useCreateTeam.ts b/apps/web/modules/onboarding/hooks/useCreateTeam.ts index 6cb0ef5144..523ada7f5d 100644 --- a/apps/web/modules/onboarding/hooks/useCreateTeam.ts +++ b/apps/web/modules/onboarding/hooks/useCreateTeam.ts @@ -19,6 +19,13 @@ export function useCreateTeam() { try { const { teamDetails, teamBrand } = store; + // Validate team details - if empty, redirect back to team details step + if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) { + router.push("/onboarding/teams/details"); + setIsSubmitting(false); + return; + } + // Create the team const result = await createTeamMutation.mutateAsync({ name: teamDetails.name, 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 7ab0868fe9..97c066dba3 100644 --- a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx +++ b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; @@ -83,7 +83,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr title={t("onboarding_org_details_title")} subtitle={t("onboarding_org_details_subtitle")} footer={ -
+
+ - +
+ + +
}>
@@ -155,7 +175,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma append={append} remove={remove} defaultRole={inviteRole} - showTeamSelect + showTeamSelect={hasTeams} teams={teams} emailPlaceholder={`dave@${usersEmailDomain}`} /> @@ -176,13 +196,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
- + setIsCSVModalOpen(false)} /> ); diff --git a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx index 80817e697b..ce4d36668b 100644 --- a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx +++ b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx @@ -51,12 +51,12 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) const handleContinue = (data: FormValues) => { // Save teams to store setTeams(data.teams); - router.push("/onboarding/organization/invite"); + router.push("/onboarding/organization/invite/email"); }; const handleSkip = () => { // Skip teams and go to invite - router.push("/onboarding/organization/invite"); + router.push("/onboarding/organization/invite/email"); }; const hasValidTeams = fields.some((_, index) => { diff --git a/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx b/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx index 3c06e6d7d6..92d46d902c 100644 --- a/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx +++ b/apps/web/modules/onboarding/personal/_components/InstallableAppCard.tsx @@ -42,7 +42,7 @@ export const InstallableAppCard = ({ title={app.name}> {app.name}

-

{app.description}

+

{app.description}

{ + const router = useRouter(); const { t } = useLocale(); const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation(); const { submitPersonalOnboarding, isSubmitting } = useSubmitPersonalOnboarding(); + const scrollContainerRef = useRef(null); + const [showFadeGradient, setShowFadeGradient] = useState(false); const queryIntegrations = trpc.viewer.apps.integrations.useQuery({ variant: "calendar", @@ -27,6 +33,56 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) = sortByInstalledFirst: true, }); + const checkOverflow = () => { + const element = scrollContainerRef.current; + if (!element) return; + + const hasOverflow = element.scrollHeight > element.clientHeight; + const isAtBottom = Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) < 1; + + setShowFadeGradient(hasOverflow && !isAtBottom); + }; + + const handleScroll = () => { + checkOverflow(); + }; + + // Check for overflow when data loads, container size changes, or window resizes + useEffect(() => { + const element = scrollContainerRef.current; + if (!element) return; + + // Use requestAnimationFrame to ensure DOM and container queries are fully calculated + const checkWithRAF = () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + checkOverflow(); + }); + }); + }; + + // Check immediately with double RAF for container query calculations + checkWithRAF(); + + // Also check after a delay to catch any late layout calculations + const timeoutId = setTimeout(checkOverflow, 100); + + // Observe container size changes (handles container query recalculations) + const resizeObserver = new ResizeObserver(() => { + checkWithRAF(); + }); + resizeObserver.observe(element); + + // Also listen to window resize as a fallback + window.addEventListener("resize", checkWithRAF); + + return () => { + clearTimeout(timeoutId); + resizeObserver.disconnect(); + window.removeEventListener("resize", checkWithRAF); + }; + }, [queryIntegrations.data?.items]); + const handleContinue = () => { submitPersonalOnboarding(); }; @@ -35,6 +91,10 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) = submitPersonalOnboarding(); }; + const handleBack = () => { + router.push("/onboarding/personal/settings"); + }; + return ( {/* Left column - Main content */} @@ -42,35 +102,49 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) = title={t("connect_your_calendar")} subtitle={t("connect_calendar_to_prevent_conflicts")} isLoading={queryIntegrations.isPending} + floatingFooter={true} footer={ -
- - +
+ + +
}> -
- {queryIntegrations.data?.items?.map((app) => ( - - ))} +
+
+ {queryIntegrations.data?.items?.map((app) => ( + + ))} +
+ {showFadeGradient && ( +
+ )}
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 77afe8b38c..8c0527d3e2 100644 --- a/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx +++ b/apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx @@ -110,7 +110,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi return ( <> - {/* Left column - Main content */} { // Refetch user to get updated username and save to store const updatedUser = await utils.viewer.me.get.fetch(); diff --git a/apps/web/modules/onboarding/teams/details/team-details-view.tsx b/apps/web/modules/onboarding/teams/details/team-details-view.tsx index 52caa28506..3647758165 100644 --- a/apps/web/modules/onboarding/teams/details/team-details-view.tsx +++ b/apps/web/modules/onboarding/teams/details/team-details-view.tsx @@ -13,7 +13,6 @@ import { ImageUploader } from "@calcom/ui/components/image-uploader"; 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 { ValidatedTeamSlug } from "./validated-team-slug"; @@ -78,90 +77,92 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => { logo: teamLogo || null, }); - router.push("/onboarding/teams/invite"); + // We will push to /invite when we have other methods of inviting users from onboarding i.e. CSV upload, Google Workspace connect, copy link etc + router.push("/onboarding/teams/invite/email"); }; return ( <> - {/* Left column - Main content */} - - - -
- }> -
- {/* Team Profile Picture */} -
- -
-
- + + + +
+ }> +
+ {/* Team Profile Picture */} +
+ +
+
+ +
+ +
- - {t("onboarding_logo_size_hint")}

+
+ + {/* Team Name */} +
+ + setTeamName(e.target.value)} + placeholder="Acme Inc." + className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm" />
-

{t("onboarding_logo_size_hint")}

-
- {/* Team Name */} -
- - setTeamName(e.target.value)} - placeholder="Acme Inc." - className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm" + {/* Team Slug */} + -
- {/* Team Slug */} - - - {/* Team Bio */} -
- -