mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
Free/Pro plan limitations
This commit is contained in:
@@ -7,3 +7,11 @@ export * from "./sprints";
|
|||||||
export * from "./subscriptions";
|
export * from "./subscriptions";
|
||||||
export * from "./timed-sessions";
|
export * from "./timed-sessions";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
|
||||||
|
// free tier limits
|
||||||
|
export const FREE_TIER_LIMITS = {
|
||||||
|
organisationsPerUser: 1,
|
||||||
|
projectsPerOrganisation: 1,
|
||||||
|
issuesPerOrganisation: 100,
|
||||||
|
membersPerOrganisation: 5,
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -259,6 +259,25 @@ export async function getIssueAssigneeCount(issueId: number): Promise<number> {
|
|||||||
return result?.count ?? 0;
|
return result?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOrganisationIssueCount(organisationId: number): Promise<number> {
|
||||||
|
const { Project } = await import("@sprint/shared");
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select({ id: Project.id })
|
||||||
|
.from(Project)
|
||||||
|
.where(eq(Project.organisationId, organisationId));
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
|
|
||||||
|
if (projectIds.length === 0) return 0;
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
|
.from(Issue)
|
||||||
|
.where(inArray(Issue.projectId, projectIds));
|
||||||
|
|
||||||
|
return result?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function isIssueAssignee(issueId: number, userId: number): Promise<boolean> {
|
export async function isIssueAssignee(issueId: number, userId: number): Promise<boolean> {
|
||||||
const [assignee] = await db
|
const [assignee] = await db
|
||||||
.select({ id: IssueAssignee.id })
|
.select({ id: IssueAssignee.id })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Organisation, OrganisationMember, User } from "@sprint/shared";
|
import { Organisation, OrganisationMember, User } from "@sprint/shared";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
|
|
||||||
export async function createOrganisation(name: string, slug: string, description?: string) {
|
export async function createOrganisation(name: string, slug: string, description?: string) {
|
||||||
@@ -144,3 +144,11 @@ export async function updateOrganisationMemberRole(organisationId: number, userI
|
|||||||
.returning();
|
.returning();
|
||||||
return member;
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserOrganisationCount(userId: number): Promise<number> {
|
||||||
|
const [result] = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
|
.from(OrganisationMember)
|
||||||
|
.where(eq(OrganisationMember.userId, userId));
|
||||||
|
return result?.count ?? 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
|
import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
|
|
||||||
export async function createProject(key: string, name: string, creatorId: number, organisationId: number) {
|
export async function createProject(key: string, name: string, creatorId: number, organisationId: number) {
|
||||||
@@ -82,3 +82,11 @@ export async function getProjectsByOrganisationId(organisationId: number) {
|
|||||||
.leftJoin(Organisation, eq(Project.organisationId, Organisation.id));
|
.leftJoin(Organisation, eq(Project.organisationId, Organisation.id));
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOrganisationProjectCount(organisationId: number): Promise<number> {
|
||||||
|
const [result] = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
|
.from(Project)
|
||||||
|
.where(eq(Project.organisationId, organisationId));
|
||||||
|
return result?.count ?? 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { IssueCreateRequestSchema } from "@sprint/shared";
|
import { IssueCreateRequestSchema } from "@sprint/shared";
|
||||||
import type { AuthedRequest } from "../../auth/middleware";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { createIssue, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
|
import {
|
||||||
|
createIssue,
|
||||||
|
FREE_TIER_LIMITS,
|
||||||
|
getOrganisationIssueCount,
|
||||||
|
getOrganisationMemberRole,
|
||||||
|
getProjectByID,
|
||||||
|
getUserById,
|
||||||
|
} from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function issueCreate(req: AuthedRequest) {
|
export default async function issueCreate(req: AuthedRequest) {
|
||||||
@@ -26,6 +33,19 @@ export default async function issueCreate(req: AuthedRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check free tier limit
|
||||||
|
const user = await getUserById(req.userId);
|
||||||
|
if (user && user.plan !== "pro") {
|
||||||
|
const issueCount = await getOrganisationIssueCount(project.organisationId);
|
||||||
|
if (issueCount >= FREE_TIER_LIMITS.issuesPerOrganisation) {
|
||||||
|
return errorResponse(
|
||||||
|
`free tier is limited to ${FREE_TIER_LIMITS.issuesPerOrganisation} issues per organisation. upgrade to pro for unlimited issues.`,
|
||||||
|
"FREE_TIER_ISSUE_LIMIT",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const issue = await createIssue(
|
const issue = await createIssue(
|
||||||
project.id,
|
project.id,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { OrgAddMemberRequestSchema } from "@sprint/shared";
|
|||||||
import type { AuthedRequest } from "../../auth/middleware";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import {
|
import {
|
||||||
createOrganisationMember,
|
createOrganisationMember,
|
||||||
|
FREE_TIER_LIMITS,
|
||||||
getOrganisationById,
|
getOrganisationById,
|
||||||
getOrganisationMemberRole,
|
getOrganisationMemberRole,
|
||||||
|
getOrganisationMembers,
|
||||||
getUserById,
|
getUserById,
|
||||||
} from "../../db/queries";
|
} from "../../db/queries";
|
||||||
import { updateSeatCount } from "../../lib/seats";
|
import { updateSeatCount } from "../../lib/seats";
|
||||||
@@ -39,6 +41,19 @@ export default async function organisationAddMember(req: AuthedRequest) {
|
|||||||
return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403);
|
return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check free tier member limit
|
||||||
|
const requester = await getUserById(req.userId);
|
||||||
|
if (requester && requester.plan !== "pro") {
|
||||||
|
const members = await getOrganisationMembers(organisationId);
|
||||||
|
if (members.length >= FREE_TIER_LIMITS.membersPerOrganisation) {
|
||||||
|
return errorResponse(
|
||||||
|
`free tier is limited to ${FREE_TIER_LIMITS.membersPerOrganisation} members per organisation. upgrade to pro for unlimited members.`,
|
||||||
|
"FREE_TIER_MEMBER_LIMIT",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const member = await createOrganisationMember(organisationId, userId, role);
|
const member = await createOrganisationMember(organisationId, userId, role);
|
||||||
|
|
||||||
// update seat count if the requester is the owner
|
// update seat count if the requester is the owner
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { OrgCreateRequestSchema } from "@sprint/shared";
|
import { OrgCreateRequestSchema } from "@sprint/shared";
|
||||||
import type { AuthedRequest } from "../../auth/middleware";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { createOrganisationWithOwner, getOrganisationBySlug } from "../../db/queries";
|
import {
|
||||||
|
createOrganisationWithOwner,
|
||||||
|
FREE_TIER_LIMITS,
|
||||||
|
getOrganisationBySlug,
|
||||||
|
getUserById,
|
||||||
|
getUserOrganisationCount,
|
||||||
|
} from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function organisationCreate(req: AuthedRequest) {
|
export default async function organisationCreate(req: AuthedRequest) {
|
||||||
@@ -14,6 +20,19 @@ export default async function organisationCreate(req: AuthedRequest) {
|
|||||||
return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409);
|
return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check free tier limit
|
||||||
|
const user = await getUserById(req.userId);
|
||||||
|
if (user && user.plan !== "pro") {
|
||||||
|
const orgCount = await getUserOrganisationCount(req.userId);
|
||||||
|
if (orgCount >= FREE_TIER_LIMITS.organisationsPerUser) {
|
||||||
|
return errorResponse(
|
||||||
|
`free tier is limited to ${FREE_TIER_LIMITS.organisationsPerUser} organisation. upgrade to pro for unlimited organisations.`,
|
||||||
|
"FREE_TIER_ORG_LIMIT",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const organisation = await createOrganisationWithOwner(name, slug, req.userId, description);
|
const organisation = await createOrganisationWithOwner(name, slug, req.userId, description);
|
||||||
|
|
||||||
return Response.json(organisation);
|
return Response.json(organisation);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { ProjectCreateRequestSchema } from "@sprint/shared";
|
import { ProjectCreateRequestSchema } from "@sprint/shared";
|
||||||
import type { AuthedRequest } from "../../auth/middleware";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { createProject, getOrganisationMemberRole, getProjectByKey, getUserById } from "../../db/queries";
|
import {
|
||||||
|
createProject,
|
||||||
|
FREE_TIER_LIMITS,
|
||||||
|
getOrganisationMemberRole,
|
||||||
|
getOrganisationProjectCount,
|
||||||
|
getProjectByKey,
|
||||||
|
getUserById,
|
||||||
|
} from "../../db/queries";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function projectCreate(req: AuthedRequest) {
|
export default async function projectCreate(req: AuthedRequest) {
|
||||||
@@ -22,7 +29,19 @@ export default async function projectCreate(req: AuthedRequest) {
|
|||||||
return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403);
|
return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check free tier limit
|
||||||
const creator = await getUserById(req.userId);
|
const creator = await getUserById(req.userId);
|
||||||
|
if (creator && creator.plan !== "pro") {
|
||||||
|
const projectCount = await getOrganisationProjectCount(organisationId);
|
||||||
|
if (projectCount >= FREE_TIER_LIMITS.projectsPerOrganisation) {
|
||||||
|
return errorResponse(
|
||||||
|
`free tier is limited to ${FREE_TIER_LIMITS.projectsPerOrganisation} project per organisation. upgrade to pro for unlimited projects.`,
|
||||||
|
"FREE_TIER_PROJECT_LIMIT",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!creator) {
|
if (!creator) {
|
||||||
return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);
|
return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);
|
||||||
}
|
}
|
||||||
|
|||||||
93
packages/frontend/src/components/free-tier-limit.tsx
Normal file
93
packages/frontend/src/components/free-tier-limit.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Icon from "@/components/ui/icon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function FreeTierLimit({
|
||||||
|
current,
|
||||||
|
limit,
|
||||||
|
itemName,
|
||||||
|
isPro,
|
||||||
|
className,
|
||||||
|
showUpgrade = true,
|
||||||
|
}: {
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
itemName: string;
|
||||||
|
isPro: boolean;
|
||||||
|
className?: string;
|
||||||
|
showUpgrade?: boolean;
|
||||||
|
}) {
|
||||||
|
if (isPro) return null;
|
||||||
|
|
||||||
|
const percentage = Math.min((current / limit) * 100, 100);
|
||||||
|
const isAtLimit = current >= limit;
|
||||||
|
const isNearLimit = percentage >= 80 && !isAtLimit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{current} / {limit} {itemName}
|
||||||
|
{current !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{isAtLimit && <span className="text-destructive font-medium">Limit reached</span>}
|
||||||
|
{isNearLimit && <span className="text-yellow-600 font-medium">Almost at limit</span>}
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-300",
|
||||||
|
isAtLimit ? "bg-destructive" : isNearLimit ? "bg-yellow-500" : "bg-personality",
|
||||||
|
)}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isAtLimit && showUpgrade && (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Icon icon="info" className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Upgrade to Pro for unlimited {itemName}s</span>
|
||||||
|
<Button asChild variant="link" size="sm" className="h-auto p-0 text-xs text-personality">
|
||||||
|
<Link to="/plans">Upgrade</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FreeTierLimitBadgeProps {
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
itemName: string;
|
||||||
|
isPro: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FreeTierLimitBadge({ current, limit, itemName, isPro, className }: FreeTierLimitBadgeProps) {
|
||||||
|
if (isPro) return null;
|
||||||
|
|
||||||
|
const isAtLimit = current >= limit;
|
||||||
|
const percentage = (current / limit) * 100;
|
||||||
|
const isNearLimit = percentage >= 80 && !isAtLimit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-md border",
|
||||||
|
isAtLimit
|
||||||
|
? "bg-destructive/10 border-destructive/30 text-destructive"
|
||||||
|
: isNearLimit
|
||||||
|
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-700"
|
||||||
|
: "bg-muted border-border text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon={isAtLimit ? "alertTriangle" : isNearLimit ? "info" : "check"} className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
{current}/{limit} {itemName}
|
||||||
|
{current !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/sh
|
|||||||
|
|
||||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||||
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
|
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
|
||||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
import { SprintSelect } from "@/components/sprint-select";
|
import { SprintSelect } from "@/components/sprint-select";
|
||||||
@@ -23,6 +24,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { SelectTrigger } from "@/components/ui/select";
|
import { SelectTrigger } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
useCreateIssue,
|
useCreateIssue,
|
||||||
|
useIssues,
|
||||||
useOrganisationMembers,
|
useOrganisationMembers,
|
||||||
useSelectedOrganisation,
|
useSelectedOrganisation,
|
||||||
useSelectedProject,
|
useSelectedProject,
|
||||||
@@ -31,14 +33,21 @@ import {
|
|||||||
import { parseError } from "@/lib/server";
|
import { parseError } from "@/lib/server";
|
||||||
import { cn, issueID } from "@/lib/utils";
|
import { cn, issueID } from "@/lib/utils";
|
||||||
|
|
||||||
|
const FREE_TIER_ISSUE_LIMIT = 100;
|
||||||
|
|
||||||
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||||
const { user } = useAuthenticatedSession();
|
const { user } = useAuthenticatedSession();
|
||||||
const selectedOrganisation = useSelectedOrganisation();
|
const selectedOrganisation = useSelectedOrganisation();
|
||||||
const selectedProject = useSelectedProject();
|
const selectedProject = useSelectedProject();
|
||||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||||
|
const { data: issues = [] } = useIssues(selectedProject?.Project.id);
|
||||||
const createIssue = useCreateIssue();
|
const createIssue = useCreateIssue();
|
||||||
|
|
||||||
|
const isPro = user.plan === "pro";
|
||||||
|
const issueCount = issues.length;
|
||||||
|
const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT;
|
||||||
|
|
||||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||||
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
||||||
@@ -138,7 +147,17 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger || (
|
{trigger || (
|
||||||
<Button variant="outline" disabled={!selectedProject}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!selectedProject || isAtIssueLimit}
|
||||||
|
title={
|
||||||
|
isAtIssueLimit
|
||||||
|
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
|
||||||
|
: !selectedProject
|
||||||
|
? "Select a project first"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
Create Issue
|
Create Issue
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -149,6 +168,18 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
|||||||
<DialogTitle>Create Issue</DialogTitle>
|
<DialogTitle>Create Issue</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!isPro && selectedProject && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<FreeTierLimit
|
||||||
|
current={issueCount}
|
||||||
|
limit={FREE_TIER_ISSUE_LIMIT}
|
||||||
|
itemName="issue"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={isAtIssueLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{(typeOptions.length > 0 || statusOptions.length > 0) && (
|
{(typeOptions.length > 0 || statusOptions.length > 0) && (
|
||||||
@@ -270,10 +301,16 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
submitting ||
|
submitting ||
|
||||||
|
isAtIssueLimit ||
|
||||||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
|
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
|
||||||
submitAttempted) ||
|
submitAttempted) ||
|
||||||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
|
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
|
||||||
}
|
}
|
||||||
|
title={
|
||||||
|
isAtIssueLimit
|
||||||
|
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{submitting ? "Creating..." : "Create"}
|
{submitting ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||||
import { OrganisationForm } from "@/components/organisation-form";
|
import { OrganisationForm } from "@/components/organisation-form";
|
||||||
import { useSelection } from "@/components/selection-provider";
|
import { useSelection } from "@/components/selection-provider";
|
||||||
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -17,6 +19,8 @@ import { useOrganisations } from "@/lib/query/hooks";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import OrgIcon from "./org-icon";
|
import OrgIcon from "./org-icon";
|
||||||
|
|
||||||
|
const FREE_TIER_ORG_LIMIT = 1;
|
||||||
|
|
||||||
export function OrganisationSelect({
|
export function OrganisationSelect({
|
||||||
placeholder = "Select Organisation",
|
placeholder = "Select Organisation",
|
||||||
contentClass,
|
contentClass,
|
||||||
@@ -40,6 +44,11 @@ export function OrganisationSelect({
|
|||||||
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
|
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
|
||||||
const { data: organisationsData = [] } = useOrganisations();
|
const { data: organisationsData = [] } = useOrganisations();
|
||||||
const { selectedOrganisationId, selectOrganisation } = useSelection();
|
const { selectedOrganisationId, selectOrganisation } = useSelection();
|
||||||
|
const { user } = useAuthenticatedSession();
|
||||||
|
|
||||||
|
const isPro = user.plan === "pro";
|
||||||
|
const orgCount = organisationsData.length;
|
||||||
|
const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT;
|
||||||
|
|
||||||
const organisations = useMemo(
|
const organisations = useMemo(
|
||||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
@@ -107,9 +116,31 @@ export function OrganisationSelect({
|
|||||||
{organisations.length > 0 && <SelectSeparator />}
|
{organisations.length > 0 && <SelectSeparator />}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
|
|
||||||
|
{!isPro && (
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<FreeTierLimit
|
||||||
|
current={orgCount}
|
||||||
|
limit={FREE_TIER_ORG_LIMIT}
|
||||||
|
itemName="organisation"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={isAtOrgLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<OrganisationForm
|
<OrganisationForm
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" className={"w-full"} size={"sm"}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={"w-full"}
|
||||||
|
size={"sm"}
|
||||||
|
disabled={isAtOrgLimit}
|
||||||
|
title={
|
||||||
|
isAtOrgLimit
|
||||||
|
? "Free tier limited to 1 organisation. Upgrade to Pro for unlimited."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
Create Organisation
|
Create Organisation
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
} from "@sprint/shared";
|
} from "@sprint/shared";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddMember } from "@/components/add-member";
|
import { AddMember } from "@/components/add-member";
|
||||||
|
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||||
import OrgIcon from "@/components/org-icon";
|
import OrgIcon from "@/components/org-icon";
|
||||||
import { OrganisationForm } from "@/components/organisation-form";
|
import { OrganisationForm } from "@/components/organisation-form";
|
||||||
import { OrganisationSelect } from "@/components/organisation-select";
|
import { OrganisationSelect } from "@/components/organisation-select";
|
||||||
@@ -42,6 +44,7 @@ import {
|
|||||||
useDeleteOrganisation,
|
useDeleteOrganisation,
|
||||||
useDeleteProject,
|
useDeleteProject,
|
||||||
useDeleteSprint,
|
useDeleteSprint,
|
||||||
|
useIssues,
|
||||||
useOrganisationMembers,
|
useOrganisationMembers,
|
||||||
useOrganisationMemberTimeTracking,
|
useOrganisationMemberTimeTracking,
|
||||||
useOrganisations,
|
useOrganisations,
|
||||||
@@ -58,6 +61,13 @@ import { apiClient } from "@/lib/server";
|
|||||||
import { capitalise, formatDuration, unCamelCase } from "@/lib/utils";
|
import { capitalise, formatDuration, unCamelCase } from "@/lib/utils";
|
||||||
import { Switch } from "./ui/switch";
|
import { Switch } from "./ui/switch";
|
||||||
|
|
||||||
|
const FREE_TIER_LIMITS = {
|
||||||
|
organisationsPerUser: 1,
|
||||||
|
projectsPerOrganisation: 1,
|
||||||
|
issuesPerOrganisation: 100,
|
||||||
|
membersPerOrganisation: 5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function Organisations({ trigger }: { trigger?: ReactNode }) {
|
function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||||
const { user } = useAuthenticatedSession();
|
const { user } = useAuthenticatedSession();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -66,6 +76,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||||
const { data: sprints = [] } = useSprints(selectedProjectId);
|
const { data: sprints = [] } = useSprints(selectedProjectId);
|
||||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
||||||
|
const { data: issues = [] } = useIssues(selectedProjectId);
|
||||||
const updateOrganisation = useUpdateOrganisation();
|
const updateOrganisation = useUpdateOrganisation();
|
||||||
const updateMemberRole = useUpdateOrganisationMemberRole();
|
const updateMemberRole = useUpdateOrganisationMemberRole();
|
||||||
const removeMember = useRemoveOrganisationMember();
|
const removeMember = useRemoveOrganisationMember();
|
||||||
@@ -75,6 +86,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
const replaceIssueStatus = useReplaceIssueStatus();
|
const replaceIssueStatus = useReplaceIssueStatus();
|
||||||
const replaceIssueType = useReplaceIssueType();
|
const replaceIssueType = useReplaceIssueType();
|
||||||
|
|
||||||
|
const isPro = user.plan === "pro";
|
||||||
|
const orgCount = organisationsData.length;
|
||||||
|
const projectCount = projectsData.length;
|
||||||
|
const issueCount = issues.length;
|
||||||
|
const memberCount = membersData.length;
|
||||||
|
|
||||||
const organisations = useMemo(
|
const organisations = useMemo(
|
||||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
[organisationsData],
|
[organisationsData],
|
||||||
@@ -823,6 +840,49 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
<p className="text-sm text-muted-foreground break-words">No description</p>
|
<p className="text-sm text-muted-foreground break-words">No description</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Free tier limits section */}
|
||||||
|
{!isPro && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-600">Plan Limits</h3>
|
||||||
|
<Button asChild variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||||
|
<Link to="/plans">Upgrade to Pro</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<FreeTierLimit
|
||||||
|
current={orgCount}
|
||||||
|
limit={FREE_TIER_LIMITS.organisationsPerUser}
|
||||||
|
itemName="organisation"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={false}
|
||||||
|
/>
|
||||||
|
<FreeTierLimit
|
||||||
|
current={projectCount}
|
||||||
|
limit={FREE_TIER_LIMITS.projectsPerOrganisation}
|
||||||
|
itemName="project"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={false}
|
||||||
|
/>
|
||||||
|
<FreeTierLimit
|
||||||
|
current={issueCount}
|
||||||
|
limit={FREE_TIER_LIMITS.issuesPerOrganisation}
|
||||||
|
itemName="issue"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={false}
|
||||||
|
/>
|
||||||
|
<FreeTierLimit
|
||||||
|
current={memberCount}
|
||||||
|
limit={FREE_TIER_LIMITS.membersPerOrganisation}
|
||||||
|
itemName="member"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
|
||||||
@@ -968,6 +1028,18 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
{!isPro && (
|
||||||
|
<div className="px-1">
|
||||||
|
<FreeTierLimit
|
||||||
|
current={memberCount}
|
||||||
|
limit={FREE_TIER_LIMITS.membersPerOrganisation}
|
||||||
|
itemName="member"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AddMember
|
<AddMember
|
||||||
organisationId={selectedOrganisation.Organisation.id}
|
organisationId={selectedOrganisation.Organisation.id}
|
||||||
existingMembers={members.map((m) => m.User.username)}
|
existingMembers={members.map((m) => m.User.username)}
|
||||||
@@ -982,11 +1054,20 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
void invalidateMembers();
|
void invalidateMembers();
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||||
|
title={
|
||||||
|
!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation
|
||||||
|
? "Free tier limited to 5 members per organisation. Upgrade to Pro for unlimited."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
Add user <Icon icon="plus" className="size-4" />
|
Add user <Icon icon="plus" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||||
import { ProjectForm } from "@/components/project-form";
|
import { ProjectForm } from "@/components/project-form";
|
||||||
import { useSelection } from "@/components/selection-provider";
|
import { useSelection } from "@/components/selection-provider";
|
||||||
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -14,6 +16,8 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useProjects } from "@/lib/query/hooks";
|
import { useProjects } from "@/lib/query/hooks";
|
||||||
|
|
||||||
|
const FREE_TIER_PROJECT_LIMIT = 1;
|
||||||
|
|
||||||
export function ProjectSelect({
|
export function ProjectSelect({
|
||||||
placeholder = "Select Project",
|
placeholder = "Select Project",
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
@@ -29,6 +33,11 @@ export function ProjectSelect({
|
|||||||
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
|
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
|
||||||
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
|
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
|
||||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||||
|
const { user } = useAuthenticatedSession();
|
||||||
|
|
||||||
|
const isPro = user.plan === "pro";
|
||||||
|
const projectCount = projectsData.length;
|
||||||
|
const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT;
|
||||||
|
|
||||||
const projects = useMemo(
|
const projects = useMemo(
|
||||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||||
@@ -81,10 +90,35 @@ export function ProjectSelect({
|
|||||||
))}
|
))}
|
||||||
{projects.length > 0 && <SelectSeparator />}
|
{projects.length > 0 && <SelectSeparator />}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
|
|
||||||
|
{!isPro && selectedOrganisationId && (
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<FreeTierLimit
|
||||||
|
current={projectCount}
|
||||||
|
limit={FREE_TIER_PROJECT_LIMIT}
|
||||||
|
itemName="project"
|
||||||
|
isPro={isPro}
|
||||||
|
showUpgrade={isAtProjectLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ProjectForm
|
<ProjectForm
|
||||||
organisationId={selectedOrganisationId ?? undefined}
|
organisationId={selectedOrganisationId ?? undefined}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!selectedOrganisationId}>
|
<Button
|
||||||
|
size={"sm"}
|
||||||
|
variant="ghost"
|
||||||
|
className={"w-full"}
|
||||||
|
disabled={!selectedOrganisationId || isAtProjectLimit}
|
||||||
|
title={
|
||||||
|
isAtProjectLimit
|
||||||
|
? "Free tier limited to 1 project per organisation. Upgrade to Pro for unlimited."
|
||||||
|
: !selectedOrganisationId
|
||||||
|
? "Select an organisation first"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
Create Project
|
Create Project
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const faqs = [
|
const faqs = [
|
||||||
{
|
|
||||||
question: "Can I switch plans?",
|
|
||||||
answer:
|
|
||||||
"Yes, you can upgrade or downgrade at any time. Changes take effect immediately, and we'll prorate any charges.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
question: "What payment methods do you accept?",
|
question: "What payment methods do you accept?",
|
||||||
answer: "We accept all major credit cards.",
|
answer: "We accept all major credit cards.",
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function Plans() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && isProUser && (
|
{user && isProUser && (
|
||||||
<div className="w-full max-w-4xl mx-auto border rounded-md p-6">
|
<div className="w-full max-w-4xl mx-auto border p-4">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-700">Cancel subscription</p>
|
<p className="font-700">Cancel subscription</p>
|
||||||
@@ -264,7 +264,7 @@ export default function Plans() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* trust signals */}
|
{/* trust signals */}
|
||||||
<div className="grid md:grid-cols-3 gap-8 w-full border-t pt-16 pb-8 max-w-4xl mx-auto">
|
<div className="grid md:grid-cols-3 gap-8 w-full border-t pt-16 pb-4 max-w-4xl mx-auto">
|
||||||
<div className="flex flex-col items-center text-center gap-2">
|
<div className="flex flex-col items-center text-center gap-2">
|
||||||
<Icon icon="eyeClosed" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
|
<Icon icon="eyeClosed" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
|
||||||
<p className="font-700">Secure & Encrypted</p>
|
<p className="font-700">Secure & Encrypted</p>
|
||||||
@@ -286,33 +286,6 @@ export default function Plans() {
|
|||||||
<p className="text-sm text-muted-foreground">30-day no-risk policy</p>
|
<p className="text-sm text-muted-foreground">30-day no-risk policy</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQ */}
|
|
||||||
<div className="max-w-2xl mx-auto space-y-8 border-t pt-16">
|
|
||||||
<h2 className="text-3xl font-basteleur font-700 text-center">Frequently Asked Questions</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-700">Can I switch plans?</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Yes, you can upgrade or downgrade at any time. Changes take effect immediately.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-700">What happens when I add team members?</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your
|
|
||||||
billing automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-700">Can I cancel my subscription?</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Absolutely. Cancel anytime with no questions asked. You'll keep access until the end of your
|
|
||||||
billing period.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user