* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main: - Rebrand Cal.com to Cal.diy across the entire codebase - Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions - Switch license from AGPL-3.0 to MIT - Remove docs/ directory (migrated to Nextra at cal.diy) - Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc. - Clean up .env.example for self-hosted Cal.diy - Update Docker image references to calcom/cal.diy - Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork - Add PR welcome bot for Cal.diy contributors - Fix API v2 breaking changes oasdiff ignore entries - Replace Blacksmith CI runners with default GitHub Actions 3893 files changed, 20789 insertions(+), 411020 deletions(-) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * rip out org related comments in api v2 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
231 lines
8.6 KiB
TypeScript
231 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import classNames from "@calcom/ui/classNames";
|
|
import { Badge } from "@calcom/ui/components/badge";
|
|
import { Button } from "@calcom/ui/components/button";
|
|
import type { IconName } from "@calcom/ui/components/icon";
|
|
import { RadioAreaGroup } from "@calcom/ui/components/radio";
|
|
import { AnimatePresence } from "framer-motion";
|
|
import { useRouter } from "next/navigation";
|
|
import posthog from "posthog-js";
|
|
import { useEffect, useRef, useTransition } from "react";
|
|
import { OnboardingCard } from "../components/OnboardingCard";
|
|
import { OnboardingLayout } from "../components/OnboardingLayout";
|
|
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
|
|
import { PlanIcon } from "../components/plan-icon";
|
|
import { type PlanType, useOnboardingStore } from "../store/onboarding-store";
|
|
|
|
type OnboardingViewProps = {
|
|
userEmail: string;
|
|
};
|
|
|
|
export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
|
|
const router = useRouter();
|
|
const { t } = useLocale();
|
|
const { selectedPlan, setSelectedPlan, resetOnboardingPreservingPlan } = useOnboardingStore();
|
|
const previousPlanRef = useRef<PlanType | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
const hasTeamMembership = false;
|
|
const isPendingMembership = false;
|
|
|
|
// Reset onboarding data when visiting this page, but preserve the selected plan
|
|
useEffect(() => {
|
|
resetOnboardingPreservingPlan();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [resetOnboardingPreservingPlan]);
|
|
|
|
// If user has any team membership (pending or accepted), redirect them directly to personal onboarding
|
|
// This handles the case where users sign up with an invite token (membership is auto-accepted)
|
|
useEffect(() => {
|
|
if (!isPendingMembership && hasTeamMembership) {
|
|
setSelectedPlan("personal");
|
|
startTransition(() => {
|
|
router.push("/onboarding/personal/settings");
|
|
});
|
|
}
|
|
}, [isPendingMembership, hasTeamMembership, router, setSelectedPlan]);
|
|
|
|
// Plan order mapping for determining direction
|
|
const planOrder: Record<PlanType, number> = {
|
|
personal: 0,
|
|
team: 1,
|
|
organization: 2,
|
|
};
|
|
|
|
// Calculate animation direction synchronously
|
|
const getDirection = (): "up" | "down" => {
|
|
if (!selectedPlan || !previousPlanRef.current) return "down";
|
|
const previousOrder = planOrder[previousPlanRef.current];
|
|
const currentOrder = planOrder[selectedPlan];
|
|
return currentOrder > previousOrder ? "up" : "down";
|
|
};
|
|
|
|
const direction = getDirection();
|
|
|
|
// Update previous plan ref after render
|
|
useEffect(() => {
|
|
previousPlanRef.current = selectedPlan;
|
|
}, [selectedPlan]);
|
|
|
|
const handleContinue = () => {
|
|
if (selectedPlan) {
|
|
posthog.capture("onboarding_plan_continue_clicked", {
|
|
plan_type: selectedPlan,
|
|
});
|
|
}
|
|
startTransition(() => {
|
|
if (selectedPlan === "organization") {
|
|
router.push("/onboarding/organization/details");
|
|
} else if (selectedPlan === "team") {
|
|
router.push("/onboarding/teams/details");
|
|
} else if (selectedPlan === "personal") {
|
|
router.push("/onboarding/personal/settings");
|
|
}
|
|
});
|
|
};
|
|
|
|
const planIconByType: Record<PlanType, IconName> = {
|
|
personal: "user",
|
|
team: "user",
|
|
organization: "users",
|
|
};
|
|
|
|
const allPlans = [
|
|
{
|
|
id: "personal" as PlanType,
|
|
title: t("onboarding_plan_personal_title"),
|
|
badge: t("onboarding_plan_personal_badge"),
|
|
description: t("onboarding_plan_personal_description"),
|
|
icon: planIconByType.personal,
|
|
variant: "single" as const,
|
|
},
|
|
{
|
|
id: "team" as PlanType,
|
|
title: t("onboarding_plan_team_title"),
|
|
badge: t("onboarding_plan_team_badge"),
|
|
description: t("onboarding_plan_team_description"),
|
|
icon: planIconByType.team,
|
|
variant: "team" as const,
|
|
},
|
|
{
|
|
id: "organization" as PlanType,
|
|
title: t("onboarding_plan_organization_title"),
|
|
badge: t("onboarding_plan_organization_badge"),
|
|
description: t("onboarding_plan_organization_description"),
|
|
icon: planIconByType.organization,
|
|
variant: "organization" as const,
|
|
},
|
|
];
|
|
|
|
// Only show organization plan for company emails
|
|
const plans = allPlans.filter((plan) => {
|
|
if (plan.id === "organization") {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const selectedPlanData = plans.find((plan) => plan.id === selectedPlan);
|
|
|
|
// Show loading state while checking for team membership or if redirecting
|
|
if (isPendingMembership || hasTeamMembership) {
|
|
return (
|
|
<OnboardingLayout userEmail={userEmail}>
|
|
<OnboardingCard title={t("loading")} subtitle="" />
|
|
</OnboardingLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<OnboardingContinuationPrompt />
|
|
<OnboardingLayout userEmail={userEmail}>
|
|
{/* Left column - Main content */}
|
|
<OnboardingCard
|
|
title={t("onboarding_select_plan")}
|
|
subtitle={t("onboarding_welcome_question")}
|
|
footer={
|
|
<div className="flex w-full justify-end gap-2">
|
|
<Button
|
|
data-testid="onboarding-continue-btn"
|
|
color="primary"
|
|
className="rounded-[10px]"
|
|
onClick={handleContinue}
|
|
disabled={isPending}>
|
|
{isPending ? t("loading") : t("continue")}
|
|
</Button>
|
|
</div>
|
|
}>
|
|
{/* Card */}
|
|
<div className="relative flex min-h-0 w-full flex-col overflow-hidden rounded-xl border border-muted bg-cal-muted p-1">
|
|
<div className="flex w-full flex-col items-start overflow-clip rounded-inherit">
|
|
{/* Plan options */}
|
|
<RadioAreaGroup.Group
|
|
value={selectedPlan ?? undefined}
|
|
onValueChange={(value) => {
|
|
const planType = value as PlanType;
|
|
setSelectedPlan(planType);
|
|
posthog.capture("onboarding_plan_selected", {
|
|
plan_type: planType,
|
|
});
|
|
}}
|
|
className="flex w-full flex-col gap-1 rounded-[10px]">
|
|
{plans.map((plan) => {
|
|
const isSelected = selectedPlan === plan.id;
|
|
|
|
return (
|
|
<RadioAreaGroup.Item
|
|
key={plan.id}
|
|
value={plan.id}
|
|
className={classNames(
|
|
"relative flex items-center overflow-hidden rounded-[10px] border bg-default transition",
|
|
isSelected ? "border-emphasis shadow-sm" : "border-subtle",
|
|
"pr-12 [&>button]:right-6 [&>button]:left-auto [&>button]:mt-0 [&>button]:transform"
|
|
)}
|
|
classNames={{
|
|
container: "flex w-full items-center gap-3 p-5 pr-12",
|
|
}}>
|
|
<div className="flex w-full flex-col gap-1">
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<p className="font-semibold text-emphasis text-sm leading-4">{plan.title}</p>
|
|
<Badge
|
|
variant="gray"
|
|
size="md"
|
|
className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center">
|
|
<span className="font-medium text-emphasis text-xs leading-3">{plan.badge}</span>
|
|
</Badge>
|
|
</div>
|
|
<Badge variant="gray" size="md" className="h-4 w-fit rounded-md px-1 py-1 md:hidden">
|
|
<span className="font-medium text-emphasis text-xs leading-3">{plan.badge}</span>
|
|
</Badge>
|
|
<p className="max-w-full font-medium text-sm text-subtle leading-[1.25]">
|
|
{plan.description}
|
|
</p>
|
|
</div>
|
|
</RadioAreaGroup.Item>
|
|
);
|
|
})}
|
|
</RadioAreaGroup.Group>
|
|
</div>
|
|
</div>
|
|
</OnboardingCard>
|
|
|
|
{/* Right column - Icon display */}
|
|
<div className="hidden h-full w-full rounded-l-2xl border-subtle border-t border-b border-l bg-cal-muted xl:flex xl:items-center xl:justify-center">
|
|
<AnimatePresence mode="wait">
|
|
{selectedPlanData && (
|
|
<PlanIcon
|
|
key={selectedPlan}
|
|
icon={selectedPlanData.icon}
|
|
variant={selectedPlanData.variant}
|
|
animationDirection={direction}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</OnboardingLayout>
|
|
</>
|
|
);
|
|
};
|