feat: toggle pro subscription

This commit is contained in:
Henry
2025-09-11 15:00:44 +01:00
parent 239b903997
commit b8112f630b
3 changed files with 61 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ import { z } from "zod";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useDebounce } from "~/hooks/useDebounce"; import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -50,6 +51,7 @@ export function NewWorkspaceForm() {
watch, watch,
trigger, trigger,
clearErrors, clearErrors,
setValue,
} = useForm<FormValues>({ } = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -68,6 +70,10 @@ export function NewWorkspaceForm() {
const [debouncedSlug] = useDebounce(slug, 500); const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug; const isTyping = slug !== debouncedSlug;
// Pro toggle state management
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
// Validate slug only after debounce // Validate slug only after debounce
useEffect(() => { useEffect(() => {
if (isTyping) { if (isTyping) {
@@ -104,12 +110,8 @@ export function NewWorkspaceForm() {
role: "admin", role: "admin",
}); });
// If in cloud and user provided a valid slug, create checkout session for pro // If in cloud and Pro toggle is enabled, create checkout session for pro
if ( if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
slug &&
isWorkspaceSlugAvailable?.isAvailable
) {
try { try {
const response = await fetch( const response = await fetch(
"/api/stripe/create_checkout_session", "/api/stripe/create_checkout_session",
@@ -119,9 +121,9 @@ export function NewWorkspaceForm() {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
slug, slug: slug || undefined,
workspacePublicId: values.publicId, workspacePublicId: values.publicId,
cancelUrl: "/boards", cancelUrl: "/settings?upgrade=pro",
successUrl: "/boards", successUrl: "/boards",
}), }),
}, },
@@ -168,12 +170,21 @@ export function NewWorkspaceForm() {
useEffect(() => { useEffect(() => {
if (!checkWorkspaceSlugAvailability.isPending && isValidSlug) { if (!checkWorkspaceSlugAvailability.isPending && isValidSlug) {
setShouldShowBenefits(isWorkspaceSlugAvailable?.isAvailable === true); const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
setShouldShowBenefits(isAvailable);
// Automatically enable Pro toggle when slug is available (only in cloud env)
if (isAvailable && isCloudEnv) {
setIsProToggleEnabled(true);
setLastAvailableSlug(slug);
}
} }
}, [ }, [
isValidSlug, isValidSlug,
isWorkspaceSlugAvailable?.isAvailable, isWorkspaceSlugAvailable?.isAvailable,
checkWorkspaceSlugAvailability.isPending, checkWorkspaceSlugAvailability.isPending,
slug,
isCloudEnv,
]); ]);
// Reset benefits when slug becomes invalid // Reset benefits when slug becomes invalid
@@ -183,7 +194,22 @@ export function NewWorkspaceForm() {
} }
}, [isValidSlug]); }, [isValidSlug]);
const showProBenefits = shouldShowBenefits; // Handle Pro toggle changes
const handleProToggleChange = () => {
if (isProToggleEnabled) {
// Turning off: clear the slug and disable Pro
setValue("slug", "");
setIsProToggleEnabled(false);
} else {
// Turning on: restore the last available slug if we have one
if (lastAvailableSlug) {
setValue("slug", lastAvailableSlug);
}
setIsProToggleEnabled(true);
}
};
const showProBenefits = isProToggleEnabled;
const onSubmit = (values: FormValues) => { const onSubmit = (values: FormValues) => {
// Don't submit if slug is provided but not available // Don't submit if slug is provided but not available
@@ -278,11 +304,11 @@ export function NewWorkspaceForm() {
}} }}
/> />
{showProBenefits && ( {slug && slug.length >= 3 && shouldShowBenefits && (
<div className="mt-2 flex items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<HiInformationCircle className="h-4 w-4 text-dark-900" /> <HiInformationCircle className="h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900"> <p className="text-xs text-gray-500 dark:text-dark-900">
{t`Custom URLs require Pro plan ($29/month)`} {t`Custom URLs require upgrading to a Pro plan`}
</p> </p>
</div> </div>
)} )}
@@ -331,20 +357,27 @@ export function NewWorkspaceForm() {
!showProBenefits && "mt-12", !showProBenefits && "mt-12",
)} )}
> >
<div className="flex gap-2"> {/* Pro Toggle - only show in cloud environment */}
{isCloudEnv && (
<Toggle
isChecked={isProToggleEnabled}
onChange={handleProToggleChange}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div>
<Button <Button
type="submit" type="submit"
isLoading={createWorkspace.isPending} isLoading={createWorkspace.isPending}
iconLeft={showProBenefits ? <HiBolt /> : undefined}
disabled={ disabled={
createWorkspace.isPending || createWorkspace.isPending ||
(!!isValidSlug && (!!slug &&
(checkWorkspaceSlugAvailability.isPending || (checkWorkspaceSlugAvailability.isPending ||
isWorkspaceSlugAvailable?.isAvailable === false || isWorkspaceSlugAvailable?.isAvailable === false ||
isTyping)) isTyping))
} }
> >
{showProBenefits ? t`Create Pro workspace` : t`Create workspace`} {t`Create workspace`}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -95,7 +95,12 @@ export function UpgradeToProConfirmation({
</div> </div>
</div> </div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6"> <div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary"> <Button
onClick={() => {
closeModal();
}}
variant="secondary"
>
{t`Cancel`} {t`Cancel`}
</Button> </Button>
<Button <Button

View File

@@ -1,7 +1,7 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
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 { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { HiBolt, HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiBolt, HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import type { Subscription } from "@kan/shared/utils"; import type { Subscription } from "@kan/shared/utils";
@@ -36,6 +36,7 @@ export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null); const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const isCredentialsEnabled = const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true"; env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const { data } = api.user.getUser.useQuery(); const { data } = api.user.getUser.useQuery();
@@ -100,11 +101,13 @@ export default function SettingsPage() {
if ( if (
router.query.upgrade === "pro" && router.query.upgrade === "pro" &&
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") !hasActiveSubscription(subscriptions, "pro") &&
!hasOpenedUpgradeModal
) { ) {
openModal("UPGRADE_TO_PRO"); openModal("UPGRADE_TO_PRO");
setHasOpenedUpgradeModal(true);
} }
}, [router.query.upgrade, subscriptions, openModal]); }, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
const { mutateAsync: disconnectTrello } = const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({ api.integration.disconnect.useMutation({