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)) && ( -

-
- - + {!isFreePlan && + (!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && ( +
+
+ + +
+
+ +

+ {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 ? ( - ) : (
- {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/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 && ( +
+ +
+ )} +
+

{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") && ( -
- -
- )} -

{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;