feat: organization v3 redesign onboarding (#24967)
## What does this PR do? - Redesigns the organization onboarding flow by merging the brand and details pages - Improves the organization details page with a scrollable interface and visual previews - Adds a new organization-specific browser preview component ## Visual Demo (For contributors especially) #### Image Demo: - The PR replaces the separate brand page with an integrated details page that includes logo and banner uploads - The new organization browser view shows a preview of the organization profile with the selected branding ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code. - [ ] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox. - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? - Go through the organization onboarding flow - Test uploading logos and banners - Verify that the organization browser preview updates in real-time with the form inputs - Confirm that the form validation works correctly for organization name and slug - Check that the scrollable interface works properly with fade effects at top and bottom ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Split organization brand from the details step and added live previews for organizations and teams. Revamped org/team invites with reusable components, a dedicated email-invite page, and a CSV upload modal. - **New Features** - Separate Brand step with logo, banner, and color; instant org preview via OnboardingOrganizationBrowserView. - Teams browser preview added; invites include email substep (/onboarding/organization/invite/email, /onboarding/teams/invite/email), CSV upload (template + parsing), and Google Workspace (behind flag). - Shared components (EmailInviteForm, InviteOptions, RoleSelector) used across org and team invites. - **Refactors** - Updated org flow: Details → Brand → Teams → Invites; OnboardingLayout now supports dynamic step counts (org=4, team=3, personal=2). - UI polish (OnboardingCard header padding) and org-specific previews now replace generic views across details/brand/invites/teams; ensured org welcome modal takes precedence over personal. <sup>Written for commit d9b55c0b5505aa0d4ca1c4298a513bcd90606915. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -33,3 +32,4 @@ const ServerPage = async () => {
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { OrganizationInviteEmailView } from "~/onboarding/organization/invite/email/organization-invite-email-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/invite/email"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <OrganizationInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/invite/email"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, Select } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { InviteRole } from "../store/onboarding-store";
|
||||
|
||||
type BaseInviteFormData = {
|
||||
email: string;
|
||||
role: InviteRole;
|
||||
};
|
||||
|
||||
type InviteFormDataWithOptionalTeam = BaseInviteFormData & {
|
||||
team?: string;
|
||||
};
|
||||
|
||||
type InviteFormDataWithRequiredTeam = BaseInviteFormData & {
|
||||
team: string;
|
||||
};
|
||||
|
||||
type InviteFormData = InviteFormDataWithOptionalTeam | InviteFormDataWithRequiredTeam;
|
||||
|
||||
type EmailInviteFormProps = {
|
||||
fields: Array<{ id: string }>;
|
||||
append: (value: InviteFormData) => void;
|
||||
remove: (index: number) => void;
|
||||
defaultRole: InviteRole;
|
||||
showTeamSelect?: boolean;
|
||||
teams?: Array<{ value: string; label: string }>;
|
||||
emailPlaceholder?: string;
|
||||
};
|
||||
|
||||
export function EmailInviteForm({
|
||||
fields,
|
||||
append,
|
||||
remove,
|
||||
defaultRole,
|
||||
showTeamSelect = false,
|
||||
teams = [],
|
||||
emailPlaceholder,
|
||||
}: EmailInviteFormProps) {
|
||||
const { t } = useLocale();
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{showTeamSelect ? (
|
||||
<div className="grid grid-cols-2">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
) : (
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
showTeamSelect ? "flex flex-col gap-2" : "scroll-bar flex max-h-72 flex-col gap-1 overflow-y-auto"
|
||||
}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5 p-0.5">
|
||||
<div className={showTeamSelect ? "grid flex-1 items-start gap-2 md:grid-cols-2" : "flex-1"}>
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...register(`invites.${index}.email`)}
|
||||
placeholder={emailPlaceholder || `rick@cal.com`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
{showTeamSelect && (
|
||||
<Select
|
||||
size="sm"
|
||||
options={teams}
|
||||
value={teams.find((t) => t.value === watch(`invites.${index}.team`))}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
setValue(`invites.${index}.team`, option.value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("select_team")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className={showTeamSelect ? "mt-2 w-fit" : "w-fit"}
|
||||
onClick={() =>
|
||||
append(
|
||||
showTeamSelect ? { email: "", team: "", role: defaultRole } : { email: "", role: defaultRole }
|
||||
)
|
||||
}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_4178_176214)">
|
||||
<path
|
||||
d="M8.31875 15.36C4.26 15.36 0.9575 12.0588 0.9575 8.00001C0.9575 3.94126 4.26 0.640015 8.31875 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.2525 4.07376 8.31875 4.07376C6.15375 4.07376 4.39125 5.83501 4.39125 8.00001C4.39125 10.165 6.15375 11.9263 8.31875 11.9263C9.88 11.9263 11.1138 11.1288 11.695 9.77001H7.99875V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31875 15.36Z"
|
||||
fill="#6B7280"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4178_176214">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
type InviteOptionsProps = {
|
||||
onInviteViaEmail: () => void;
|
||||
onUploadCSV?: () => void;
|
||||
onCopyInviteLink?: () => void;
|
||||
onConnectGoogleWorkspace?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
};
|
||||
|
||||
export const InviteOptions = ({
|
||||
onInviteViaEmail,
|
||||
onUploadCSV,
|
||||
onCopyInviteLink,
|
||||
onConnectGoogleWorkspace,
|
||||
isSubmitting = false,
|
||||
}: InviteOptionsProps) => {
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-1 flex-col gap-6 ">
|
||||
{googleWorkspaceEnabled && onConnectGoogleWorkspace && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full rounded-[10px]"
|
||||
CustomStartIcon={<GoogleIcon />}
|
||||
onClick={onConnectGoogleWorkspace}
|
||||
disabled={isSubmitting}>
|
||||
{t("connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
<span className="text-subtle text-sm font-medium">{t("or")}</span>
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-1 flex-col gap-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={onInviteViaEmail}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="mail" className="h-4 w-4" />
|
||||
<span>{t("invite_via_email")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{onUploadCSV && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={onUploadCSV}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="upload" className="h-4 w-4" />
|
||||
<span>{t("upload_csv_file")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCopyInviteLink && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={onCopyInviteLink}
|
||||
disabled>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="link" className="h-4 w-4" />
|
||||
<span>{t("copy_invite_link")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
|
||||
<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" />
|
||||
@@ -38,4 +38,3 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Children, type ReactNode } from "react";
|
||||
|
||||
@@ -9,11 +10,12 @@ import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
type OnboardingLayoutProps = {
|
||||
userEmail: string;
|
||||
currentStep: 1 | 2 | 3;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children }: OnboardingLayoutProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleSignOut = () => {
|
||||
@@ -42,17 +44,31 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
|
||||
|
||||
{/* 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-2">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="relative flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full ${
|
||||
step <= currentStep ? "bg-emphasis" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
{step <= currentStep && <div className="bg-emphasis absolute h-1 w-1 rounded-full" />}
|
||||
</div>
|
||||
))}
|
||||
<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={classNames("absolute rounded-full transition-all", {
|
||||
"h-2 w-2": !isCurrentStep,
|
||||
"h-3 w-3": isCurrentStep,
|
||||
"bg-emphasis": isCompleted || isCurrentStep,
|
||||
"bg-muted": !isCompleted && !isCurrentStep,
|
||||
})}
|
||||
/>
|
||||
{(isCompleted || isCurrentStep) && (
|
||||
<div
|
||||
className={classNames("bg-emphasis absolute rounded-full", {
|
||||
"h-1 w-1": !isCurrentStep,
|
||||
"h-1.5 w-1.5": isCurrentStep,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button onClick={handleSignOut} color="minimal" className="text-subtle h-7">
|
||||
{t("sign_out")}
|
||||
@@ -61,4 +77,3 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { InfoBadge } from "@calcom/ui/components/badge";
|
||||
|
||||
import type { InviteRole } from "../store/onboarding-store";
|
||||
|
||||
type RoleSelectorProps = {
|
||||
value: InviteRole;
|
||||
onValueChange: (value: InviteRole) => void;
|
||||
showInfoBadge?: boolean;
|
||||
};
|
||||
|
||||
export const RoleSelector = ({ value, onValueChange, showInfoBadge = false }: RoleSelectorProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={value}
|
||||
onValueChange={(val) => val && onValueChange(val as InviteRole)}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
{showInfoBadge && <InfoBadge content={t("onboarding_modify_roles_later")} />}
|
||||
</div>
|
||||
{!showInfoBadge && (
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -95,7 +97,11 @@ export const OnboardingBrowserView = ({
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">
|
||||
{name || t("your_name")}
|
||||
</h2>
|
||||
<p className="text-default text-sm leading-normal">
|
||||
<p
|
||||
className={classNames("text-sm leading-normal", {
|
||||
"text-default": bio,
|
||||
"text-subtle italic": !bio,
|
||||
})}>
|
||||
{bio || t("onboarding_browser_view_default_bio")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
type OnboardingOrganizationBrowserViewProps = {
|
||||
avatar?: string | null;
|
||||
name?: string;
|
||||
bio?: string;
|
||||
slug?: string;
|
||||
bannerUrl?: string | null;
|
||||
};
|
||||
|
||||
export const OnboardingOrganizationBrowserView = ({
|
||||
avatar,
|
||||
name,
|
||||
bio,
|
||||
slug,
|
||||
bannerUrl,
|
||||
}: OnboardingOrganizationBrowserViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
const displayUrl = `${webappUrl}/${slug || ""}`;
|
||||
|
||||
const events: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
title: t("onboarding_browser_view_demo"),
|
||||
description: t("onboarding_browser_view_demo_description"),
|
||||
duration: 15,
|
||||
icon: "bell",
|
||||
},
|
||||
{
|
||||
title: t("onboarding_browser_view_quick_meeting"),
|
||||
description: t("onboarding_browser_view_quick_meeting_description"),
|
||||
duration: 15,
|
||||
icon: "bell",
|
||||
},
|
||||
{
|
||||
title: t("onboarding_browser_view_longer_meeting"),
|
||||
description: t("onboarding_browser_view_longer_meeting_description"),
|
||||
duration: 30,
|
||||
icon: "clock",
|
||||
},
|
||||
{
|
||||
title: t("in_person_meeting"),
|
||||
description: t("onboarding_browser_view_in_person_description"),
|
||||
duration: 120,
|
||||
icon: "map-pin",
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<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">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default 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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
type OnboardingTeamsBrowserViewProps = {
|
||||
teams: Array<{ name: string }>;
|
||||
organizationLogo?: string | null;
|
||||
organizationName?: string;
|
||||
organizationBanner?: string | null;
|
||||
};
|
||||
|
||||
export const OnboardingTeamsBrowserView = ({
|
||||
teams,
|
||||
organizationLogo,
|
||||
organizationName,
|
||||
organizationBanner,
|
||||
}: OnboardingTeamsBrowserViewProps) => {
|
||||
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);
|
||||
|
||||
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">
|
||||
<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">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/teams</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">
|
||||
{/* 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" />
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -106,7 +106,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={1}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title="Select plan"
|
||||
|
||||
@@ -4,9 +4,12 @@ import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/us
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { sessionStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const ORG_MODAL_STORAGE_KEY = "showNewOrgModal";
|
||||
|
||||
const DEFAULT_EVENT_TYPES = [
|
||||
{
|
||||
title: "15min_meeting",
|
||||
@@ -56,9 +59,19 @@ export const useSubmitPersonalOnboarding = () => {
|
||||
}
|
||||
|
||||
await utils.viewer.me.get.refetch();
|
||||
// Set flag to show welcome modal after redirect
|
||||
setShowWelcomeToCalcomModalFlag();
|
||||
router.push("/event-types?welcomeToCalcomModal=true");
|
||||
// Check if org modal flag is set - if so, don't show personal modal
|
||||
// Organization onboarding takes precedence
|
||||
const hasOrgModalFlag = sessionStorage.getItem(ORG_MODAL_STORAGE_KEY) === "true";
|
||||
|
||||
if (!hasOrgModalFlag) {
|
||||
// Only set personal modal flag if org modal flag is not set
|
||||
setShowWelcomeToCalcomModalFlag();
|
||||
router.push("/event-types?welcomeToCalcomModal=true");
|
||||
} else {
|
||||
// Org modal flag exists - redirect to event-types without personal modal query param
|
||||
// The org modal will show via sessionStorage check
|
||||
router.push("/event-types?newOrganizationModal=true");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
|
||||
@@ -1,77 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ColorPicker, Label } from "@calcom/ui/components/form";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationBrandViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const BrandColorPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="border-default bg-default flex h-7 w-32 items-center gap-2 rounded-lg border px-2 py-1.5">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="h-4 w-4 shrink-0 rounded-full border border-gray-200"
|
||||
style={{ backgroundColor: value }}
|
||||
aria-label={t("onboarding_pick_color_aria")}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content align="start" sideOffset={5}>
|
||||
<HexColorPicker color={value} onChange={onChange} className="!h-32 !w-32" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<input
|
||||
type="text"
|
||||
value={value.replace("#", "")}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`;
|
||||
onChange(newValue);
|
||||
}}
|
||||
className="text-emphasis grow border-none bg-transparent text-sm font-medium leading-4 outline-none"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { organizationBrand, setOrganizationBrand, organizationDetails } = useOnboardingStore();
|
||||
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
|
||||
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [_bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setBrandColor(organizationBrand.color);
|
||||
setLogoPreview(organizationBrand.logo);
|
||||
setBannerPreview(organizationBrand.banner);
|
||||
setBrandColor(organizationBrand.color);
|
||||
}, [organizationBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
@@ -106,129 +66,116 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
// Save brand color to store
|
||||
setOrganizationBrand({ color: brandColor });
|
||||
router.push("/onboarding/organization/teams");
|
||||
const handleColorChange = (color: string) => {
|
||||
setBrandColor(color);
|
||||
setOrganizationBrand({ color });
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip brand setup and go to teams
|
||||
const handleContinue = () => {
|
||||
// Save to store (already saved on change, but ensure it's persisted)
|
||||
setOrganizationBrand({
|
||||
logo: logoPreview,
|
||||
banner: bannerPreview,
|
||||
color: brandColor,
|
||||
});
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_brand_title")}
|
||||
subtitle={t("onboarding_org_brand_subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/organization/details")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-4">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
|
||||
{t("onboarding_primary_color_label")}
|
||||
</p>
|
||||
<BrandColorPicker
|
||||
value={brandColor}
|
||||
onChange={(value) => {
|
||||
setBrandColor(value);
|
||||
setOrganizationBrand({ color: value });
|
||||
}}
|
||||
t={t}
|
||||
<div className="relative flex">
|
||||
{/* Scrollable content container */}
|
||||
<div className="relative h-full w-full gap-6">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_logo_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_banner_label")}
|
||||
</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("light_brand_color")}
|
||||
</Label>
|
||||
<ColorPicker defaultValue={brandColor} onChange={handleColorChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,7 +183,13 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={logoPreview}
|
||||
name={organizationDetails.name}
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={bannerPreview}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
@@ -77,60 +77,60 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_details_title")}
|
||||
subtitle={t("onboarding_org_details_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex">
|
||||
{/* Scrollable content container */}
|
||||
<div className="relative h-full w-full gap-6 px-2 py-2">
|
||||
{/* Top fade overlay */}
|
||||
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +138,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={null}
|
||||
name={organizationName}
|
||||
bio={organizationBio}
|
||||
slug={organizationLink}
|
||||
bannerUrl={null}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useOnboardingStore, type Invite } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationCSVUploadModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const OrganizationCSVUploadModal = ({ isOpen, onClose }: OrganizationCSVUploadModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const store = useOnboardingStore();
|
||||
const { setInvites, teams } = store;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
showToast(t("please_upload_csv_file"), "error");
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const headers = ["email", "team", "role"];
|
||||
const exampleRow = ["example@email.com", "team-name", "MEMBER"];
|
||||
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "organization_invite_template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast(t("template_downloaded"), "success");
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
showToast(t("please_select_file"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const text = await selectedFile.text();
|
||||
const lines = text.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const emailIndex = headers.indexOf("email");
|
||||
const teamIndex = headers.indexOf("team");
|
||||
const roleIndex = headers.indexOf("role");
|
||||
|
||||
if (emailIndex === -1) {
|
||||
showToast(t("csv_missing_email_column"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredTeams = teams.filter((team) => team.name && team.name.trim().length > 0);
|
||||
const teamNames = filteredTeams.map((team) => team.name.toLowerCase());
|
||||
|
||||
const parsedInvites = lines
|
||||
.slice(1)
|
||||
.map((line) => {
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
const email = values[emailIndex]?.trim();
|
||||
const team = teamIndex !== -1 ? values[teamIndex]?.trim() : "";
|
||||
const role =
|
||||
roleIndex !== -1 ? (values[roleIndex]?.trim().toUpperCase() as "MEMBER" | "ADMIN") : "MEMBER";
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (teamIndex !== -1 && team && !teamNames.includes(team.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
team: team || (filteredTeams.length > 0 ? filteredTeams[0].name.toLowerCase() : ""),
|
||||
role: role === "ADMIN" ? "ADMIN" : "MEMBER",
|
||||
};
|
||||
})
|
||||
.filter((invite): invite is Invite => invite !== null);
|
||||
|
||||
if (parsedInvites.length === 0) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setInvites(parsedInvites);
|
||||
|
||||
showToast(t("csv_uploaded_successfully", { count: parsedInvites.length }), "success");
|
||||
onClose();
|
||||
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
} catch (error) {
|
||||
console.error("Error uploading CSV:", error);
|
||||
showToast(t("error_uploading_csv"), "error");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<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>
|
||||
|
||||
<div className="relative mx-auto flex items-center justify-center" style={{ width: 320, height: 180 }}>
|
||||
<div
|
||||
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
|
||||
style={{ width: 200, height: 150 }}>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon name="upload" className="text-emphasis opacity-70" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
|
||||
</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("upload_csv_file")}</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
|
||||
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
|
||||
</div>
|
||||
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
|
||||
{t("download_template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{selectedFile ? selectedFile.name : t("upload_csv_description")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon={selectedFile ? "file-text" : "upload"}
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
{t("choose_file")}
|
||||
</Button>
|
||||
</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">
|
||||
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
loading={isUploading}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
import { EmailInviteForm } from "../../../components/EmailInviteForm";
|
||||
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 { useSubmitOnboarding } from "../../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
|
||||
import { OrganizationCSVUploadModal } from "../csv-upload-modal";
|
||||
|
||||
type OrganizationInviteEmailViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
team: string;
|
||||
role: "MEMBER" | "ADMIN";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEmailViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
const {
|
||||
invites: storedInvites,
|
||||
inviteRole,
|
||||
setInvites,
|
||||
setInviteRole,
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
} = store;
|
||||
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
|
||||
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
|
||||
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
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")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields,
|
||||
append: appendField,
|
||||
remove,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
// Wrapper to ensure team is always provided when appending
|
||||
const append = (value: { email: string; team?: string; role: InviteRole }) => {
|
||||
appendField({
|
||||
email: value.email,
|
||||
team: value.team || "",
|
||||
role: value.role,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContinue = async (data: FormValues) => {
|
||||
setInvites(data.invites);
|
||||
await submitOnboarding(store, userEmail, data.invites);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/onboarding/organization/invite");
|
||||
};
|
||||
|
||||
const handleGoogleWorkspaceConnect = () => {
|
||||
console.log("Connect Google Workspace");
|
||||
};
|
||||
|
||||
const handleUploadCSV = () => {
|
||||
setIsCSVModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = () => {
|
||||
console.log("Copy invite link - disabled");
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
const team = form.watch(`invites.${index}.team`);
|
||||
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">
|
||||
<OnboardingCard
|
||||
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">
|
||||
<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>
|
||||
}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<Form form={form} handleSubmit={handleContinue} className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<EmailInviteForm
|
||||
fields={fields}
|
||||
append={append}
|
||||
remove={remove}
|
||||
defaultRole={inviteRole}
|
||||
showTeamSelect
|
||||
teams={teams}
|
||||
emailPlaceholder={`dave@${usersEmailDomain}`}
|
||||
/>
|
||||
|
||||
<RoleSelector
|
||||
value={inviteRole}
|
||||
showInfoBadge={true}
|
||||
onValueChange={(value) => {
|
||||
setInviteRole(value);
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={organizationBrand.logo}
|
||||
name={organizationDetails.name}
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={organizationBrand.banner}
|
||||
/>
|
||||
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,307 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { InviteOptions } from "../../components/InviteOptions";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { OrganizationCSVUploadModal } from "./csv-upload-modal";
|
||||
|
||||
type OrganizationInviteViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
team: string;
|
||||
role: "MEMBER" | "ADMIN";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
// We are using email mode for now until we implement the other invite methods, csv, workspace - invite link
|
||||
const isEmailMode = true;
|
||||
const flags = useFlags();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
|
||||
const { setInvites, organizationDetails, organizationBrand } = store;
|
||||
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
|
||||
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
|
||||
|
||||
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")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
|
||||
// Save invites to store before submitting
|
||||
setInvites(invitesData);
|
||||
await submitOnboarding(store, userEmail, invitesData);
|
||||
};
|
||||
|
||||
const handleContinue = async (data?: FormValues) => {
|
||||
if (data) {
|
||||
await handleSubmitOnboarding(data.invites);
|
||||
} else {
|
||||
await handleSubmitOnboarding([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
// Complete onboarding without invites
|
||||
await handleSubmitOnboarding([]);
|
||||
const handleGoogleWorkspaceConnect = () => {
|
||||
console.log("Connect Google Workspace");
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/organization/invite?mode=email");
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
const team = form.watch(`invites.${index}.team`);
|
||||
return email && email.trim().length > 0 && team && team.trim().length > 0;
|
||||
});
|
||||
const handleUploadCSV = () => {
|
||||
setIsCSVModalOpen(true);
|
||||
};
|
||||
|
||||
// Get teams from store, filter out empty teams
|
||||
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 handleCopyInviteLink = () => {
|
||||
console.log("Copy invite link - disabled");
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setInvites([]);
|
||||
await submitOnboarding(store, userEmail, []);
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
await submitOnboarding(store, userEmail, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="grid w-full max-w-[1200px] grid-cols-1 gap-6 lg:grid-cols-[1fr_auto]">
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* 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">
|
||||
{t("onboarding_org_invite_title")}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{isEmailMode
|
||||
? t("onboarding_org_invite_subtitle_email")
|
||||
: t("onboarding_org_invite_subtitle_full")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
{!isEmailMode ? (
|
||||
// Coming soon
|
||||
// Initial invite options view
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
|
||||
{t("onboarding_connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-subtle h-px flex-1" />
|
||||
<span className="text-subtle text-xs">{t("onboarding_or_divider")}</span>
|
||||
<div className="bg-subtle h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="flex-1 justify-center"
|
||||
onClick={handleInviteViaEmail}>
|
||||
{t("invite_via_email")}
|
||||
</Button>
|
||||
<Button color="secondary" className="flex-1 justify-center" StartIcon="upload">
|
||||
{t("onboarding_upload_csv")}
|
||||
</Button>
|
||||
<Button color="secondary" className="flex-1 justify-center" StartIcon="link">
|
||||
{t("onboarding_copy_invite_link")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
options={[
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Email invite form
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email and Team inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`dave@${usersEmailDomain}`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
options={teams}
|
||||
value={teams.find((t) => t.value === form.watch(`invites.${index}.team`))}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
form.setValue(`invites.${index}.team`, option.value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("select_team")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
type={isEmailMode ? "submit" : "button"}
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={(isEmailMode && !hasValidInvites) || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_invite_title")}
|
||||
subtitle={t("onboarding_org_invite_subtitle_full")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/organization/teams")}
|
||||
disabled={isSubmitting}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
</div>
|
||||
}>
|
||||
<InviteOptions
|
||||
onInviteViaEmail={handleInviteViaEmail}
|
||||
onUploadCSV={handleUploadCSV}
|
||||
onCopyInviteLink={handleCopyInviteLink}
|
||||
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingOrganizationBrowserView
|
||||
avatar={organizationBrand.logo}
|
||||
name={organizationDetails.name}
|
||||
bio={organizationDetails.bio}
|
||||
slug={organizationDetails.link}
|
||||
bannerUrl={organizationBrand.banner}
|
||||
/>
|
||||
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,9 +10,9 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationTeamsViewProps = {
|
||||
@@ -26,7 +26,7 @@ type FormValues = {
|
||||
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teams: storedTeams, setTeams } = useOnboardingStore();
|
||||
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
|
||||
|
||||
const formSchema = z.object({
|
||||
teams: z.array(
|
||||
@@ -65,29 +65,38 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_teams_title")}
|
||||
subtitle={t("onboarding_org_teams_subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
form="teams-form"
|
||||
color="primary"
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
onClick={() => router.push("/onboarding/organization/brand")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="teams-form"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
<div className="w-full ">
|
||||
<div className="flex w-full flex-col gap-8 ">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
|
||||
@@ -136,7 +145,12 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
<OnboardingTeamsBrowserView
|
||||
teams={form.watch("teams")}
|
||||
organizationLogo={organizationBrand.logo}
|
||||
organizationName={organizationDetails.name}
|
||||
organizationBanner={organizationBrand.banner}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("connect_your_calendar")}
|
||||
|
||||
@@ -111,7 +111,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("add_your_details")}
|
||||
@@ -139,7 +139,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
<form
|
||||
id="personal-settings-form"
|
||||
onSubmit={handleContinue}
|
||||
className="flex w-full flex-col gap-6 px-5">
|
||||
className="flex w-full flex-col gap-6 px-1">
|
||||
{/* Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_picture")}</Label>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("create_your_team")}
|
||||
@@ -106,7 +106,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-5">
|
||||
<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>
|
||||
|
||||
@@ -7,13 +7,13 @@ import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { InfoBadge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
import { EmailInviteForm } from "../../../components/EmailInviteForm";
|
||||
import { OnboardingCard } from "../../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../../components/OnboardingLayout";
|
||||
import { RoleSelector } from "../../../components/RoleSelector";
|
||||
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
|
||||
import { useCreateTeam } from "../../../hooks/useCreateTeam";
|
||||
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
|
||||
@@ -85,7 +85,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
@@ -107,74 +107,27 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-5">
|
||||
<div className="flex w-full flex-col gap-4 px-1">
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
<EmailInviteForm
|
||||
fields={fields}
|
||||
append={append}
|
||||
remove={remove}
|
||||
defaultRole={inviteRole}
|
||||
emailPlaceholder="rick@cal.com"
|
||||
/>
|
||||
|
||||
<div className="scroll-bar flex max-h-72 flex-col gap-2 overflow-y-auto">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`rick@cal.com`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ email: "", role: inviteRole })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setInviteRole(value as InviteRole);
|
||||
// Update all invites with the new role
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value as InviteRole);
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
<InfoBadge content={t("onboarding_modify_roles_later")} />
|
||||
</div>
|
||||
</div>
|
||||
<RoleSelector
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
setInviteRole(value);
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value);
|
||||
});
|
||||
}}
|
||||
showInfoBadge
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import React from "react";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { InviteOptions } from "../../components/InviteOptions";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingInviteBrowserView } from "../../components/onboarding-invite-browser-view";
|
||||
@@ -63,7 +63,7 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
@@ -97,63 +97,13 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-6 px-5">
|
||||
{/* Google Workspace Connect - Only show if feature flag is enabled */}
|
||||
{googleWorkspaceEnabled && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full rounded-[10px]"
|
||||
onClick={handleGoogleWorkspaceConnect}
|
||||
disabled={isSubmitting}>
|
||||
{t("connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
{/* Divider with "or" */}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
<span className="text-subtle text-sm font-medium">{t("or")}</span>
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invite options */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleInviteViaEmail}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="mail" className="h-4 w-4" />
|
||||
<span>{t("invite_via_email")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleUploadCSV}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="upload" className="h-4 w-4" />
|
||||
<span>{t("upload_csv_file")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="link" className="h-4 w-4" />
|
||||
<span>{t("copy_invite_link")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<InviteOptions
|
||||
onInviteViaEmail={handleInviteViaEmail}
|
||||
onUploadCSV={handleUploadCSV}
|
||||
onCopyInviteLink={handleCopyInviteLink}
|
||||
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3998,7 +3998,7 @@
|
||||
"onboarding_browser_view_in_person_description": "Meet in person",
|
||||
"onboarding_browser_view_ask_question": "Ask a question",
|
||||
"onboarding_browser_view_ask_question_description": "Ask a question",
|
||||
"onboarding_browser_view_default_bio": "Find a time that suits you",
|
||||
"onboarding_browser_view_default_bio": "Add your bio here...",
|
||||
"book_now": "Book now",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { sessionStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
const STORAGE_KEY = "showWelcomeToCalcomModal";
|
||||
const ORG_MODAL_STORAGE_KEY = "showNewOrgModal";
|
||||
|
||||
export function useWelcomeToCalcomModal() {
|
||||
const [welcomeToCalcomModal, setWelcomeToCalcomModal] = useQueryState(
|
||||
@@ -14,6 +15,13 @@ export function useWelcomeToCalcomModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't show personal modal if org modal flag is set (org modal takes precedence)
|
||||
const hasOrgModalFlag = sessionStorage.getItem(ORG_MODAL_STORAGE_KEY) === "true";
|
||||
if (hasOrgModalFlag) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (welcomeToCalcomModal) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user