From 8436644966386094831c02fe856b1bc6dba22337 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 15 May 2026 22:33:14 +0100 Subject: [PATCH] feat(cloud): improve partner onboarding --- apps/web/src/components/AuthForm.tsx | 9 +- apps/web/src/components/Dashboard.tsx | 37 ++++- apps/web/src/pages/api/partner/callback.ts | 2 +- apps/web/src/pages/api/partner/link.ts | 10 +- apps/web/src/pages/partner/activate.tsx | 11 ++ .../onboarding/workspace-details/index.tsx | 129 ++++++++++-------- 6 files changed, 135 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx index 9bb66931..11e58dfe 100644 --- a/apps/web/src/components/AuthForm.tsx +++ b/apps/web/src/components/AuthForm.tsx @@ -46,6 +46,7 @@ interface FormValues { interface AuthProps { setIsMagicLinkSent: (value: boolean, recipient: string) => void; isSignUp?: boolean; + callbackURL?: string; } const EmailSchema = z.object({ @@ -152,7 +153,11 @@ const availableSocialProviders = { }, }; -export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { +export function Auth({ + setIsMagicLinkSent, + isSignUp, + callbackURL: callbackURLProp, +}: AuthProps) { const [isCloudEnv, setIsCloudEnv] = useState(false); const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState(null); @@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { const passwordRef = useRef(null); const redirect = useSearchParams().get("next"); - const callbackURL = redirect ?? "/boards"; + const callbackURL = callbackURLProp ?? redirect ?? "/boards"; // Safely get environment variables on client side to avoid hydration mismatch useEffect(() => { diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx index fd267877..b5c50109 100644 --- a/apps/web/src/components/Dashboard.tsx +++ b/apps/web/src/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { t } from "@lingui/core/macro"; import { env } from "next-runtime-env"; import { useTheme } from "next-themes"; @@ -14,6 +14,7 @@ import { authClient } from "@kan/auth/client"; import { useClickOutside } from "~/hooks/useClickOutside"; import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { api } from "~/utils/api"; import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation"; @@ -49,7 +50,9 @@ export default function Dashboard({ const { resolvedTheme } = useTheme(); const { openModal, closeModal, modalContentType } = useModal(); const { availableWorkspaces, hasLoaded } = useWorkspace(); + const { showPopup } = usePopup(); const router = useRouter(); + const searchParams = useSearchParams(); const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: user, isLoading: userLoading } = api.user.getUser.useQuery( @@ -103,6 +106,38 @@ export default function Dashboard({ } }); + useEffect(() => { + const partnerActivated = searchParams.get("partner_activated"); + const partnerError = searchParams.get("partner_error"); + + if (partnerActivated) { + showPopup({ + header: t`License activated`, + message: t`Your license has been activated successfully.`, + icon: "success", + }); + const params = new URLSearchParams(searchParams.toString()); + params.delete("partner_activated"); + router.replace(`?${params.toString()}`); + } else if (partnerError) { + const messages: Record = { + invalid_license: t`That license key could not be found. Please contact support.`, + license_inactive: t`Your license is not active. Please check your account.`, + missing_license: t`No license key was provided. Please try activating again.`, + }; + showPopup({ + header: t`License activation failed`, + message: + messages[partnerError] ?? + t`Something went wrong during license activation.`, + icon: "error", + }); + const params = new URLSearchParams(searchParams.toString()); + params.delete("partner_error"); + router.replace(`?${params.toString()}`); + } + }, [searchParams, showPopup, router]); + useEffect(() => { if (hasLoaded && availableWorkspaces.length === 0) { if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") { diff --git a/apps/web/src/pages/api/partner/callback.ts b/apps/web/src/pages/api/partner/callback.ts index ad543ef3..347a4350 100644 --- a/apps/web/src/pages/api/partner/callback.ts +++ b/apps/web/src/pages/api/partner/callback.ts @@ -146,7 +146,7 @@ export default withRateLimit( }, ); return res.redirect( - `/onboarding?license_key=${encodeURIComponent(license.license_key)}`, + `/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`, ); } diff --git a/apps/web/src/pages/api/partner/link.ts b/apps/web/src/pages/api/partner/link.ts index 70d23084..3b6ff02e 100644 --- a/apps/web/src/pages/api/partner/link.ts +++ b/apps/web/src/pages/api/partner/link.ts @@ -16,7 +16,7 @@ export default withRateLimit( const { license_key } = req.query; if (!license_key || typeof license_key !== "string") { - return res.redirect("/?partner_error=missing_license"); + return res.redirect("/boards?partner_error=missing_license"); } const { db, user } = await createNextApiContext(req); @@ -30,11 +30,11 @@ export default withRateLimit( const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key); if (!sub) { - return res.redirect("/?partner_error=invalid_license"); + return res.redirect("/boards?partner_error=invalid_license"); } if (sub.status !== "active") { - return res.redirect("/?partner_error=license_inactive"); + return res.redirect("/boards?partner_error=license_inactive"); } const memberships = await workspaceRepo.getAllByUserId(db, user.id); @@ -42,7 +42,7 @@ export default withRateLimit( if (!workspace) { return res.redirect( - `/onboarding?license_key=${encodeURIComponent(license_key)}`, + `/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`, ); } @@ -59,6 +59,6 @@ export default withRateLimit( plan: sub.plan as "free" | "team" | "pro" | "enterprise", }); - return res.redirect("/?partner_activated=1"); + return res.redirect("/boards?partner_activated=1"); }), ); diff --git a/apps/web/src/pages/partner/activate.tsx b/apps/web/src/pages/partner/activate.tsx index e0f1bed0..5622ed7b 100644 --- a/apps/web/src/pages/partner/activate.tsx +++ b/apps/web/src/pages/partner/activate.tsx @@ -20,6 +20,12 @@ export default function PartnerActivatePage() { const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); + useEffect(() => { + if (licenseKey) { + localStorage.setItem("partnerLicenseKey", licenseKey); + } + }, [licenseKey]); + useEffect(() => { if (!isPending && session?.user && licenseKey) { router.push( @@ -68,6 +74,11 @@ export default function PartnerActivatePage() { setIsMagicLinkSent(val); setMagicLinkRecipient(recipient); }} + callbackURL={ + licenseKey + ? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}` + : "/boards" + } /> diff --git a/apps/web/src/views/onboarding/workspace-details/index.tsx b/apps/web/src/views/onboarding/workspace-details/index.tsx index a8489999..bda0dfbb 100644 --- a/apps/web/src/views/onboarding/workspace-details/index.tsx +++ b/apps/web/src/views/onboarding/workspace-details/index.tsx @@ -39,7 +39,16 @@ export default function WorkspaceNameView() { const plan = searchParams.get("plan") ?? "solo"; const billing = searchParams.get("billing") ?? "annual"; const returnUrl = searchParams.get("returnUrl") ?? "/boards"; + const licenseKeyParam = searchParams.get("license_key"); + const isLicenseFlow = !!licenseKeyParam; const { showPopup } = usePopup(); + + useEffect(() => { + if (licenseKeyParam) { + localStorage.setItem("partnerLicenseKey", licenseKeyParam); + } + }, [licenseKeyParam]); + const [isProToggle, setIsProToggle] = useState(plan === "pro"); const effectivePlan = isProToggle ? "pro" : plan; @@ -94,7 +103,15 @@ export default function WorkspaceNameView() { if (!workspace.publicId) return; localStorage.setItem("workspacePublicId", workspace.publicId); void utils.workspace.all.invalidate(); - router.push("/boards"); + const storedLicenseKey = localStorage.getItem("partnerLicenseKey"); + if (storedLicenseKey) { + localStorage.removeItem("partnerLicenseKey"); + router.push( + `/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`, + ); + } else { + router.push("/boards"); + } }, onError: () => { showPopup({ @@ -110,7 +127,7 @@ export default function WorkspaceNameView() { const handleContinue = async () => { if (!name.trim()) return; - if (effectivePlan === "solo") { + if (effectivePlan === "solo" || isLicenseFlow) { createWorkspace.mutate({ name: name.trim(), ...(description.trim() && { description: description.trim() }), @@ -193,50 +210,52 @@ export default function WorkspaceNameView() { 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 ? ( - + {!isLicenseFlow && ( +
+ { + 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 - ) : null - } - /> -
+ } + /> +
+ )} - {plan !== "pro" && ( + {!isLicenseFlow && plan !== "pro" && (
- + {!isLicenseFlow && ( + + )}