feat(cloud): improve partner onboarding
This commit is contained in:
@@ -46,6 +46,7 @@ interface FormValues {
|
|||||||
interface AuthProps {
|
interface AuthProps {
|
||||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||||
isSignUp?: boolean;
|
isSignUp?: boolean;
|
||||||
|
callbackURL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmailSchema = z.object({
|
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 [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||||
useState<null | AuthProvider>(null);
|
useState<null | AuthProvider>(null);
|
||||||
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const redirect = useSearchParams().get("next");
|
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
|
// Safely get environment variables on client side to avoid hydration mismatch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
@@ -14,6 +14,7 @@ import { authClient } from "@kan/auth/client";
|
|||||||
|
|
||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||||
@@ -49,7 +50,9 @@ export default function Dashboard({
|
|||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { openModal, closeModal, modalContentType } = useModal();
|
const { openModal, closeModal, modalContentType } = useModal();
|
||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
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(() => {
|
useEffect(() => {
|
||||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default withRateLimit(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return res.redirect(
|
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;
|
const { license_key } = req.query;
|
||||||
|
|
||||||
if (!license_key || typeof license_key !== "string") {
|
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);
|
const { db, user } = await createNextApiContext(req);
|
||||||
@@ -30,11 +30,11 @@ export default withRateLimit(
|
|||||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
||||||
|
|
||||||
if (!sub) {
|
if (!sub) {
|
||||||
return res.redirect("/?partner_error=invalid_license");
|
return res.redirect("/boards?partner_error=invalid_license");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.status !== "active") {
|
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);
|
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||||
@@ -42,7 +42,7 @@ export default withRateLimit(
|
|||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return res.redirect(
|
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",
|
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 [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (licenseKey) {
|
||||||
|
localStorage.setItem("partnerLicenseKey", licenseKey);
|
||||||
|
}
|
||||||
|
}, [licenseKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && session?.user && licenseKey) {
|
if (!isPending && session?.user && licenseKey) {
|
||||||
router.push(
|
router.push(
|
||||||
@@ -68,6 +74,11 @@ export default function PartnerActivatePage() {
|
|||||||
setIsMagicLinkSent(val);
|
setIsMagicLinkSent(val);
|
||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
}}
|
}}
|
||||||
|
callbackURL={
|
||||||
|
licenseKey
|
||||||
|
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||||
|
: "/boards"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,16 @@ export default function WorkspaceNameView() {
|
|||||||
const plan = searchParams.get("plan") ?? "solo";
|
const plan = searchParams.get("plan") ?? "solo";
|
||||||
const billing = searchParams.get("billing") ?? "annual";
|
const billing = searchParams.get("billing") ?? "annual";
|
||||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||||
|
const licenseKeyParam = searchParams.get("license_key");
|
||||||
|
const isLicenseFlow = !!licenseKeyParam;
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (licenseKeyParam) {
|
||||||
|
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
|
||||||
|
}
|
||||||
|
}, [licenseKeyParam]);
|
||||||
|
|
||||||
const [isProToggle, setIsProToggle] = useState(plan === "pro");
|
const [isProToggle, setIsProToggle] = useState(plan === "pro");
|
||||||
const effectivePlan = isProToggle ? "pro" : plan;
|
const effectivePlan = isProToggle ? "pro" : plan;
|
||||||
|
|
||||||
@@ -94,7 +103,15 @@ export default function WorkspaceNameView() {
|
|||||||
if (!workspace.publicId) return;
|
if (!workspace.publicId) return;
|
||||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||||
void utils.workspace.all.invalidate();
|
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: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -110,7 +127,7 @@ export default function WorkspaceNameView() {
|
|||||||
const handleContinue = async () => {
|
const handleContinue = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
|
|
||||||
if (effectivePlan === "solo") {
|
if (effectivePlan === "solo" || isLicenseFlow) {
|
||||||
createWorkspace.mutate({
|
createWorkspace.mutate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
...(description.trim() && { description: description.trim() }),
|
...(description.trim() && { description: description.trim() }),
|
||||||
@@ -193,50 +210,52 @@ export default function WorkspaceNameView() {
|
|||||||
maxLength={64}
|
maxLength={64}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{!isLicenseFlow && (
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
placeholder={t`your-workspace`}
|
<Input
|
||||||
value={isProToggle ? slug : t`your-workspace`}
|
placeholder={t`your-workspace`}
|
||||||
onChange={(e) => {
|
value={isProToggle ? slug : t`your-workspace`}
|
||||||
setSlugManuallyEdited(true);
|
onChange={(e) => {
|
||||||
setSlug(
|
setSlugManuallyEdited(true);
|
||||||
e.target.value
|
setSlug(
|
||||||
.toLowerCase()
|
e.target.value
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
.toLowerCase()
|
||||||
.replace(/\s+/g, "-")
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
.replace(/-+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
.slice(0, 60),
|
.replace(/-+/g, "-")
|
||||||
);
|
.slice(0, 60),
|
||||||
}}
|
);
|
||||||
disabled={!isProToggle}
|
}}
|
||||||
prefix="kan.bn/"
|
disabled={!isProToggle}
|
||||||
className={
|
prefix="kan.bn/"
|
||||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
className={
|
||||||
}
|
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||||
errorMessage={slugError}
|
}
|
||||||
iconRight={
|
errorMessage={slugError}
|
||||||
!isProToggle ? (
|
iconRight={
|
||||||
<Tooltip
|
!isProToggle ? (
|
||||||
content={
|
<Tooltip
|
||||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
content={
|
||||||
}
|
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||||
placement="top"
|
}
|
||||||
delay={0}
|
placement="top"
|
||||||
>
|
delay={0}
|
||||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
>
|
||||||
</Tooltip>
|
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||||
) : isProToggle && slug.length >= 3 ? (
|
</Tooltip>
|
||||||
isTyping || slugAvailability.isPending ? (
|
) : isProToggle && slug.length >= 3 ? (
|
||||||
<LoadingSpinner />
|
isTyping || slugAvailability.isPending ? (
|
||||||
) : isSlugAvailable ? (
|
<LoadingSpinner />
|
||||||
<HiCheck className="h-4 w-4 text-white" />
|
) : isSlugAvailable ? (
|
||||||
|
<HiCheck className="h-4 w-4 text-white" />
|
||||||
|
) : null
|
||||||
) : null
|
) : null
|
||||||
) : null
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{plan !== "pro" && (
|
{!isLicenseFlow && plan !== "pro" && (
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<Toggle
|
<Toggle
|
||||||
isChecked={isProToggle}
|
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="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<Button
|
{!isLicenseFlow && (
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={() =>
|
variant="ghost"
|
||||||
router.replace(
|
onClick={() =>
|
||||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
router.replace(
|
||||||
)
|
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||||
}
|
)
|
||||||
>
|
}
|
||||||
{t`Back`}
|
>
|
||||||
</Button>
|
{t`Back`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void handleContinue()}
|
onClick={() => void handleContinue()}
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
Reference in New Issue
Block a user