diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx
index ce032e69..5941a0e2 100644
--- a/apps/web/src/components/SideNavigation.tsx
+++ b/apps/web/src/components/SideNavigation.tsx
@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
-import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -53,7 +52,6 @@ export default function SideNavigation({
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
- const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
@@ -223,21 +221,19 @@ export default function SideNavigation({
}
variant="secondary"
- href="/settings/workspace?upgrade=pro"
- aria-label="Upgrade to Pro"
- title="Upgrade to Pro"
+ href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
+ aria-label={t`Start free trial`}
+ title={t`Start free trial`}
iconOnly
- onClick={() => openModal("UPGRADE_TO_PRO")}
/>
) : (
}
fullWidth
variant="secondary"
- href="/settings/workspace?upgrade=pro"
- onClick={() => openModal("UPGRADE_TO_PRO")}
+ href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
>
- {t`Upgrade to Pro`}
+ {t`Start free trial`}
)}
diff --git a/apps/web/src/pages/api/partner/webhook.ts b/apps/web/src/pages/api/partner/webhook.ts
index 1d4a4a66..d6a6ae98 100644
--- a/apps/web/src/pages/api/partner/webhook.ts
+++ b/apps/web/src/pages/api/partner/webhook.ts
@@ -7,6 +7,7 @@ import { withApiLogging } from "@kan/api/utils/apiLogging";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
+import { getActiveSubscriptions } from "@kan/shared/utils";
import { tierConfig } from "./_utils";
@@ -122,17 +123,18 @@ export default withApiLogging(
license_key,
);
if (sub) {
- await subscriptionRepo.updateById(db, sub.id, {
- plan: "free",
- status: "inactive",
- });
+ const [, allSubs] = await Promise.all([
+ subscriptionRepo.updateById(db, sub.id, {
+ plan: "free",
+ status: "inactive",
+ }),
+ sub.referenceId
+ ? subscriptionRepo.getByReferenceId(db, sub.referenceId)
+ : Promise.resolve([]),
+ ]);
if (sub.referenceId) {
- const allSubs = await subscriptionRepo.getByReferenceId(
- db,
- sub.referenceId,
- );
- const hasActiveSub = allSubs.some(
- (s) => s.id !== sub.id && s.status === "active",
+ const hasActiveSub = getActiveSubscriptions(allSubs).some(
+ (s) => s.id !== sub.id,
);
if (!hasActiveSub) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
diff --git a/apps/web/src/pages/api/stripe/webhook.ts b/apps/web/src/pages/api/stripe/webhook.ts
index fb672fa9..fa79f3c4 100644
--- a/apps/web/src/pages/api/stripe/webhook.ts
+++ b/apps/web/src/pages/api/stripe/webhook.ts
@@ -64,7 +64,10 @@ export default async function handler(
meta.userId &&
meta.userEmail
) {
- const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
+ const existing = await workspaceRepo.getByPublicId(
+ db,
+ meta.workspacePublicId,
+ );
if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
@@ -90,10 +93,10 @@ export default async function handler(
});
}
} else {
- // Existing workspace upgrade — update plan (and slug for pro)
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
- ...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
+ ...(plan === "pro" &&
+ meta.workspaceSlug && { slug: meta.workspaceSlug }),
});
}
diff --git a/apps/web/src/pages/partner/activate.tsx b/apps/web/src/pages/partner/activate.tsx
index 5622ed7b..5fb0e417 100644
--- a/apps/web/src/pages/partner/activate.tsx
+++ b/apps/web/src/pages/partner/activate.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
+import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { authClient } from "@kan/auth/client";
@@ -16,6 +17,8 @@ export default function PartnerActivatePage() {
const licenseKey = searchParams.get("license_key");
const error = searchParams.get("error");
+ const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
+
const { data: session, isPending } = authClient.useSession();
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
@@ -55,6 +58,11 @@ export default function PartnerActivatePage() {
{isMagicLinkSent ? (
We sent a link to {magicLinkRecipient}
+ ) : partnerName ? (
+
+ Sign in or create an account to activate your {partnerName}{" "}
+ license
+
) : (
t`Sign in or create an account to activate your license`
)}
diff --git a/apps/web/src/pages/upgrade/select-plan.tsx b/apps/web/src/pages/upgrade/select-plan.tsx
new file mode 100644
index 00000000..5f223c20
--- /dev/null
+++ b/apps/web/src/pages/upgrade/select-plan.tsx
@@ -0,0 +1,31 @@
+import { useRouter } from "next/navigation";
+import { env } from "next-runtime-env";
+import { useEffect } from "react";
+
+import { authClient } from "@kan/auth/client";
+
+import { PageHead } from "~/components/PageHead";
+import SelectPlanView from "~/views/onboarding/select-plan";
+
+export default function UpgradeSelectPlanPage() {
+ const router = useRouter();
+ const { data: session, isPending } = authClient.useSession();
+
+ useEffect(() => {
+ if (!isPending && !session?.user) {
+ router.push("/login");
+ }
+ if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
+ router.push("/boards");
+ }
+ }, [session, isPending, router]);
+
+ if (isPending || !session?.user) return null;
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/web/src/views/members/components/InviteMemberForm.tsx b/apps/web/src/views/members/components/InviteMemberForm.tsx
index 95ab9fe7..69809630 100644
--- a/apps/web/src/views/members/components/InviteMemberForm.tsx
+++ b/apps/web/src/views/members/components/InviteMemberForm.tsx
@@ -13,7 +13,6 @@ import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types";
import type { Subscription } from "@kan/shared/utils";
-import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan } from "@kan/shared/utils";
import Button from "~/components/Button";
@@ -25,15 +24,11 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm({
- numberOfMembers,
subscriptions,
unlimitedSeats,
- userId,
}: {
- numberOfMembers: number;
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
- userId: string | undefined;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -153,6 +148,9 @@ export function InviteMemberForm({
const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription;
+ const isPartnerTier = !!(
+ teamSubscription?.partnerTier ?? proSubscription?.partnerTier
+ );
let isYearly = false;
let price = t`$10/month`;
@@ -175,13 +173,13 @@ export function InviteMemberForm({
inviteMember.mutate(member);
};
+ const isFreePlan =
+ env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
+ !hasTeamSubscription &&
+ !hasProSubscription;
+
const handleInviteLinkToggle = async () => {
- if (
- env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
- !hasTeamSubscription &&
- !hasProSubscription
- )
- return handleUpgrade();
+ if (isFreePlan) return;
setIsLoadingInviteLink(true);
@@ -219,31 +217,6 @@ export function InviteMemberForm({
}
};
- const handleUpgrade = async () => {
- const { data, error } = await authClient.subscription.upgrade({
- plan: "team",
- referenceId: workspace.publicId,
- metadata: { userId },
- seats: numberOfMembers,
- successUrl: "/members",
- cancelUrl: "/members",
- returnUrl: "/members",
- disableRedirect: true,
- });
-
- if (data?.url) {
- window.location.href = data.url;
- }
-
- if (error) {
- showPopup({
- header: t`Error upgrading subscription`,
- message: t`Please try again later, or contact customer support.`,
- icon: "error",
- });
- }
- };
-
useEffect(() => {
const emailElement: HTMLElement | null =
document.querySelector("#email");
@@ -270,11 +243,7 @@ export function InviteMemberForm({
{
if (e.key === "Enter") {
@@ -285,78 +254,86 @@ export function InviteMemberForm({
errorMessage={errors.email?.message}
/>
)}
- {(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
-
-
-
-
- {copied ? (
-
- ) : (
-
- )}
-
+ {!isFreePlan &&
+ (!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {t`Anyone with this link can join your workspace`}
+
+
-
-
-
- {t`Anyone with this link can join your workspace`}
-
-
-
- )}
+ )}
- {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
-
- {hasTeamSubscription || hasProSubscription ? (
-
-
- {hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
-
-
- {unlimitedSeats
- ? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
- : t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
-
-
- ) : (
-
-
- {t`Free Plan`}
-
-
- {t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
-
-
- )}
-
- )}
+ {env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
+ !isPartnerTier &&
+ !unlimitedSeats && (
+
+ {hasTeamSubscription || hasProSubscription ? (
+
+
+ {hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
+
+ {!isPartnerTier && (
+
+ {unlimitedSeats
+ ? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
+ : t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
+
+ )}
+
+ ) : (
+
+
+ {t`Free Plan`}
+
+
+ {t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
+
+
+ )}
+
+ )}
-
+ {!isFreePlan && (
+
+ )}
- {env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
- !hasTeamSubscription &&
- !hasProSubscription ? (
-
- {t`Start 14 day free trial`}
+ {isFreePlan ? (
+
+ {t`Choose plan`}
) : (
- {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
+ {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
<>
{!isPaidPlan && (
@@ -295,11 +301,17 @@ export default function MembersPage() {
: isTeamPlan
? t`Team Plan`
: t`Free Plan`}
- {isProPlan && unlimitedSeats && (
- ∞
- )}
+ {isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
+
+
+ {unlimitedSeats
+ ? t`Unlimited seats`
+ : `${activeMembers}/${totalSeats} ${t`seats`}`}
+
+
+ )}
>
)}
diff --git a/apps/web/src/views/onboarding/select-plan/index.tsx b/apps/web/src/views/onboarding/select-plan/index.tsx
index 3c14254d..d7ba7b7e 100644
--- a/apps/web/src/views/onboarding/select-plan/index.tsx
+++ b/apps/web/src/views/onboarding/select-plan/index.tsx
@@ -1,6 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
-import { t } from "@lingui/core/macro";
import { Radio, RadioGroup } from "@headlessui/react";
+import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { HiUser } from "react-icons/hi2";
@@ -48,6 +48,7 @@ export default function SelectPlanView() {
(searchParams.get("billing") as Billing | null) ?? "annual",
);
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
+ const workspacePublicId = searchParams.get("workspacePublicId");
const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
@@ -96,18 +97,44 @@ export default function SelectPlanView() {
},
];
+ const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
+ const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
+ return workspacePublicId
+ ? `${base}&workspacePublicId=${workspacePublicId}`
+ : base;
+ };
+
const handleSelectPlan = (plan: PlanId) => {
setSelected(plan);
- router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
+ router.replace(buildSelectPlanUrl(plan, billing));
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
- router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
+ router.replace(buildSelectPlanUrl(selected, b));
};
- const handleContinue = () =>
- router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
+ const handleContinue = async () => {
+ if (workspacePublicId && selected !== "solo") {
+ const response = await fetch("/api/stripe/create_checkout_session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ plan: selected,
+ billing,
+ workspacePublicId,
+ successUrl: returnUrl,
+ cancelUrl: returnUrl,
+ }),
+ });
+ const { url } = (await response.json()) as { url: string };
+ if (url) window.location.href = url;
+ return;
+ }
+ router.push(
+ `/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
+ );
+ };
const handleCancel = () => router.push(returnUrl);
@@ -209,7 +236,11 @@ export default function SelectPlanView() {
{t`Cancel`}
)}
-
{t`Continue`}
+
void handleContinue()}>
+ {workspacePublicId && selected !== "solo"
+ ? t`Upgrade`
+ : t`Continue`}
+
diff --git a/apps/web/src/views/settings/BillingSettings.tsx b/apps/web/src/views/settings/BillingSettings.tsx
index f7d175fb..866d7e7e 100644
--- a/apps/web/src/views/settings/BillingSettings.tsx
+++ b/apps/web/src/views/settings/BillingSettings.tsx
@@ -1,4 +1,6 @@
+import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
+import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -7,24 +9,49 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
+import { useWorkspace } from "~/providers/workspace";
+import { api } from "~/utils/api";
export default function BillingSettings() {
const { modalContentType, isOpen } = useModal();
+ const router = useRouter();
+ const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
+ const { workspace } = useWorkspace();
+
+ const { data: workspaceData } = api.workspace.byId.useQuery(
+ { workspacePublicId: workspace.publicId },
+ { enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
+ );
+
+ const subscription = workspaceData?.subscriptions.find((s) =>
+ ["active", "trialing", "past_due"].includes(s.status),
+ );
+
+ const planLabel = (() => {
+ if (!subscription) return t`Free (1 member)`;
+ if (subscription.unlimitedSeats) {
+ const name =
+ subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
+ return `${name} (${t`unlimited members`})`;
+ }
+ if (subscription.seats != null) {
+ const name =
+ subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
+ return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
+ }
+ return (
+ subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
+ );
+ })();
const handleOpenBillingPortal = async () => {
try {
const response = await fetch("/api/stripe/create_billing_session", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: { "Content-Type": "application/json" },
});
-
const { url } = (await response.json()) as { url: string };
-
- if (url) {
- window.location.href = url;
- }
+ if (url) window.location.href = url;
} catch (error) {
console.error("Error creating billing session:", error);
}
@@ -34,6 +61,28 @@ export default function BillingSettings() {
<>
+
+
+ {t`Plan`}
+
+
+ {planLabel}
+
+ {!subscription && isCloud && (
+
+
+ router.push(
+ `/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
+ )
+ }
+ >
+ {t`Choose plan`}
+
+
+ )}
+
+
{t`Billing`}
diff --git a/apps/web/src/views/settings/WorkspaceSettings.tsx b/apps/web/src/views/settings/WorkspaceSettings.tsx
index 65b16855..d08bc3ca 100644
--- a/apps/web/src/views/settings/WorkspaceSettings.tsx
+++ b/apps/web/src/views/settings/WorkspaceSettings.tsx
@@ -1,11 +1,6 @@
-import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
-import { env } from "next-runtime-env";
-import { useEffect, useState } from "react";
-import { HiBolt } from "react-icons/hi2";
import type { Subscription } from "@kan/shared/utils";
-import { hasActiveSubscription } from "@kan/shared/utils";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -22,38 +17,16 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
-import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { canEditWorkspace } = usePermissions();
- const router = useRouter();
- const { data } = api.user.getUser.useQuery();
- const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
-
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
- const subscriptions = workspaceData?.subscriptions as
- | Subscription[]
- | undefined;
-
- // Open upgrade modal if upgrade=pro is in URL params
- useEffect(() => {
- if (
- router.query.upgrade === "pro" &&
- env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
- !hasActiveSubscription(subscriptions, "pro") &&
- !hasOpenedUpgradeModal
- ) {
- openModal("UPGRADE_TO_PRO");
- setHasOpenedUpgradeModal(true);
- }
- }, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
-
return (
<>
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace}
/>
- {env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
- !hasActiveSubscription(subscriptions, "pro") &&
- !hasActiveSubscription(subscriptions, "team") && (
-
- openModal("UPGRADE_TO_PRO")}
- iconRight={ }
- >
- {t`Upgrade to Pro`}
-
-
- )}
-
{t`Delete workspace`}
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
>
-
-
-
-
{/* Global modals */}
{
const [result] = await db
diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts
index fd48a897..c8e894bd 100644
--- a/packages/db/src/repository/workspace.repo.ts
+++ b/packages/db/src/repository/workspace.repo.ts
@@ -239,6 +239,7 @@ export const getByPublicIdWithMembers = (
status: true,
seats: true,
unlimitedSeats: true,
+ partnerTier: true,
periodStart: true,
periodEnd: true,
},
diff --git a/packages/shared/src/utils/subscriptions.ts b/packages/shared/src/utils/subscriptions.ts
index 7858aede..0aa5c68c 100644
--- a/packages/shared/src/utils/subscriptions.ts
+++ b/packages/shared/src/utils/subscriptions.ts
@@ -12,6 +12,7 @@ export interface Subscription {
status: string;
seats: number | null;
unlimitedSeats: boolean;
+ partnerTier: number | null;
periodStart: Date | null;
periodEnd: Date | null;
referenceId: string | null;