feat: add unlimited invites messaging
This commit is contained in:
62
apps/web/src/utils/subscriptions.ts
Normal file
62
apps/web/src/utils/subscriptions.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user