feat(cloud): improve partner onboarding
This commit is contained in:
@@ -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 | AuthProvider>(null);
|
||||
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const passwordRef = useRef<HTMLInputElement | null>(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(() => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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") {
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
{!isLicenseFlow && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
) : null
|
||||
) : null
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan !== "pro" && (
|
||||
{!isLicenseFlow && plan !== "pro" && (
|
||||
<div className="pb-2">
|
||||
<Toggle
|
||||
isChecked={isProToggle}
|
||||
@@ -265,16 +284,18 @@ export default function WorkspaceNameView() {
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
{!isLicenseFlow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={
|
||||
|
||||
Reference in New Issue
Block a user