feat(cloud): improved upgrade journey

This commit is contained in:
Henry
2026-05-18 23:20:11 +01:00
parent 6ea622e7f7
commit cf566e04c6
15 changed files with 262 additions and 200 deletions

View File

@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton"; import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu"; import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu"; import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -53,7 +52,6 @@ export default function SideNavigation({
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false); const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery( const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId }, { workspacePublicId: workspace.publicId },
@@ -223,21 +221,19 @@ export default function SideNavigation({
<ButtonComponent <ButtonComponent
iconLeft={<HiBolt />} iconLeft={<HiBolt />}
variant="secondary" variant="secondary"
href="/settings/workspace?upgrade=pro" href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
aria-label="Upgrade to Pro" aria-label={t`Start free trial`}
title="Upgrade to Pro" title={t`Start free trial`}
iconOnly iconOnly
onClick={() => openModal("UPGRADE_TO_PRO")}
/> />
) : ( ) : (
<ButtonComponent <ButtonComponent
iconLeft={<HiBolt />} iconLeft={<HiBolt />}
fullWidth fullWidth
variant="secondary" variant="secondary"
href="/settings/workspace?upgrade=pro" href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
onClick={() => openModal("UPGRADE_TO_PRO")}
> >
{t`Upgrade to Pro`} {t`Start free trial`}
</ButtonComponent> </ButtonComponent>
)} )}
</div> </div>

View File

@@ -7,6 +7,7 @@ import { withApiLogging } from "@kan/api/utils/apiLogging";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger"; import { createLogger } from "@kan/logger";
import { getActiveSubscriptions } from "@kan/shared/utils";
import { tierConfig } from "./_utils"; import { tierConfig } from "./_utils";
@@ -122,17 +123,18 @@ export default withApiLogging(
license_key, license_key,
); );
if (sub) { if (sub) {
await subscriptionRepo.updateById(db, sub.id, { const [, allSubs] = await Promise.all([
plan: "free", subscriptionRepo.updateById(db, sub.id, {
status: "inactive", plan: "free",
}); status: "inactive",
}),
sub.referenceId
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
: Promise.resolve([]),
]);
if (sub.referenceId) { if (sub.referenceId) {
const allSubs = await subscriptionRepo.getByReferenceId( const hasActiveSub = getActiveSubscriptions(allSubs).some(
db, (s) => s.id !== sub.id,
sub.referenceId,
);
const hasActiveSub = allSubs.some(
(s) => s.id !== sub.id && s.status === "active",
); );
if (!hasActiveSub) { if (!hasActiveSub) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" }); await workspaceRepo.update(db, sub.referenceId, { plan: "free" });

View File

@@ -64,7 +64,10 @@ export default async function handler(
meta.userId && meta.userId &&
meta.userEmail meta.userEmail
) { ) {
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId); const existing = await workspaceRepo.getByPublicId(
db,
meta.workspacePublicId,
);
if (!existing) { if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId; const slug = meta.workspaceSlug ?? meta.workspacePublicId;
@@ -90,10 +93,10 @@ export default async function handler(
}); });
} }
} else { } else {
// Existing workspace upgrade — update plan (and slug for pro)
await workspaceRepo.update(db, meta.workspacePublicId, { await workspaceRepo.update(db, meta.workspacePublicId, {
plan, plan,
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }), ...(plan === "pro" &&
meta.workspaceSlug && { slug: meta.workspaceSlug }),
}); });
} }

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
@@ -16,6 +17,8 @@ export default function PartnerActivatePage() {
const licenseKey = searchParams.get("license_key"); const licenseKey = searchParams.get("license_key");
const error = searchParams.get("error"); const error = searchParams.get("error");
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
@@ -55,6 +58,11 @@ export default function PartnerActivatePage() {
<p className="mb-10 text-sm text-light-800 dark:text-dark-800"> <p className="mb-10 text-sm text-light-800 dark:text-dark-800">
{isMagicLinkSent ? ( {isMagicLinkSent ? (
<Trans>We sent a link to {magicLinkRecipient}</Trans> <Trans>We sent a link to {magicLinkRecipient}</Trans>
) : partnerName ? (
<Trans>
Sign in or create an account to activate your {partnerName}{" "}
license
</Trans>
) : ( ) : (
t`Sign in or create an account to activate your license` t`Sign in or create an account to activate your license`
)} )}

View File

@@ -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 (
<>
<PageHead title="Upgrade | kan.bn" />
<SelectPlanView />
</>
);
}

View File

@@ -13,7 +13,6 @@ import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types"; import type { InviteMemberInput } from "@kan/api/types";
import type { Subscription } from "@kan/shared/utils"; import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan } from "@kan/shared/utils"; import { getSubscriptionByPlan } from "@kan/shared/utils";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -25,15 +24,11 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function InviteMemberForm({ export function InviteMemberForm({
numberOfMembers,
subscriptions, subscriptions,
unlimitedSeats, unlimitedSeats,
userId,
}: { }: {
numberOfMembers: number;
subscriptions: Subscription[] | undefined; subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean; unlimitedSeats: boolean;
userId: string | undefined;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] = const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -153,6 +148,9 @@ export function InviteMemberForm({
const hasTeamSubscription = !!teamSubscription; const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription; const hasProSubscription = !!proSubscription;
const isPartnerTier = !!(
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
);
let isYearly = false; let isYearly = false;
let price = t`$10/month`; let price = t`$10/month`;
@@ -175,13 +173,13 @@ export function InviteMemberForm({
inviteMember.mutate(member); inviteMember.mutate(member);
}; };
const isFreePlan =
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription;
const handleInviteLinkToggle = async () => { const handleInviteLinkToggle = async () => {
if ( if (isFreePlan) return;
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
)
return handleUpgrade();
setIsLoadingInviteLink(true); 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(() => { useEffect(() => {
const emailElement: HTMLElement | null = const emailElement: HTMLElement | null =
document.querySelector<HTMLElement>("#email"); document.querySelector<HTMLElement>("#email");
@@ -270,11 +243,7 @@ export function InviteMemberForm({
<Input <Input
id="email" id="email"
placeholder={t`Email`} placeholder={t`Email`}
disabled={ disabled={isFreePlan}
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
{...register("email", { required: true })} {...register("email", { required: true })}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -285,78 +254,86 @@ export function InviteMemberForm({
errorMessage={errors.email?.message} errorMessage={errors.email?.message}
/> />
)} )}
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && ( {!isFreePlan &&
<div className="my-4"> (!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="relative"> <div className="my-4">
<Input <div className="relative">
value={inviteLink} <Input
className="pr-10 text-sm text-light-900 dark:text-dark-900" value={inviteLink}
readOnly className="pr-10 text-sm text-light-900 dark:text-dark-900"
/> readOnly
<button />
type="button" <button
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950" type="button"
onClick={copyToClipboard} className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
> onClick={copyToClipboard}
{copied ? ( >
<HiMiniCheck className="h-5 w-5 text-green-600" /> {copied ? (
) : ( <HiMiniCheck className="h-5 w-5 text-green-600" />
<HiOutlineDocumentDuplicate className="h-5 w-5" /> ) : (
)} <HiOutlineDocumentDuplicate className="h-5 w-5" />
</button> )}
</button>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div> </div>
<div className="mt-2 flex items-start gap-1"> )}
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( {env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"> !isPartnerTier &&
{hasTeamSubscription || hasProSubscription ? ( !unlimitedSeats && (
<div> <div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<span className="font-medium text-emerald-500 dark:text-emerald-400"> {hasTeamSubscription || hasProSubscription ? (
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`} <div>
</span> <span className="font-medium text-emerald-500 dark:text-emerald-400">
<p className="mt-1"> {hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
{unlimitedSeats </span>
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!` {!isPartnerTier && (
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`} <p className="mt-1">
</p> {unlimitedSeats
</div> ? 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.`}
<div> </p>
<span className="font-medium text-light-950 dark:text-dark-950"> )}
{t`Free Plan`} </div>
</span> ) : (
<p className="mt-1"> <div>
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`} <span className="font-medium text-light-950 dark:text-dark-950">
</p> {t`Free Plan`}
</div> </span>
)} <p className="mt-1">
</div> {t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
)} </p>
</div>
)}
</div>
)}
</div> </div>
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle {!isFreePlan && (
label={ <Toggle
isShareInviteLinkEnabled label={
? t`Deactivate invite link` isShareInviteLinkEnabled
: t`Create invite link` ? t`Deactivate invite link`
} : t`Create invite link`
isChecked={isShareInviteLinkEnabled} }
onChange={handleInviteLinkToggle} isChecked={isShareInviteLinkEnabled}
/> onChange={handleInviteLinkToggle}
/>
)}
<div> <div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && {isFreePlan ? (
!hasTeamSubscription && <Button
!hasProSubscription ? ( type="button"
<Button type="button" onClick={handleUpgrade}> href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
{t`Start 14 day free trial`} >
{t`Choose plan`}
</Button> </Button>
) : ( ) : (
<Button <Button

View File

@@ -83,6 +83,12 @@ export default function MembersPage() {
const isTeamPlan = !!teamSubscription || workspace.plan === "team"; const isTeamPlan = !!teamSubscription || workspace.plan === "team";
const isPaidPlan = isProPlan || isTeamPlan; const isPaidPlan = isProPlan || isTeamPlan;
const activeMembers = data?.members.length ?? 0;
const totalSeats =
teamSubscription?.seats ??
proSubscription?.seats ??
(isPaidPlan ? null : 1);
const TableRow = ({ const TableRow = ({
memberPublicId, memberPublicId,
memberId, memberId,
@@ -270,11 +276,11 @@ export default function MembersPage() {
</h1> </h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
<> <>
{!isPaidPlan && ( {!isPaidPlan && (
<Link <Link
href="/settings/workspace?upgrade=pro" href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex" className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
> >
<HiBolt /> <HiBolt />
@@ -295,11 +301,17 @@ export default function MembersPage() {
: isTeamPlan : isTeamPlan
? t`Team Plan` ? t`Team Plan`
: t`Free Plan`} : t`Free Plan`}
{isProPlan && unlimitedSeats && (
<span className="ml-1 text-xs"></span>
)}
</span> </span>
</div> </div>
{isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
<div className="flex items-center rounded-full border border-light-300 bg-light-50 px-3 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<span className="font-medium">
{unlimitedSeats
? t`Unlimited seats`
: `${activeMembers}/${totalSeats} ${t`seats`}`}
</span>
</div>
)}
</> </>
)} )}
<Button <Button
@@ -388,8 +400,6 @@ export default function MembersPage() {
isVisible={isOpen && modalContentType === "INVITE_MEMBER"} isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
> >
<InviteMemberForm <InviteMemberForm
userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1}
subscriptions={subscriptions} subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats} unlimitedSeats={unlimitedSeats}
/> />

View File

@@ -1,6 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Radio, RadioGroup } from "@headlessui/react"; import { Radio, RadioGroup } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react"; import { useState } from "react";
import { HiUser } from "react-icons/hi2"; import { HiUser } from "react-icons/hi2";
@@ -48,6 +48,7 @@ export default function SelectPlanView() {
(searchParams.get("billing") as Billing | null) ?? "annual", (searchParams.get("billing") as Billing | null) ?? "annual",
); );
const returnUrl = searchParams.get("returnUrl") ?? "/boards"; const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const workspacePublicId = searchParams.get("workspacePublicId");
const { data: workspaces } = api.workspace.all.useQuery(); const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, { 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) => { const handleSelectPlan = (plan: PlanId) => {
setSelected(plan); setSelected(plan);
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`); router.replace(buildSelectPlanUrl(plan, billing));
}; };
const handleSetBilling = (b: Billing) => { const handleSetBilling = (b: Billing) => {
setBilling(b); setBilling(b);
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`); router.replace(buildSelectPlanUrl(selected, b));
}; };
const handleContinue = () => const handleContinue = async () => {
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`); 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); const handleCancel = () => router.push(returnUrl);
@@ -209,7 +236,11 @@ export default function SelectPlanView() {
{t`Cancel`} {t`Cancel`}
</Button> </Button>
)} )}
<Button onClick={handleContinue}>{t`Continue`}</Button> <Button onClick={() => void handleContinue()}>
{workspacePublicId && selected !== "solo"
? t`Upgrade`
: t`Continue`}
</Button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,6 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -7,24 +9,49 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export default function BillingSettings() { export default function BillingSettings() {
const { modalContentType, isOpen } = useModal(); 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 () => { const handleOpenBillingPortal = async () => {
try { try {
const response = await fetch("/api/stripe/create_billing_session", { const response = await fetch("/api/stripe/create_billing_session", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
}); });
const { url } = (await response.json()) as { url: string }; const { url } = (await response.json()) as { url: string };
if (url) window.location.href = url;
if (url) {
window.location.href = url;
}
} catch (error) { } catch (error) {
console.error("Error creating billing session:", error); console.error("Error creating billing session:", error);
} }
@@ -34,6 +61,28 @@ export default function BillingSettings() {
<> <>
<PageHead title={t`Settings | Billing`} /> <PageHead title={t`Settings | Billing`} />
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Plan`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{planLabel}
</p>
{!subscription && isCloud && (
<div className="mb-8">
<Button
onClick={() =>
router.push(
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
)
}
>
{t`Choose plan`}
</Button>
</div>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300"> <div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000"> <h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Billing`} {t`Billing`}

View File

@@ -1,11 +1,6 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; 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 type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -22,38 +17,16 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm"; import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() { export default function WorkspaceSettings() {
const { modalContentType, openModal, isOpen } = useModal(); const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { canEditWorkspace } = usePermissions(); const { canEditWorkspace } = usePermissions();
const router = useRouter();
const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery( const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId }, { workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 }, { 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 ( return (
<> <>
<PageHead title={t`Settings | Workspace`} /> <PageHead title={t`Settings | Workspace`} />
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace} disabled={!canEditWorkspace}
/> />
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (
<div className="my-8">
<Button
onClick={() => openModal("UPGRADE_TO_PRO")}
iconRight={<HiBolt />}
>
{t`Upgrade to Pro`}
</Button>
</div>
)}
<div className="border-t border-light-300 dark:border-dark-300"> <div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000"> <h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Delete workspace`} {t`Delete workspace`}
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
> >
<DeleteWorkspaceConfirmation /> <DeleteWorkspaceConfirmation />
</Modal> </Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
{/* Global modals */} {/* Global modals */}
<Modal <Modal
modalSize="md" modalSize="md"

View File

@@ -101,6 +101,7 @@ services:
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
# Partner config (optional) # Partner config (optional)
- NEXT_PUBLIC_PARTNER_NAME=${NEXT_PUBLIC_PARTNER_NAME}
- PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID} - PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID}
- PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET} - PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET}
- PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL} - PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL}

View File

@@ -39,6 +39,7 @@ const workspaceSubscriptionSchema = z.object({
status: z.string(), status: z.string(),
seats: z.number().nullable(), seats: z.number().nullable(),
unlimitedSeats: z.boolean().nullable(), unlimitedSeats: z.boolean().nullable(),
partnerTier: z.number().nullable(),
periodStart: z.date().nullable(), periodStart: z.date().nullable(),
periodEnd: z.date().nullable(), periodEnd: z.date().nullable(),
}); });

View File

@@ -14,6 +14,7 @@ export const updateById = async (
periodStart?: Date | null; periodStart?: Date | null;
periodEnd?: Date | null; periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null; cancelAtPeriodEnd?: boolean | null;
stripeSubscriptionId?: string | null;
}, },
) => { ) => {
const [result] = await db const [result] = await db

View File

@@ -239,6 +239,7 @@ export const getByPublicIdWithMembers = (
status: true, status: true,
seats: true, seats: true,
unlimitedSeats: true, unlimitedSeats: true,
partnerTier: true,
periodStart: true, periodStart: true,
periodEnd: true, periodEnd: true,
}, },

View File

@@ -12,6 +12,7 @@ export interface Subscription {
status: string; status: string;
seats: number | null; seats: number | null;
unlimitedSeats: boolean; unlimitedSeats: boolean;
partnerTier: number | null;
periodStart: Date | null; periodStart: Date | null;
periodEnd: Date | null; periodEnd: Date | null;
referenceId: string | null; referenceId: string | null;