From 7045cd8e25363237631670149dfc16319bed07bc Mon Sep 17 00:00:00 2001 From: Henry <30578846+hjball@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:59:26 +0100 Subject: [PATCH] feat(cloud): improved workspace onboarding (#462) * feat: setup onboarding and select plan page * feat: add workspace details step * refactor: optimize board rendering in workspace details * feat: tweak padding in boards mockup * fix: validate/convert slug input * refactor: tweak onboarding steps UX * feat: add team to workspace plans * feat: allow setting description when creating a workspace * feat: add logging to next api routes * feat: create worspace on checkout.session.completed * chore: update types * refactor: improve webhook logs * feat: create workspace on successful activation * feat: set slug on workspace name change * feat: add returnUrl params --- apps/web/src/components/Dashboard.tsx | 11 +- apps/web/src/components/Input.tsx | 2 +- apps/web/src/components/NewWorkspaceForm.tsx | 2 +- apps/web/src/components/Toggle.tsx | 11 +- apps/web/src/components/WorkspaceMenu.tsx | 9 +- .../web/src/pages/api/download/attatchment.ts | 14 +- apps/web/src/pages/api/oss-friends.ts | 32 +- .../api/stripe/create_billing_session.ts | 44 +- .../api/stripe/create_checkout_session.ts | 211 +- apps/web/src/pages/api/stripe/webhook.ts | 79 +- apps/web/src/pages/api/trello/authenticate.ts | 78 +- apps/web/src/pages/api/unsubscribe.ts | 147 +- apps/web/src/pages/api/upload/attachment.ts | 14 +- apps/web/src/pages/api/upload/avatar.ts | 22 +- apps/web/src/pages/onboarding/select-plan.tsx | 31 + apps/web/src/pages/onboarding/workspace.tsx | 28 + apps/web/src/providers/workspace.tsx | 25 +- .../views/onboarding/select-plan/index.tsx | 289 ++ .../onboarding/workspace-details/index.tsx | 367 ++ packages/api/package.json | 4 + packages/api/src/routers/workspace.ts | 2 + packages/api/src/utils/apiLogging.ts | 64 + .../20260402225628_AddTeamWorkspacePlan.sql | 1 + .../meta/20260402225628_snapshot.json | 3869 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/repository/workspace.repo.ts | 6 +- packages/db/src/schema/workspaces.ts | 2 +- 27 files changed, 5094 insertions(+), 277 deletions(-) create mode 100644 apps/web/src/pages/onboarding/select-plan.tsx create mode 100644 apps/web/src/pages/onboarding/workspace.tsx create mode 100644 apps/web/src/views/onboarding/select-plan/index.tsx create mode 100644 apps/web/src/views/onboarding/workspace-details/index.tsx create mode 100644 packages/api/src/utils/apiLogging.ts create mode 100644 packages/db/migrations/20260402225628_AddTeamWorkspacePlan.sql create mode 100644 packages/db/migrations/meta/20260402225628_snapshot.json diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx index e541d402..ecff8acb 100644 --- a/apps/web/src/components/Dashboard.tsx +++ b/apps/web/src/components/Dashboard.tsx @@ -1,4 +1,6 @@ import { useTheme } from "next-themes"; +import { useRouter } from "next/navigation"; +import { env } from "next-runtime-env"; import { useEffect, useRef, useState } from "react"; import { TbLayoutSidebarLeftCollapse, @@ -43,6 +45,7 @@ export default function Dashboard({ const { resolvedTheme } = useTheme(); const { openModal } = useModal(); const { availableWorkspaces, hasLoaded } = useWorkspace(); + const router = useRouter(); const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: user, isLoading: userLoading } = api.user.getUser.useQuery( @@ -98,9 +101,13 @@ export default function Dashboard({ useEffect(() => { if (hasLoaded && availableWorkspaces.length === 0) { - openModal("NEW_WORKSPACE", undefined, undefined, false); + if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") { + router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`); + } else { + openModal("NEW_WORKSPACE", undefined, undefined, false); + } } - }, [hasLoaded, availableWorkspaces.length, openModal]); + }, [hasLoaded, availableWorkspaces.length, openModal, router]); const isDarkMode = resolvedTheme === "dark"; 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/components/NewWorkspaceForm.tsx b/apps/web/src/components/NewWorkspaceForm.tsx index acc3c44e..70d501cf 100644 --- a/apps/web/src/components/NewWorkspaceForm.tsx +++ b/apps/web/src/components/NewWorkspaceForm.tsx @@ -127,7 +127,7 @@ export function NewWorkspaceForm() { body: JSON.stringify({ slug: slug || undefined, workspacePublicId: values.publicId, - cancelUrl: "/settings/workspace?upgrade=pro", + cancelUrl: window.location.pathname + window.location.search, successUrl: "/boards", }), }, diff --git a/apps/web/src/components/Toggle.tsx b/apps/web/src/components/Toggle.tsx index 3efe3b5b..fbb9766f 100644 --- a/apps/web/src/components/Toggle.tsx +++ b/apps/web/src/components/Toggle.tsx @@ -7,15 +7,17 @@ const Toggle = ({ label, disabled, showLabel = true, + labelPosition = "before", }: { isChecked: boolean; onChange: () => void; label: string; disabled?: boolean; showLabel?: boolean; + labelPosition?: "before" | "after"; }) => ( -
- {showLabel && ( +
+ {showLabel && labelPosition === "before" && ( {label} @@ -38,6 +40,11 @@ const Toggle = ({ )} /> + {showLabel && labelPosition === "after" && ( + + {label} + + )}
); diff --git a/apps/web/src/components/WorkspaceMenu.tsx b/apps/web/src/components/WorkspaceMenu.tsx index 34c6220c..7d8f2ef5 100644 --- a/apps/web/src/components/WorkspaceMenu.tsx +++ b/apps/web/src/components/WorkspaceMenu.tsx @@ -1,5 +1,7 @@ import { Button, Menu, Transition } from "@headlessui/react"; import { t } from "@lingui/core/macro"; +import { useRouter } from "next/navigation"; +import { env } from "next-runtime-env"; import { Fragment, useState } from "react"; import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; @@ -18,6 +20,7 @@ export default function WorkspaceMenu({ const { workspace, isLoading, availableWorkspaces, switchWorkspace } = useWorkspace(); const { openModal } = useModal(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const { tooltipContent: commandPaletteShortcutTooltipContent } = @@ -147,7 +150,11 @@ export default function WorkspaceMenu({
+ ); + })} +
+
+ +
+ {hasExistingWorkspace && ( + + )} + +
+
+ + {/* Right panel */} +
+
+ {/* Orbit rings */} +
+
+
+ + {/* Centre person */} +
+ {userImage ? ( + {t`Your + ) : ( + + )} +
+ + {/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */} + + {ORBIT_USERS[selected].map((u) => ( + + {/* Counter-rotate icon so it stays upright */} + + + + + ))} + +
+
+
+
+ {!hasExistingWorkspace && ( + + )} +
+ ); +} 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..8a92cc92 --- /dev/null +++ b/apps/web/src/views/onboarding/workspace-details/index.tsx @@ -0,0 +1,367 @@ +import { useRouter, useSearchParams } from "next/navigation"; +import { t } from "@lingui/core/macro"; +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; +import { + HiArrowLeft, + HiArrowPath, + HiArrowRight, + HiCheck, + HiEllipsisVertical, + HiInformationCircle, + HiLockClosed, +} from "react-icons/hi2"; + +import { authClient } from "@kan/auth/client"; + +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 returnUrl = searchParams.get("returnUrl") ?? "/boards"; + const { showPopup } = usePopup(); + const [isProToggle, setIsProToggle] = useState(plan === "pro"); + + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + const [description, setDescription] = useState(""); + + const handleNameChange = (value: string) => { + setName(value); + if (isProToggle && !slugManuallyEdited) { + setSlug(slugify(value)); + } + }; + + 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 { data: workspaces } = api.workspace.all.useQuery(); + const hasExistingWorkspace = !!workspaces?.length; + + 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); + void utils.workspace.all.invalidate(); + router.push("/boards"); + }, + onError: () => { + showPopup({ + header: t`Unable to create workspace`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + }); + + const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false); + + const handleContinue = async () => { + if (!name.trim()) return; + + if (plan === "solo") { + createWorkspace.mutate({ + name: name.trim(), + ...(description.trim() && { description: description.trim() }), + }); + return; + } + + // team/pro: redirect to Stripe — workspace created on checkout_success + setIsRedirectingToCheckout(true); + try { + const response = await fetch("/api/stripe/create_checkout_session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspaceName: name.trim(), + ...(description.trim() && { + workspaceDescription: description.trim(), + }), + ...(plan === "pro" && slug ? { workspaceSlug: slug } : {}), + cancelUrl: window.location.pathname + window.location.search, + successUrl: "/boards", + billing, + plan, + }), + }); + const data = await response.json(); + const url = (data as { url: string }).url; + if (url) { + window.location.href = url; + return; + } + } catch { + // fall through + } + setIsRedirectingToCheckout(false); + showPopup({ + header: t`Unable to start checkout`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }; + + 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.`} +

+ +
+ handleNameChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleContinue()} + maxLength={64} + /> + +
+ { + setSlugManuallyEdited(true); + setSlug( + e.target.value + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 60), + ); + }} + 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 + } + /> +
+ + {plan !== "pro" && ( +
+ setIsProToggle((v) => !v)} + label={t`Upgrade to Pro ($29/month)`} + labelPosition="after" + /> +
+ )} + +
+