feat(cloud): improved upgrade journey
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
)}
|
)}
|
||||||
|
|||||||
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user