From 1e93d85318f233be66b61f0afbecc9cf5c831999 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 1 Apr 2026 23:30:00 +0100 Subject: [PATCH] feat: add workspace details step --- apps/web/src/components/Input.tsx | 2 +- apps/web/src/pages/onboarding/workspace.tsx | 28 ++ .../views/onboarding/select-plan/index.tsx | 96 +++--- .../onboarding/workspace-details/index.tsx | 318 ++++++++++++++++++ 4 files changed, 394 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/pages/onboarding/workspace.tsx create mode 100644 apps/web/src/views/onboarding/workspace-details/index.tsx diff --git a/apps/web/src/components/Input.tsx b/apps/web/src/components/Input.tsx index abf1b24f..96b5360c 100644 --- a/apps/web/src/components/Input.tsx +++ b/apps/web/src/components/Input.tsx @@ -88,7 +88,7 @@ const Input = forwardRef( )} {iconRight && type !== "password" && ( -
+
{iconRight}
)} diff --git a/apps/web/src/pages/onboarding/workspace.tsx b/apps/web/src/pages/onboarding/workspace.tsx new file mode 100644 index 00000000..354ed112 --- /dev/null +++ b/apps/web/src/pages/onboarding/workspace.tsx @@ -0,0 +1,28 @@ +import { useRouter } from "next/navigation"; +import { env } from "next-runtime-env"; +import { useEffect } from "react"; + +import { authClient } from "@kan/auth/client"; + +import { PageHead } from "~/components/PageHead"; +import WorkspaceDetailsView from "~/views/onboarding/workspace-details"; + +export default function WorkspaceDetailsPage() { + const router = useRouter(); + const { data: session, isPending } = authClient.useSession(); + + useEffect(() => { + if (!isPending && !session?.user) router.push("/login"); + if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") + router.push("/boards"); + }, [session, isPending, router]); + + if (isPending || !session?.user) return null; + + return ( + <> + + + + ); +} diff --git a/apps/web/src/views/onboarding/select-plan/index.tsx b/apps/web/src/views/onboarding/select-plan/index.tsx index 6894ce15..8bac8eea 100644 --- a/apps/web/src/views/onboarding/select-plan/index.tsx +++ b/apps/web/src/views/onboarding/select-plan/index.tsx @@ -1,4 +1,5 @@ import { useRouter } from "next/navigation"; +import { t } from "@lingui/core/macro"; import { Radio, RadioGroup } from "@headlessui/react"; import { AnimatePresence, motion } from "framer-motion"; import { useState } from "react"; @@ -37,47 +38,6 @@ const ORBIT_USERS: Record< ], }; -const FREQUENCIES: { value: Billing; label: string }[] = [ - { value: "monthly", label: "Monthly" }, - { value: "annual", label: "Annual" }, -]; - -const PLANS: { - id: PlanId; - name: string; - monthly: string; - annual: string; - description: string; - trial?: boolean; -}[] = [ - { - id: "solo", - name: "Solo", - monthly: "Free", - annual: "Free", - description: - "Good for individuals starting out who just need the essentials.", - }, - { - id: "team", - name: "Team", - monthly: "$12/user/mo", - annual: "$10/user/mo", - description: - "Best for small teams who want to collaborate and move faster together.", - trial: true, - }, - { - id: "pro", - name: "Pro", - monthly: "$29/mo", - annual: "$23/mo", - description: - "Unlimited members and a custom workspace username for teams ready to scale.", - trial: true, - }, -]; - export default function SelectPlanView() { const router = useRouter(); const [selected, setSelected] = useState("solo"); @@ -92,7 +52,46 @@ export default function SelectPlanView() { const hasExistingWorkspace = !!workspaces?.length; - const handleContinue = () => router.push("/boards"); + const FREQUENCIES: { value: Billing; label: string }[] = [ + { value: "monthly", label: t`Monthly` }, + { value: "annual", label: t`Annual` }, + ]; + + const PLANS: { + id: PlanId; + name: string; + monthly: string; + annual: string; + description: string; + trial?: boolean; + }[] = [ + { + id: "solo", + name: t`Solo`, + monthly: t`Free`, + annual: t`Free`, + description: t`Good for individuals starting out who just need the essentials.`, + }, + { + id: "team", + name: t`Team`, + monthly: "$12/user/mo", + annual: "$10/user/mo", + description: t`Best for small teams who want to collaborate and move faster together.`, + trial: true, + }, + { + id: "pro", + name: t`Pro`, + monthly: "$29/mo", + annual: "$23/mo", + description: t`Unlimited members and a custom workspace username for teams ready to scale.`, + trial: true, + }, + ]; + + const handleContinue = () => + router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}`); const handleCancel = () => { if (window.history.length > 1) { @@ -105,16 +104,15 @@ export default function SelectPlanView() { return (
-
+
{/* Left panel */}

- Choose a plan + {t`Choose a plan`}

- Pick a plan to get started. All paid plans include a 14-day free - trial. + {t`Pick a plan to get started. All paid plans include a 14-day free trial.`}

{/* Billing toggle */} @@ -198,10 +196,10 @@ export default function SelectPlanView() {
{hasExistingWorkspace && ( )} - +
@@ -218,7 +216,7 @@ export default function SelectPlanView() { {userImage ? ( Your avatar ) : ( diff --git a/apps/web/src/views/onboarding/workspace-details/index.tsx b/apps/web/src/views/onboarding/workspace-details/index.tsx new file mode 100644 index 00000000..3f200400 --- /dev/null +++ b/apps/web/src/views/onboarding/workspace-details/index.tsx @@ -0,0 +1,318 @@ +import { useRouter, useSearchParams } from "next/navigation"; +import { t } from "@lingui/core/macro"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useMemo, useState } from "react"; +import { + HiArrowLeft, + HiArrowPath, + HiArrowRight, + HiCheck, + HiEllipsisVertical, + HiInformationCircle, + HiLockClosed, +} from "react-icons/hi2"; + +import { authClient } from "@kan/auth/client"; +import { generateUID } from "@kan/shared/utils"; + +import Button from "~/components/Button"; +import Input from "~/components/Input"; +import LoadingSpinner from "~/components/LoadingSpinner"; +import Toggle from "~/components/Toggle"; +import { Tooltip } from "~/components/Tooltip"; +import { useDebounce } from "~/hooks/useDebounce"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +function slugify(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 60); +} + +export default function WorkspaceNameView() { + const router = useRouter(); + const searchParams = useSearchParams(); + const plan = searchParams.get("plan") ?? "solo"; + const billing = searchParams.get("billing") ?? "annual"; + const { showPopup } = usePopup(); + const [isProToggle, setIsProToggle] = useState(plan === "pro"); + + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + + const generatedUid = useMemo(() => generateUID(), []); + + const [debouncedSlug] = useDebounce(slug, 500); + const isTyping = slug !== debouncedSlug; + + const slugAvailability = api.workspace.checkSlugAvailability.useQuery( + { workspaceSlug: debouncedSlug }, + { enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping }, + ); + + const isSlugAvailable = + slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved; + const isSlugTaken = + slugAvailability.data?.isAvailable === false && + !slugAvailability.data?.isReserved; + const isSlugReserved = slugAvailability.data?.isReserved === true; + + const slugError = isSlugTaken + ? t`This URL has already been taken` + : isSlugReserved + ? t`This URL is reserved` + : undefined; + + const { data: session } = authClient.useSession(); + const { data: user } = api.user.getUser.useQuery(undefined, { + enabled: !!session?.user, + }); + + const utils = api.useUtils(); + + const previewSlug = isProToggle + ? slugify(slug) || slugify(name) || "your-workspace" + : "your-workspace"; + + const createWorkspace = api.workspace.create.useMutation({ + onSuccess: async (workspace) => { + if (!workspace.publicId) return; + + localStorage.setItem("workspacePublicId", workspace.publicId); + localStorage.removeItem("onboardingWorkspaceSlug"); + void utils.workspace.all.invalidate(); + + if (plan !== "solo") { + try { + const response = await fetch("/api/stripe/create_checkout_session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspacePublicId: workspace.publicId, + cancelUrl: "/boards", + successUrl: "/boards", + billing, + }), + }); + const data = await response.json(); + const url = (data as { url: string }).url; + if (url) { + window.location.href = url; + return; + } + } catch { + // fall through + } + } + + router.push("/boards"); + }, + onError: () => { + showPopup({ + header: t`Unable to create workspace`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + }); + + const handleContinue = () => { + if (!name.trim()) return; + createWorkspace.mutate({ name: name.trim() }); + }; + + useEffect(() => { + document.getElementById("workspace-name-input")?.focus(); + }, []); + + const displayName = user?.name ?? session?.user.name ?? ""; + + const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`]; + const [visibleCount, setVisibleCount] = useState(1); + + useEffect(() => { + const timers = BOARDS.slice(1).map((_, i) => + setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000), + ); + return () => timers.forEach(clearTimeout); + }, []); + + return ( +
+
+
+ {/* Left panel */} +
+
+

+ {t`Set up your workspace`} +

+

+ {t`You can always change these later in settings.`} +

+ +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleContinue()} + maxLength={64} + /> + +
+ setSlug(e.target.value)} + disabled={!isProToggle} + prefix="kan.bn/" + className={ + !isProToggle ? "cursor-not-allowed opacity-50" : "" + } + errorMessage={slugError} + iconRight={ + !isProToggle ? ( + {t`Custom usernames require upgrading to a Pro plan`} + } + placement="top" + delay={0} + > + + + ) : isProToggle && slug.length >= 3 ? ( + isTyping || slugAvailability.isPending ? ( + + ) : isSlugAvailable ? ( + + ) : null + ) : null + } + /> +
+ +
+