chore: tidy up onboarding with new animations and illustrations (#25124)

<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Revamps onboarding with subtle route transitions, new org/teams illustrations, and clearer invite flows with real-time previews. Adds “Skip for now” on invites and guardrails to avoid incomplete team creation.

- **New Features**
  - Added email invite pages for organizations and teams with shared EmailInviteForm and RoleSelector.
  - Built a live invite browser preview that renders a 3×3 grid and updates as you type; works for orgs and teams.
  - Applied route-keyed framer-motion transitions to layout, cards, and browser views for smoother page changes.
  - Continuation prompt now detects saved org or team and routes to the right next step; localized copy.
  - Added loading state to plan selection to prevent double navigation.
  - Refreshed Organizations welcome modal with a new animated ring illustration and better scrolling.
  - Added “Skip for now” to org and team invite-by-email steps to proceed without invites.
  - Calendar browser preview now focuses on a time window around “now” for a more realistic demo.
  - Added an optional floating footer to keep actions visible on long, scrollable lists.

- **Refactors and Fixes**
  - Unified invite browser and replaced the org preview on the org email step.
  - Split and simplified legacy invite views; team and org routes are cleaner and easier to extend.
  - Streamlined the team invite step by removing a redundant action; non-admins are redirected to the email invite page.
  - Prevented creating teams with empty details by redirecting back to team details.
  - Minor UI cleanup: tighter borders, padding, and mobile max-heights across onboarding screens.
  - Hide team select on org invites when no teams exist.

<sup>Written for commit ae7f5277ab998560ae6e2f3f9144852a7b4959a7. Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
sean-brydon
2025-11-20 10:20:38 +00:00
committed by GitHub
parent 1dcd54aab7
commit 956f81fab3
29 changed files with 1142 additions and 672 deletions
@@ -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 <TeamInviteView userEmail={userEmail} />;
@@ -47,7 +47,7 @@ export function EmailInviteForm({
const { register, watch, setValue } = useFormContext();
return (
<div className="flex w-full flex-col gap-4">
<div className="flex h-full w-full flex-col gap-4">
<div className="flex flex-col gap-2">
{showTeamSelect ? (
<div className="grid grid-cols-2">
@@ -59,7 +59,7 @@ export function EmailInviteForm({
</Label>
</div>
) : (
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
<Label className="text-emphasis mb-0 text-sm font-medium">{t("email")}</Label>
)}
<div
@@ -1,3 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { SkeletonText } from "@calcom/ui/components/skeleton";
@@ -8,33 +12,82 @@ type OnboardingCardProps = {
children: ReactNode;
footer: ReactNode;
isLoading?: boolean;
floatingFooter?: boolean;
};
export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }: OnboardingCardProps) => {
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 (
<div className="relative flex h-full min-h-0 w-full flex-col">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{title}</h1>
<p className="text-subtle text-sm font-medium leading-tight">{subtitle}</p>
</div>
</div>
{/* Content */}
<div className="flex h-full min-h-0 w-full flex-1 flex-col gap-4">
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="flex h-full min-h-0 w-full flex-col"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
{/* Card Header */}
<div className="mb-2 flex w-full gap-1.5 py-2 md:mb-0 md:py-4">
<div className="flex w-full flex-col gap-2">
<h1 className="font-cal text-xl font-semibold leading-6">{title}</h1>
<p className="text-subtle text-sm leading-tight">{subtitle}</p>
</div>
</div>
) : (
children
)}
</div>
{/* Content */}
<div
className={`flex h-full min-h-0 w-full flex-1 flex-col gap-4 [container-type:size] ${
floatingFooter ? "pb-10" : ""
}`}>
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
</div>
) : (
children
)}
</div>
</motion.div>
</AnimatePresence>
{/* Footer */}
<div className="flex w-full items-center justify-start px-5 py-4">{footer}</div>
{floatingFooter ? (
<div className="absolute bottom-0 left-0 right-[12px] z-10 flex items-center justify-start rounded-[12px] bg-[rgba(255,255,255,0.01)] p-2 shadow-[0px_12px_32px_-6px_rgba(0,0,0,0.12),0px_0px_0px_1px_rgba(111,107,107,0.1),0px_1px_3px_0px_rgba(63,70,75,0.1)] backdrop-blur-[6px] backdrop-filter">
{footer}
</div>
) : (
<div className="mt-4 flex w-full items-center justify-start py-2">{footer}</div>
)}
</div>
);
};
@@ -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 (
<div className="bg-muted flex min-h-screen w-full flex-col items-center justify-between overflow-clip rounded-[12px] px-6 py-10">
<div className="bg-muted flex min-h-screen w-full flex-col items-center justify-between overflow-clip rounded-[12px] px-4 py-2 md:px-6">
{/* Logo and container - centered */}
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8">
<Logo className="h-5 w-auto shrink-0" />
<div className="border-subtle bg-default grid w-full max-w-[1130px] grid-cols-1 gap-6 overflow-hidden rounded-2xl border px-12 py-10 xl:h-[690px] xl:grid-cols-[40%_1fr] xl:pl-10 xl:pr-0">
<div className="flex w-full flex-1 flex-col items-center justify-center gap-6">
<Logo className="mt-4 h-5 w-auto shrink-0" />
<div className="border-subtle bg-default grid max-h-[690px] min-h-0 w-full max-w-[532px] flex-1 grid-cols-1 gap-6 overflow-hidden rounded-2xl border px-4 py-2 sm:px-12 sm:py-10 xl:h-[690px] xl:max-w-[1130px] xl:grid-cols-[40%_1fr] xl:pl-10 xl:pr-0">
{/* Column 1 - Always visible, 40% on xl+ */}
<div className="flex min-h-0 flex-col">{column1}</div>
<div className="flex h-full min-h-0 flex-col">{column1}</div>
{/* Column 2 - Hidden on mobile, visible on xl+, 60% on xl+ */}
{column2 && (
<div className="hidden h-full max-h-full min-h-0 flex-col overflow-hidden xl:flex">{column2}</div>
@@ -44,31 +44,28 @@ export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children
{/* Footer with progress dots and sign out */}
<div className="flex w-full flex-col items-center justify-center gap-4 px-10 py-8">
<div className="flex items-center gap-3">
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => {
const isCurrentStep = step === currentStep;
const isCompleted = step < currentStep;
return (
<div key={step} className="relative flex shrink-0 items-center justify-center">
<div className="flex min-h-[6px] items-center gap-1">
{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 (
<div
className={classNames("absolute rounded-full transition-all", {
"h-2 w-2": !isCurrentStep,
"h-3 w-3": isCurrentStep,
"bg-emphasis": isCompleted || isCurrentStep,
"bg-muted": !isCompleted && !isCurrentStep,
key={step}
className={classNames("shrink-0 rounded-full transition-all", {
"h-[6px] w-[6px] bg-[var(--cal-text)]": isCurrent,
"h-[4px] w-[4px] bg-[var(--cal-text-subtle)]": isPast,
"h-[4px] w-[4px] bg-[var(--cal-text-muted)] opacity-50": isUpcoming,
})}
aria-label={`Step ${step} of ${totalSteps}`}
/>
{(isCompleted || isCurrentStep) && (
<div
className={classNames("bg-emphasis absolute rounded-full", {
"h-1 w-1": !isCurrentStep,
"h-1.5 w-1.5": isCurrentStep,
})}
/>
)}
</div>
);
})}
);
})
) : (
<div className="h-[6px]" aria-hidden="true" />
)}
</div>
<Button onClick={handleSignOut} color="minimal" className="text-subtle h-7">
{t("sign_out")}
@@ -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,10 +26,27 @@ export const OnboardingBrowserView = ({
teamSlug,
}: OnboardingBrowserViewProps) => {
const { t } = useLocale();
const pathname = usePathname();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const displayUrl =
teamSlug !== undefined ? `${webappUrl}/team/${teamSlug || ""}` : `${webappUrl}/${username || ""}`;
// 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;
@@ -66,74 +85,86 @@ export const OnboardingBrowserView = ({
},
];
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<div className="bg-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{displayUrl}</p>
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Profile Header */}
<div className="border-subtle flex flex-col gap-4 border-b p-4">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-2 border-white"
/>
<div className="flex flex-col gap-2">
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{name || t("your_name")}
</h2>
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
</div>
</div>
{/* Events List */}
<div className="flex flex-col overflow-y-auto">
{events.map((event, index) => (
<div key={event.title} className="opacity-30">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
<span className="text-emphasis text-xs font-medium leading-none">
{event.duration} {t("minute_timeUnit")}
</span>
</div>
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right">
{t("book_now")}
</Button>
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-l-xl border-y border-s"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
{/* Profile Header */}
<div className="border-subtle flex flex-col gap-4 border-b p-4">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-2 border-white"
/>
<div className="flex w-full flex-col gap-2">
<h2 className="text-emphasis w-full truncate text-xl font-semibold leading-tight">
{name || t("your_name")}
</h2>
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Events List */}
<div className="flex flex-col overflow-y-auto">
{events.map((event, index) => (
<div key={event.title} className="opacity-30">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
<span className="text-emphasis text-xs font-medium leading-none">
{event.duration} {t("minute_timeUnit")}
</span>
</div>
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right" tabIndex={-1}>
{t("book_now")}
</Button>
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
@@ -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 (
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
<div className="flex items-center gap-2 px-4 py-3">
<span className="text-default text-sm font-semibold">
{dayjs(startDate).format("MMM D")}
<span className="mx-1">–</span>
{dayjs(endDate).format("MMM D, YYYY")}
</span>
</div>
{/* Calendar View */}
<div className="flex h-full flex-col overflow-hidden">
<Calendar {...calendarProps} />
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
<div className="flex items-center gap-2 px-4 py-3">
<span className="text-default text-sm font-semibold">
{dayjs(startDate).format("MMM D")}
<span className="mx-1">–</span>
{dayjs(endDate).format("MMM D, YYYY")}
</span>
</div>
{/* Calendar View */}
<div className="flex h-full flex-col overflow-hidden">
<Calendar {...calendarProps} />
</div>
</motion.div>
</AnimatePresence>
);
};
@@ -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<string>("");
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 = () => {
</button>
<div className="mb-3 pr-6">
<h3 className="text-emphasis mb-1 text-base font-semibold">Continue onboarding?</h3>
<h3 className="text-emphasis mb-1 text-base font-semibold">
{t("onboarding_continue_prompt_title")}
</h3>
<p className="text-subtle text-sm">
Would you like to carry on with onboarding for{" "}
<span className="text-emphasis font-medium">{organizationDetails.name}</span> or start over?
{t("onboarding_continue_prompt_description", { entityName: entityName })}
</p>
</div>
<div className="ml-auto flex gap-2">
<Button onClick={handleStartOver} color="secondary">
Start over
{t("onboarding_start_over")}
</Button>
<Button onClick={handleContinue} color="primary">
Continue
{t("continue")}
</Button>
</div>
</div>
@@ -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 (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<div className="h-3 w-3 rounded-full bg-gray-300" />
<div className="h-3 w-3 rounded-full bg-gray-300" />
<div className="h-3 w-3 rounded-full bg-gray-300" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<div className="h-3 w-3 rounded-full bg-gray-400" />
<p className="text-subtle text-xs font-medium leading-tight">mail.example.com</p>
</div>
</div>
<div className="border-subtle bg-muted hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border xl:flex">
{/* Content */}
<div className="bg-muted h-full pl-8 pt-8">
<div className="bg-default border-subtle flex h-full w-full flex-col overflow-hidden rounded-tl-2xl border-l border-t">
{/* Email Header */}
<div className="border-subtle bg-muted flex flex-col gap-4 border-b p-8">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={teamAvatar || undefined}
alt={displayTeamName}
className="border-default h-12 w-12 border-2"
/>
<div className="flex w-full flex-col items-start gap-1">
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
{displayInviterName} invited you to join {displayTeamName}
</h2>
<p className="text-subtle text-left text-sm font-normal leading-tight">
We're emailing you all the details
</p>
<div className="h-full px-6 pt-6">
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="flex h-full flex-col"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
<div className="bg-default border-subtle flex flex-col gap-4 rounded-2xl border p-8">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={displayName}
className="border-default h-12 w-12 border-2"
/>
<div className="flex w-full flex-col items-start gap-1">
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
{displayInviterName} invited you to join {displayName}
</h2>
<p className="text-subtle text-left text-sm font-normal leading-tight">
We&apos;re emailing you all the details
</p>
</div>
</div>
</div>
</div>
{/* Email Body */}
<div className="bg-default flex-1 rounded-bl-2xl" />
</div>
{/* Email Body */}
<div className="bg-default border-subtle mt-3 grid grid-cols-3 gap-4 rounded-t-2xl border p-6 opacity-60">
{displayItems.map((item, index) => (
<div
key={`${item.email}-${index}`}
className="bg-default border-subtle flex aspect-square w-full min-w-0 flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border p-4">
<Avatar size="mdLg" imageSrc={undefined} alt={item.name} className="mt-4" />
<div className="flex w-full min-w-0 flex-col items-center gap-4">
<div className="flex w-full min-w-0 flex-col items-center">
<p className="text-default w-full truncate text-center text-sm font-semibold leading-tight">
{item.name}
</p>
<p className="text-subtle w-full truncate text-center text-xs font-medium leading-tight">
{item.email}
</p>
</div>
{item.team && (
<div className="bg-emphasis text-emphasis rounded-md px-2 py-0.5 text-xs">
{item.team}
</div>
)}
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
@@ -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 (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<div className="bg-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{displayUrl}</p>
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Organization Profile Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
{bannerUrl ? (
<img src={bannerUrl} alt={name || ""} className="h-full w-full object-cover" />
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-l-xl border-y border-s"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
{/* Organization Profile Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-36 w-full overflow-hidden border-b">
{bannerUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={bannerUrl} alt={name || ""} className="h-full w-full object-cover" />
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Avatar - Overlaying the banner */}
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-4 border-white"
/>
</div>
</div>
{/* Organization Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{name || t("organization_name")}
</h2>
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
</div>
{/* Events List */}
<div className="flex flex-col overflow-y-hidden">
{events.map((event, index) => (
<div key={event.title} className="border opacity-30 first:border-t-0">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
<span className="text-emphasis text-xs font-medium leading-none">
{event.duration} {t("minute_timeUnit")}
</span>
</div>
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right">
{t("book_now")}
</Button>
{/* Organization Avatar - Overlaying the banner */}
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-4 border-white"
/>
</div>
</div>
))}
</div>
</div>
{/* Organization Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis truncate text-xl font-semibold leading-tight">
{name || t("organization_name")}
</h2>
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
</div>
{/* Events List */}
<div className="flex flex-col overflow-y-hidden">
{events.map((event) => (
<div key={event.title} className="border opacity-30 first:border-t-0">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
<span className="text-emphasis text-xs font-medium leading-none">
{event.duration} {t("minute_timeUnit")}
</span>
</div>
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right" tabIndex={-1}>
{t("book_now")}
</Button>
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
@@ -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 (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
@@ -43,93 +63,108 @@ export const OnboardingTeamsBrowserView = ({
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Teams Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
{organizationBanner ? (
<img
src={organizationBanner}
alt={organizationName || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-tl-xl border"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
{/* Teams Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden border-b">
{organizationBanner ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={organizationBanner}
alt={organizationName || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Logo - Overlaying the banner */}
{organizationLogo && (
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={organizationLogo}
alt={organizationName || ""}
className="border-4 border-white"
/>
</div>
)}
</div>
{/* Organization Logo - Overlaying the banner */}
{organizationLogo && (
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={organizationLogo}
alt={organizationName || ""}
className="border-4 border-white"
/>
{/* Teams Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
<p className="text-subtle text-sm leading-normal">
{hasValidTeams
? t("onboarding_teams_browser_view_subtitle")
: t("onboarding_teams_browser_view_placeholder_subtitle")}
</p>
</div>
</div>
{/* Teams List */}
<div className="flex flex-col overflow-y-auto">
{hasValidTeams ? (
teams
.filter((team) => team.name && team.name.trim().length > 0)
.map((team, index) => (
<div key={index} className="">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center gap-3 px-5 py-4">
<Avatar
size="md"
alt={team.name}
className="border-2 border-white"
fallback={
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
<span className="text-sm font-semibold">
{team.name.charAt(0).toUpperCase()}
</span>
</div>
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
<p className="text-muted text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_team_description")}
</p>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="users" className="text-subtle h-8 w-8" />
</div>
<div className="flex flex-col gap-1 text-center">
<p className="text-default truncate text-sm font-semibold leading-tight">
{t("onboarding_teams_browser_view_no_teams_title")}
</p>
<p className="text-subtle truncate text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_no_teams_description")}
</p>
</div>
</div>
)}
</div>
{/* Teams Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
<p className="text-subtle text-sm leading-normal">
{hasValidTeams
? t("onboarding_teams_browser_view_subtitle")
: t("onboarding_teams_browser_view_placeholder_subtitle")}
</p>
</div>
</div>
{/* Teams List */}
<div className="flex flex-col overflow-y-auto">
{hasValidTeams ? (
teams
.filter((team) => team.name && team.name.trim().length > 0)
.map((team, index) => (
<div key={index} className="">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center gap-3 px-5 py-4">
<Avatar
size="md"
alt={team.name}
className="border-2 border-white"
fallback={
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
<span className="text-sm font-semibold">{team.name.charAt(0).toUpperCase()}</span>
</div>
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
<p className="text-muted text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_team_description")}
</p>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="users" className="text-subtle h-8 w-8" />
</div>
<div className="flex flex-col gap-1 text-center">
<p className="text-default text-sm font-semibold leading-tight">
{t("onboarding_teams_browser_view_no_teams_title")}
</p>
<p className="text-subtle text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_no_teams_description")}
</p>
</div>
</div>
)}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
@@ -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 (
<motion.div
key={`main-icon-${variant}-${icon}`}
initial={{
opacity: 0,
y: (animationDirection === "down" ? -20 : 20) - 40, // -40 is half of 80px for centering
}}
animate={{
opacity: 1,
y: -40, // Centered position (half of 80px)
}}
exit={{
opacity: 0,
y: (animationDirection === "down" ? 20 : -20) - 40,
}}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
className="bg-default absolute flex h-[80px] w-[80px] items-center justify-center overflow-clip rounded-full"
<div
className="bg-default absolute left-1/2 top-1/2 flex h-[80px] w-[80px] -translate-x-1/2 -translate-y-1/2 items-center justify-center overflow-clip rounded-full"
style={{
x: "-50%",
left: "50%",
top: "50%",
background: "linear-gradient(to bottom, var(--cal-bg, #ffffff), var(--cal-bg-muted, #f7f7f7))",
boxShadow:
"0px 2.818px 5.635px 0px var(--cal-border-subtle), 0px 0px 0px 0.704px var(--cal-border), 0px 0.704px 3.522px -2.818px rgba(0, 0, 0, 0.3)",
@@ -73,7 +53,7 @@ export function PlanIcon({
opacity: 0.15,
}}
/>
</motion.div>
</div>
);
};
@@ -89,7 +69,7 @@ export function PlanIcon({
return (
<motion.div
key={`small-icon-${variant}-${ringIndex}-${angle}`}
key={`small-icon-${ringIndex}-${angle}`}
initial={{
opacity: 0,
y: y - iconHalfSize + (animationDirection === "down" ? -20 : 20),
@@ -104,7 +84,7 @@ export function PlanIcon({
}}
transition={{
duration: 0.5,
ease: "easeInOut",
ease: [0.34, 1.56, 0.64, 1],
delay: index * 0.05, // Stagger animation for small icons
}}
className="bg-default absolute flex h-[40.5px] w-[40.5px] items-center justify-center overflow-clip rounded-full shadow-[0px_2.075px_4.15px_0px_rgba(34,42,53,0.05),0px_0px_0px_0.519px_rgba(34,42,53,0.08),0px_0.519px_2.594px_-2.075px_rgba(19,19,22,0.7)]"
@@ -149,12 +129,15 @@ export function PlanIcon({
})}
{/* Small user icons on rings (for team variant) */}
{(variant === "team" || variant === "organization") &&
TEAM_ICON_POSITIONS.map(({ ringIndex, angle }, index) =>
renderSmallUserIcon(ringIndex, angle, index)
)}
<AnimatePresence mode="wait">
{variant === "team" || variant === "organization"
? TEAM_ICON_POSITIONS.map(({ ringIndex, angle }, index) =>
renderSmallUserIcon(ringIndex, angle, index)
)
: null}
</AnimatePresence>
{renderIconContainer(0)}
{renderIconContainer()}
</div>
);
}
@@ -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<PlanType | null>(null);
const [isPending, startTransition] = useTransition();
// Plan order mapping for determining direction
const planOrder: Record<PlanType, number> = {
@@ -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<PlanType, IconName> = {
@@ -106,15 +109,19 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={1}>
<OnboardingLayout userEmail={userEmail}>
{/* Left column - Main content */}
<OnboardingCard
title="Select plan"
title={t("onboarding_select_plan")}
subtitle={t("onboarding_welcome_question")}
footer={
<div className="flex w-full justify-end gap-2">
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={isPending}>
{isPending ? t("loading") : t("continue")}
</Button>
</div>
}>
@@ -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,
@@ -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={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
@@ -95,10 +101,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
}>
{/* Form */}
<div className="relative flex">
{/* Scrollable content container */}
<div className="relative h-full w-full gap-6 px-2 py-2">
{/* Top fade overlay */}
<div className="relative h-full w-full gap-6 py-2 pr-2">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Organization Name */}
<div className="flex w-full flex-col gap-1.5">
@@ -129,7 +132,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
onChange={(e) => setOrganizationBio(e.target.value)}
placeholder={t("onboarding_org_bio_placeholder")}
rows={4}
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
className="border-default max-h-[200px] rounded-lg border px-2 py-2 text-sm leading-tight"
/>
</div>
</div>
@@ -16,7 +16,7 @@ import { InviteOptions } from "../../../components/InviteOptions";
import { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { RoleSelector } from "../../../components/RoleSelector";
import { OnboardingOrganizationBrowserView } from "../../../components/onboarding-organization-browser-view";
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
import { useSubmitOnboarding } from "../../../hooks/useSubmitOnboarding";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "../csv-upload-modal";
@@ -53,11 +53,19 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
const teams =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
const hasTeams = teams.length > 0;
// Conditional form schema - team is optional when there are no teams
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
team: z.string().min(1, t("onboarding_team_required")),
team: hasTeams ? z.string().min(1, t("onboarding_team_required")) : z.string().optional(),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
@@ -94,7 +102,12 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
};
const handleBack = () => {
router.push("/onboarding/organization/invite");
router.push("/onboarding/organization/teams");
};
const handleSkip = async () => {
setInvites([]);
await submitOnboarding(store, userEmail, []);
};
const handleGoogleWorkspaceConnect = () => {
@@ -116,15 +129,13 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
const team = form.watch(`invites.${index}.team`);
// If there are no teams, only validate email. Otherwise, validate both email and team.
if (!hasTeams) {
return email && email.trim().length > 0;
}
return email && email.trim().length > 0 && team && team.trim().length > 0;
});
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
const teams =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
return (
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
<div className="flex h-full w-full flex-col gap-4">
@@ -132,19 +143,28 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
title={t("onboarding_org_invite_title")}
subtitle={t("onboarding_org_invite_subtitle_email")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex w-full items-center justify-between gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
@@ -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
</OnboardingCard>
</div>
<OnboardingOrganizationBrowserView
avatar={organizationBrand.logo}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={organizationBrand.banner}
/>
<OnboardingInviteBrowserView useOrganizationInvites={true} watchedInvites={form.watch("invites")} />
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</OnboardingLayout>
);
@@ -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) => {
@@ -42,7 +42,7 @@ export const InstallableAppCard = ({
title={app.name}>
{app.name}
</p>
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">{app.description}</p>
<p className="text-subtle hidden text-left text-xs leading-tight xl:line-clamp-2">{app.description}</p>
<InstallAppButtonWithoutPlanCheck
type={app.type}
options={installOptions}
@@ -1,5 +1,8 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
@@ -16,9 +19,12 @@ type PersonalCalendarViewProps = {
};
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
const { submitPersonalOnboarding, isSubmitting } = useSubmitPersonalOnboarding();
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={2}>
{/* 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={
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={queryIntegrations.isPending || isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={isSubmitting}
disabled={queryIntegrations.isPending || isSubmitting}>
{t("continue")}
<div className="flex w-full items-center justify-between gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={queryIntegrations.isPending || isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={isSubmitting}
disabled={queryIntegrations.isPending || isSubmitting}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryIntegrations.data?.items?.map((app) => (
<InstallableAppCard
key={app.slug}
app={app}
isInstalling={installingAppSlug === app.slug}
onInstallClick={setInstallingAppSlug}
installOptions={createInstallHandlers(app.slug)}
/>
))}
<div className="relative">
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="scroll-bar grid max-h-[95cqh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryIntegrations.data?.items?.map((app) => (
<InstallableAppCard
key={app.slug}
app={app}
isInstalling={installingAppSlug === app.slug}
onInstallClick={setInstallingAppSlug}
installOptions={createInstallHandlers(app.slug)}
/>
))}
</div>
{showFadeGradient && (
<div className="from-default pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t to-transparent md:h-20 xl:h-24" />
)}
</div>
</OnboardingCard>
@@ -110,7 +110,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={2}>
{/* Left column - Main content */}
<OnboardingCard
@@ -184,6 +183,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
{/* Username */}
<div className="flex w-full flex-col gap-1.5">
<UsernameAvailabilityField
disabled
onSuccessMutation={async () => {
// Refetch user to get updated username and save to store
const updatedUser = await utils.viewer.me.get.fetch();
@@ -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 (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !teamName || !teamSlug}>
{t("continue")}
</Button>
</div>
}>
<div className="flex w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !teamName || !teamSlug}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
</div>
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingCard>
</div>
{/* Right column - Browser view */}
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
@@ -79,32 +79,50 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
router.push("/onboarding/teams/invite");
};
const handleSkip = async () => {
setTeamInvites([]);
// Create the team without invites (will handle checkout redirect if needed)
await createTeam(store);
};
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
return email && email.trim().length > 0;
});
// Watch form values to pass to browser view for real-time updates
const watchedInvites = form.watch("invites");
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("invite_via_email")}
subtitle={t("team_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex w-full items-center justify-between gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="flex w-full flex-col gap-4 px-1">
@@ -135,7 +153,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} />
<OnboardingInviteBrowserView teamName={teamDetails.name} watchedInvites={watchedInvites} />
</OnboardingLayout>
);
};
@@ -55,12 +55,6 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
await createTeam(store);
};
const handleInvite = async () => {
// For now, just proceed to create the team
// The actual invites will be handled in the email page
await createTeam(store);
};
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
@@ -86,14 +80,6 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleInvite}
disabled={isSubmitting}
loading={isSubmitting}>
{t("invite")}
</Button>
</div>
</div>
}>
@@ -397,6 +397,10 @@
"welcome": "Welcome",
"welcome_back": "Welcome back",
"welcome_to_calcom": "Welcome to {{appName}}",
"personal_welcome_description": "You're all set! Start scheduling meetings and managing your calendar.",
"unlimited_event_types": "Unlimited event types",
"workflows_feature": "Workflows (or another popular feature)",
"and_more": "And more…",
"welcome_instructions": "Tell us what to call you and let us know what timezone you're in. You'll be able to edit this later.",
"connect_caldav": "Connect to CalDav (Beta)",
"connect_ics_feed": "Connect to an ICS Feed",
@@ -4015,6 +4019,17 @@
"onboarding_browser_view_ask_question": "Ask a question",
"onboarding_browser_view_ask_question_description": "Ask a question",
"onboarding_browser_view_default_bio": "Add your bio here...",
"onboarding_select_plan": "Select plan",
"onboarding_continue_prompt_title": "Continue onboarding?",
"onboarding_continue_prompt_description": "Would you like to carry on with onboarding for {{entityName}} or start over?",
"onboarding_start_over": "Start over",
"add_your_details": "Add your details",
"personal_details_subtitle": "Let's set up your personal account",
"onboarding_teams_browser_view_subtitle": "Your teams will appear here",
"onboarding_teams_browser_view_placeholder_subtitle": "Add teams to see them here",
"onboarding_teams_browser_view_team_description": "Team description",
"onboarding_teams_browser_view_no_teams_title": "No teams yet",
"onboarding_teams_browser_view_no_teams_description": "Add teams to get started",
"book_now": "Book now",
"view": "View",
"booking_response": "Booking Response",
@@ -31,125 +31,127 @@ export function WelcomeToOrganizationsModal() {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent size="default" className="!p-0">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
{/* Team illustration with rings */}
<div
className="relative mx-auto"
style={{
width: 320,
height: 220,
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
}}>
{/* Center origin */}
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
{/* Rings */}
{RINGS.map((r, i) => (
<div
key={i}
className="pointer-events-none absolute rounded-full border"
style={{
width: 2 * r,
height: 2 * r,
left: `calc(50% - ${r}px)`,
top: `calc(50% - ${r}px)`,
borderWidth: RING_STROKE,
borderColor: "var(--cal-border-subtle)",
}}
/>
))}
{/* Central users icon */}
<div
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
style={{
width: LARGE.outer,
height: LARGE.outer,
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}}>
<Icon
name="users"
className="text-emphasis opacity-70"
style={{ width: LARGE.icon, height: LARGE.icon }}
/>
</div>
{/* Surrounding user icons */}
{(
[
{ initialDeg: 30, duration: 20, icon: "user" },
{ initialDeg: 190, duration: 25, icon: "user" },
{ initialDeg: 320, duration: 15, icon: "user" },
{ initialDeg: 280, duration: 20, icon: "building" },
] as Array<{ initialDeg: number; duration: number; icon: IconName }>
).map(({ initialDeg, duration, icon }, index) => {
const r = RINGS[index % RINGS.length]; // icon orbit radius - each icon on a different ring (we have more icons than ring so we cycle through them)
const steps = 60;
const xKeyframes = [];
const yKeyframes = [];
for (let i = 0; i <= steps; i++) {
const angle = initialDeg + (360 * i) / steps;
xKeyframes.push(r * Math.cos((angle * Math.PI) / 180));
yKeyframes.push(r * Math.sin((angle * Math.PI) / 180));
}
return (
<motion.div
key={index}
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
style={{
left: "50%",
top: "50%",
width: SMALL.outer,
height: SMALL.outer,
}}
animate={{
x: xKeyframes,
y: yKeyframes,
}}
transition={{
duration,
repeat: Infinity,
ease: "linear",
}}
transformTemplate={({ x, y }) =>
// Lock the icon to the center of the ring and only translate on x and y
`translate(-50%, -50%) translateX(${x}) translateY(${y})`
}>
<Icon
name={icon}
className="text-default"
style={{ width: SMALL.icon, height: SMALL.icon }}
/>
</motion.div>
);
})}
<DialogContent size="default" className="!p-0 flex flex-col max-h-[95vh]" enableOverflow>
<div className="flex flex-1 flex-col overflow-y-auto">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("welcome_to_organizations")}</h2>
<p className="text-default text-sm leading-normal">{t("organizations_welcome_description")}</p>
</div>
{/* Team illustration with rings */}
<div
className="relative mx-auto"
style={{
width: 320,
height: 220,
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
}}>
{/* Center origin */}
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
{/* Rings */}
{RINGS.map((r, i) => (
<div
key={i}
className="pointer-events-none absolute rounded-full border"
style={{
width: 2 * r,
height: 2 * r,
left: `calc(50% - ${r}px)`,
top: `calc(50% - ${r}px)`,
borderWidth: RING_STROKE,
borderColor: "var(--cal-border-subtle)",
}}
/>
))}
<div className="mb-2 flex flex-col gap-3">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-2">
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
{/* Central users icon */}
<div
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
style={{
width: LARGE.outer,
height: LARGE.outer,
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}}>
<Icon
name="users"
className="text-emphasis opacity-70"
style={{ width: LARGE.icon, height: LARGE.icon }}
/>
</div>
{/* Surrounding user icons */}
{(
[
{ initialDeg: 30, duration: 20, icon: "user" },
{ initialDeg: 190, duration: 25, icon: "user" },
{ initialDeg: 320, duration: 15, icon: "user" },
{ initialDeg: 280, duration: 20, icon: "building" },
] as Array<{ initialDeg: number; duration: number; icon: IconName }>
).map(({ initialDeg, duration, icon }, index) => {
const r = RINGS[index % RINGS.length]; // icon orbit radius - each icon on a different ring (we have more icons than ring so we cycle through them)
const steps = 60;
const xKeyframes = [];
const yKeyframes = [];
for (let i = 0; i <= steps; i++) {
const angle = initialDeg + (360 * i) / steps;
xKeyframes.push(r * Math.cos((angle * Math.PI) / 180));
yKeyframes.push(r * Math.sin((angle * Math.PI) / 180));
}
return (
<motion.div
key={index}
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
style={{
left: "50%",
top: "50%",
width: SMALL.outer,
height: SMALL.outer,
}}
animate={{
x: xKeyframes,
y: yKeyframes,
}}
transition={{
duration,
repeat: Infinity,
ease: "linear",
}}
transformTemplate={({ x, y }) =>
// Lock the icon to the center of the ring and only translate on x and y
`translate(-50%, -50%) translateX(${x}) translateY(${y})`
}>
<Icon
name={icon}
className="text-default"
style={{ width: SMALL.icon, height: SMALL.icon }}
/>
</motion.div>
);
})}
</div>
))}
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("welcome_to_organizations")}</h2>
<p className="text-default text-sm leading-normal">{t("organizations_welcome_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-2">
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
</div>
))}
</div>
</div>
</div>
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
<div className="bg-muted border-subtle flex shrink-0 items-center justify-between rounded-b-2xl border-t px-8 py-6">
<Button
color="minimal"
href="https://cal.com/docs/organizations"
@@ -9,7 +9,7 @@ import { Logo } from "@calcom/ui/components/logo";
import { useWelcomeToCalcomModal } from "../hooks/useWelcomeToCalcomModal";
const features = ["1_user", "unlimited_calendars", "accept_payments_via_stripe"];
const features = ["unlimited_event_types", "workflows_feature", "and_more"];
export function WelcomeToCalcomModal() {
const { t } = useLocale();
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { act } from "react-dom/test-utils";
import { vi } from "vitest";
@@ -43,9 +43,9 @@ describe("Tests for ColorPicker component", () => {
fireEvent.change(colorPickerInput, { target: { value: "#000000" } });
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(colorPickerButton).toHaveStyle("background-color: #000000");
await waitFor(() => {
expect(colorPickerButton).toHaveStyle("background-color: #000000");
});
});
test("Should change the color value using the input field", async () => {
@@ -35,7 +35,7 @@ const ColorPicker = (props: ColorPickerProps) => {
<div className="border-default min-w-9 flex h-full items-center justify-center border ltr:rounded-l-md ltr:border-r-0 rtl:rounded-r-md rtl:border-l-0">
<Popover.Trigger asChild>
<button
className="h-5 w-5 rounded-sm"
className="relative h-5 w-5 rounded-sm after:absolute after:inset-[-8px] after:content-['']"
aria-label="pick colors"
style={{ backgroundColor: color }}
/>
@@ -69,7 +69,7 @@ type AddonProps = {
children: React.ReactNode;
className?: string;
error?: boolean;
onClickAddon?: () => void;
onClickAddon?: (e: React.MouseEvent<HTMLDivElement>) => void;
size?: "sm" | "md";
position?: "start" | "end";
};
@@ -125,7 +125,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
readOnly,
showAsteriskIndicator,
onClickAddon,
t: __t,
dataTestid,
size,
@@ -134,6 +134,10 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
const [inputValue, setInputValue] = useState<string>("");
const handleFocusInput = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.parentElement?.querySelector("input")?.focus();
};
return (
<div className={classNames(containerClassName)}>
{!!name && !noLabel && (
@@ -159,7 +163,11 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
inputIsFullWidth && "w-full"
)}>
{addOnLeading && (
<Addon size={size ?? "md"} position="start" className={classNames(addOnClassname)}>
<Addon
size={size ?? "md"}
position="start"
className={classNames(addOnClassname)}
onClickAddon={handleFocusInput}>
{addOnLeading}
</Addon>
)}
@@ -180,7 +188,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
{...(type == "search" && {
onChange: (e) => {
setInputValue(e.target.value);
props.onChange && props.onChange(e);
props.onChange?.(e);
},
value: inputValue,
})}
@@ -191,7 +199,10 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
<Addon
size={size ?? "md"}
position="end"
onClickAddon={onClickAddon}
onClickAddon={(e) => {
handleFocusInput(e);
onClickAddon?.(e);
}}
className={classNames(addOnClassname)}>
{addOnSuffix}
</Addon>
@@ -202,7 +213,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
className="text-subtle absolute top-2.5 h-4 w-4 cursor-pointer ltr:right-2 rtl:left-2"
onClick={(e) => {
setInputValue("");
props.onChange && props.onChange(e as unknown as React.ChangeEvent<HTMLInputElement>);
props.onChange?.(e as unknown as React.ChangeEvent<HTMLInputElement>);
}}
/>
)}
+1 -1
View File
@@ -21,7 +21,7 @@ export type InputFieldProps<Translations extends Record<string, string> = object
placeholder?: string;
dataTestid?: string;
noLabel?: boolean;
onClickAddon?: () => void;
onClickAddon?: (e: React.MouseEvent<HTMLDivElement>) => void;
} & InputProps & {
labelProps?: React.ComponentProps<typeof Label>;
labelClassName?: string;