feat: add unlimited invites messaging

This commit is contained in:
Henry
2025-09-07 21:47:13 +01:00
parent cdda0a9de1
commit 97f08fa9fb
3 changed files with 108 additions and 32 deletions

View File

@@ -0,0 +1,62 @@
import { env } from "next-runtime-env";
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "unpaid";
export type SubscriptionPlan = "team" | "pro";
export interface Subscription {
id: number | null;
plan: string;
status: string;
seats: number | null;
unlimitedSeats: boolean;
periodStart: Date | null;
periodEnd: Date | null;
}
export const getActiveSubscriptions = (
subscriptions: Subscription[] | undefined,
) => {
if (!subscriptions) return [];
return subscriptions.filter(
(sub) => sub.status === "active" || sub.status === "trialing",
);
};
export const getSubscriptionByPlan = (
subscriptions: Subscription[] | undefined,
plan: SubscriptionPlan,
) => {
if (!subscriptions) return undefined;
return subscriptions.find(
(sub) =>
sub.plan === plan &&
(sub.status === "active" || sub.status === "trialing"),
);
};
export const hasActiveSubscription = (
subscriptions: Subscription[] | undefined,
plan: SubscriptionPlan,
) => {
return getSubscriptionByPlan(subscriptions, plan) !== undefined;
};
export const canInviteMembers = (subscriptions: Subscription[] | undefined) => {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
return hasActiveSubscription(subscriptions, "team");
}
return true;
};
export const hasUnlimitedSeats = (
subscriptions: Subscription[] | undefined,
) => {
const activeSubscriptions = getActiveSubscriptions(subscriptions);
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
};

View File

@@ -9,6 +9,7 @@ import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types"; import type { InviteMemberInput } from "@kan/api/types";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import type { Subscription } from "~/utils/subscriptions";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import Toggle from "~/components/Toggle"; import Toggle from "~/components/Toggle";
@@ -16,23 +17,17 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { getSubscriptionByPlan } from "~/utils/subscriptions";
export function InviteMemberForm({ export function InviteMemberForm({
numberOfMembers, numberOfMembers,
activeTeamSubscription, subscriptions,
unlimitedSeats,
userId, userId,
}: { }: {
numberOfMembers: number; numberOfMembers: number;
activeTeamSubscription: subscriptions: Subscription[] | undefined;
| { unlimitedSeats: boolean;
id: number | null;
plan: string;
status: string;
seats: number | null;
periodStart: Date | null;
periodEnd: Date | null;
}
| undefined;
userId: string | undefined; userId: string | undefined;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
@@ -87,16 +82,19 @@ export function InviteMemberForm({
}, },
}); });
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription;
let isYearly = false; let isYearly = false;
let price = t`$10/month`; let price = t`$10/month`;
let billingType = t`monthly billing`; let billingType = t`monthly billing`;
if ( if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
activeTeamSubscription?.periodStart && const periodStartDate = new Date(teamSubscription.periodStart);
activeTeamSubscription?.periodEnd const periodEndDate = new Date(teamSubscription.periodEnd);
) {
const periodStartDate = new Date(activeTeamSubscription.periodStart);
const periodEndDate = new Date(activeTeamSubscription.periodEnd);
const diffInDays = Math.round( const diffInDays = Math.round(
(periodEndDate.getTime() - periodStartDate.getTime()) / (periodEndDate.getTime() - periodStartDate.getTime()) /
(1000 * 60 * 60 * 24), (1000 * 60 * 60 * 24),
@@ -163,7 +161,8 @@ export function InviteMemberForm({
placeholder={t`Email`} placeholder={t`Email`}
disabled={ disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!activeTeamSubscription?.id !hasTeamSubscription &&
!hasProSubscription
} }
{...register("email", { required: true })} {...register("email", { required: true })}
onKeyDown={async (e) => { onKeyDown={async (e) => {
@@ -177,13 +176,15 @@ export function InviteMemberForm({
{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"> <div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{activeTeamSubscription?.id ? ( {hasTeamSubscription || hasProSubscription ? (
<div> <div>
<span className="font-medium text-emerald-500 dark:text-emerald-400"> <span className="font-medium text-emerald-500 dark:text-emerald-400">
{t`Team Plan`} {hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span> </span>
<p className="mt-1"> <p className="mt-1">
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`} {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.`}
</p> </p>
</div> </div>
) : ( ) : (
@@ -201,7 +202,7 @@ export function InviteMemberForm({
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{activeTeamSubscription?.id && {(hasTeamSubscription || hasProSubscription) &&
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<Toggle <Toggle
label={t`Invite another`} label={t`Invite another`}
@@ -213,7 +214,8 @@ export function InviteMemberForm({
)} )}
<div> <div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && {env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!activeTeamSubscription?.id ? ( !hasTeamSubscription &&
!hasProSubscription ? (
<Button <Button
type="button" type="button"
onClick={handleUpgrade} onClick={handleUpgrade}

View File

@@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import type { Subscription } from "~/utils/subscriptions";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
@@ -16,6 +17,10 @@ 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";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
import {
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "~/utils/subscriptions";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation"; import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm"; import { InviteMemberForm } from "./components/InviteMemberForm";
@@ -30,13 +35,12 @@ export default function MembersPage() {
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const subscription = data?.subscriptions; const subscriptions = data?.subscriptions as Subscription[] | undefined;
const activeTeamSubscription = subscription?.find( const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
(sub: any) => const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
sub.status === "active" ||
(sub.status === "trialing" && sub.plan === "team"), const unlimitedSeats = hasUnlimitedSeats(subscriptions);
);
const TableRow = ({ const TableRow = ({
memberPublicId, memberPublicId,
@@ -175,13 +179,20 @@ export default function MembersPage() {
<div <div
className={twMerge( className={twMerge(
"flex items-center rounded-full border px-3 py-1 text-center text-xs", "flex items-center rounded-full border px-3 py-1 text-center text-xs",
activeTeamSubscription teamSubscription || proSubscription
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400" ? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900", : "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
)} )}
> >
<span className="font-medium"> <span className="font-medium">
{activeTeamSubscription ? t`Team Plan` : t`Free Plan`} {proSubscription
? t`Pro Plan`
: teamSubscription
? t`Team Plan`
: t`Free Plan`}
{proSubscription && unlimitedSeats && (
<span className="ml-1 text-xs"></span>
)}
</span> </span>
</div> </div>
)} )}
@@ -268,7 +279,8 @@ export default function MembersPage() {
<InviteMemberForm <InviteMemberForm
userId={session?.user.id} userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1} numberOfMembers={data?.members.length ?? 1}
activeTeamSubscription={activeTeamSubscription} subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
/> />
</Modal> </Modal>