From 97f08fa9fb5771b757d06ac240b261a6ef6ce4cc Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 7 Sep 2025 21:47:13 +0100 Subject: [PATCH] feat: add unlimited invites messaging --- apps/web/src/utils/subscriptions.ts | 62 +++++++++++++++++++ .../members/components/InviteMemberForm.tsx | 48 +++++++------- apps/web/src/views/members/index.tsx | 30 ++++++--- 3 files changed, 108 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/utils/subscriptions.ts diff --git a/apps/web/src/utils/subscriptions.ts b/apps/web/src/utils/subscriptions.ts new file mode 100644 index 00000000..99e1a9bc --- /dev/null +++ b/apps/web/src/utils/subscriptions.ts @@ -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); +}; diff --git a/apps/web/src/views/members/components/InviteMemberForm.tsx b/apps/web/src/views/members/components/InviteMemberForm.tsx index f22049cf..30e263d2 100644 --- a/apps/web/src/views/members/components/InviteMemberForm.tsx +++ b/apps/web/src/views/members/components/InviteMemberForm.tsx @@ -9,6 +9,7 @@ import { z } from "zod"; import type { InviteMemberInput } from "@kan/api/types"; import { authClient } from "@kan/auth/client"; +import type { Subscription } from "~/utils/subscriptions"; import Button from "~/components/Button"; import Input from "~/components/Input"; import Toggle from "~/components/Toggle"; @@ -16,23 +17,17 @@ import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; +import { getSubscriptionByPlan } from "~/utils/subscriptions"; export function InviteMemberForm({ numberOfMembers, - activeTeamSubscription, + subscriptions, + unlimitedSeats, userId, }: { numberOfMembers: number; - activeTeamSubscription: - | { - id: number | null; - plan: string; - status: string; - seats: number | null; - periodStart: Date | null; - periodEnd: Date | null; - } - | undefined; + subscriptions: Subscription[] | undefined; + unlimitedSeats: boolean; userId: string | undefined; }) { 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 price = t`$10/month`; let billingType = t`monthly billing`; - if ( - activeTeamSubscription?.periodStart && - activeTeamSubscription?.periodEnd - ) { - const periodStartDate = new Date(activeTeamSubscription.periodStart); - const periodEndDate = new Date(activeTeamSubscription.periodEnd); + if (teamSubscription?.periodStart && teamSubscription?.periodEnd) { + const periodStartDate = new Date(teamSubscription.periodStart); + const periodEndDate = new Date(teamSubscription.periodEnd); const diffInDays = Math.round( (periodEndDate.getTime() - periodStartDate.getTime()) / (1000 * 60 * 60 * 24), @@ -163,7 +161,8 @@ export function InviteMemberForm({ placeholder={t`Email`} disabled={ env("NEXT_PUBLIC_KAN_ENV") === "cloud" && - !activeTeamSubscription?.id + !hasTeamSubscription && + !hasProSubscription } {...register("email", { required: true })} onKeyDown={async (e) => { @@ -177,13 +176,15 @@ export function InviteMemberForm({ {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
- {activeTeamSubscription?.id ? ( + {hasTeamSubscription || hasProSubscription ? (
- {t`Team Plan`} + {hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}

- {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.`}

) : ( @@ -201,7 +202,7 @@ export function InviteMemberForm({
- {activeTeamSubscription?.id && + {(hasTeamSubscription || hasProSubscription) && env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && - !activeTeamSubscription?.id ? ( + !hasTeamSubscription && + !hasProSubscription ? (