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:
@@ -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'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>
|
||||
|
||||
+41
-27
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user