Free/Pro plan limitations

This commit is contained in:
2026-01-28 22:12:32 +00:00
parent c0e06ac8ba
commit 7f3cb7c890
15 changed files with 420 additions and 60 deletions

View 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>
);
}

View File

@@ -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 { toast } from "sonner";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useAuthenticatedSession } from "@/components/session-provider";
import { SprintSelect } from "@/components/sprint-select";
@@ -23,6 +24,7 @@ import { Label } from "@/components/ui/label";
import { SelectTrigger } from "@/components/ui/select";
import {
useCreateIssue,
useIssues,
useOrganisationMembers,
useSelectedOrganisation,
useSelectedProject,
@@ -31,14 +33,21 @@ import {
import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils";
const FREE_TIER_ISSUE_LIMIT = 100;
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
const { user } = useAuthenticatedSession();
const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const { data: issues = [] } = useIssues(selectedProject?.Project.id);
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 statuses = selectedOrganisation?.Organisation.statuses ?? {};
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
@@ -138,7 +147,17 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{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
</Button>
)}
@@ -149,6 +168,18 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
<DialogTitle>Create Issue</DialogTitle>
</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}>
<div className="grid">
{(typeOptions.length > 0 || statusOptions.length > 0) && (
@@ -270,10 +301,16 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
type="submit"
disabled={
submitting ||
isAtIssueLimit ||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_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"}
</Button>

View File

@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { OrganisationForm } from "@/components/organisation-form";
import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -17,6 +19,8 @@ import { useOrganisations } from "@/lib/query/hooks";
import { cn } from "@/lib/utils";
import OrgIcon from "./org-icon";
const FREE_TIER_ORG_LIMIT = 1;
export function OrganisationSelect({
placeholder = "Select Organisation",
contentClass,
@@ -40,6 +44,11 @@ export function OrganisationSelect({
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
const { data: organisationsData = [] } = useOrganisations();
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(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
@@ -107,9 +116,31 @@ export function OrganisationSelect({
{organisations.length > 0 && <SelectSeparator />}
</SelectGroup>
{!isPro && (
<div className="px-2 py-2">
<FreeTierLimit
current={orgCount}
limit={FREE_TIER_ORG_LIMIT}
itemName="organisation"
isPro={isPro}
showUpgrade={isAtOrgLimit}
/>
</div>
)}
<OrganisationForm
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
</Button>
}

View File

@@ -8,8 +8,10 @@ import {
} from "@sprint/shared";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { AddMember } from "@/components/add-member";
import { FreeTierLimit } from "@/components/free-tier-limit";
import OrgIcon from "@/components/org-icon";
import { OrganisationForm } from "@/components/organisation-form";
import { OrganisationSelect } from "@/components/organisation-select";
@@ -42,6 +44,7 @@ import {
useDeleteOrganisation,
useDeleteProject,
useDeleteSprint,
useIssues,
useOrganisationMembers,
useOrganisationMemberTimeTracking,
useOrganisations,
@@ -58,6 +61,13 @@ import { apiClient } from "@/lib/server";
import { capitalise, formatDuration, unCamelCase } from "@/lib/utils";
import { Switch } from "./ui/switch";
const FREE_TIER_LIMITS = {
organisationsPerUser: 1,
projectsPerOrganisation: 1,
issuesPerOrganisation: 100,
membersPerOrganisation: 5,
} as const;
function Organisations({ trigger }: { trigger?: ReactNode }) {
const { user } = useAuthenticatedSession();
const queryClient = useQueryClient();
@@ -66,6 +76,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const { data: sprints = [] } = useSprints(selectedProjectId);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
const { data: issues = [] } = useIssues(selectedProjectId);
const updateOrganisation = useUpdateOrganisation();
const updateMemberRole = useUpdateOrganisationMemberRole();
const removeMember = useRemoveOrganisationMember();
@@ -75,6 +86,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const replaceIssueStatus = useReplaceIssueStatus();
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(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
[organisationsData],
@@ -823,6 +840,49 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<p className="text-sm text-muted-foreground break-words">No description</p>
)}
</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 && (
<div className="flex gap-2 mt-3">
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
@@ -968,25 +1028,46 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
))}
</div>
{isAdmin && (
<AddMember
organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)}
onSuccess={(user) => {
toast.success(
`${user.name} added to ${selectedOrganisation.Organisation.name} successfully`,
{
dismissible: false,
},
);
<>
{!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
organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)}
onSuccess={(user) => {
toast.success(
`${user.name} added to ${selectedOrganisation.Organisation.name} successfully`,
{
dismissible: false,
},
);
void invalidateMembers();
}}
trigger={
<Button variant="outline">
Add user <Icon icon="plus" className="size-4" />
</Button>
}
/>
void invalidateMembers();
}}
trigger={
<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" />
</Button>
}
/>
</>
)}
</div>
</div>

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { ProjectForm } from "@/components/project-form";
import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -14,6 +16,8 @@ import {
} from "@/components/ui/select";
import { useProjects } from "@/lib/query/hooks";
const FREE_TIER_PROJECT_LIMIT = 1;
export function ProjectSelect({
placeholder = "Select Project",
showLabel = false,
@@ -29,6 +33,11 @@ export function ProjectSelect({
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
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(
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
@@ -81,10 +90,35 @@ export function ProjectSelect({
))}
{projects.length > 0 && <SelectSeparator />}
</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
organisationId={selectedOrganisationId ?? undefined}
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
</Button>
}

View File

@@ -10,11 +10,6 @@ import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
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?",
answer: "We accept all major credit cards.",

View File

@@ -216,7 +216,7 @@ export default function Plans() {
</div>
{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="space-y-1">
<p className="font-700">Cancel subscription</p>
@@ -264,7 +264,7 @@ export default function Plans() {
)}
{/* 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">
<Icon icon="eyeClosed" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
<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>
</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>
</main>