more Free/Pro plan limitations

This commit is contained in:
2026-01-28 23:36:03 +00:00
parent 7f3cb7c890
commit 14520618d1
14 changed files with 296 additions and 55 deletions

View File

@@ -16,6 +16,9 @@ import { useUpdateUser } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
// icon style is locked to pixel for free users
const DEFAULT_ICON_STYLE: IconStyle = "pixel";
function Account({ trigger }: { trigger?: ReactNode }) {
const { user: currentUser, setUser } = useAuthenticatedSession();
const updateUser = useUpdateUser();
@@ -35,7 +38,12 @@ function Account({ trigger }: { trigger?: ReactNode }) {
setName(currentUser.name);
setUsername(currentUser.username);
setAvatarUrl(currentUser.avatarURL || null);
setIconPreference((currentUser.iconPreference as IconStyle) ?? "pixel");
// free users are locked to pixel icon style
const effectiveIconStyle =
currentUser.plan === "pro"
? ((currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE)
: DEFAULT_ICON_STYLE;
setIconPreference(effectiveIconStyle);
setPassword("");
setError("");
@@ -51,11 +59,13 @@ function Account({ trigger }: { trigger?: ReactNode }) {
}
try {
// only send iconPreference for pro users
const effectiveIconPreference = currentUser.plan === "pro" ? iconPreference : undefined;
const data = await updateUser.mutateAsync({
name: name.trim(),
password: password.trim() || undefined,
avatarURL,
iconPreference,
iconPreference: effectiveIconPreference,
});
setError("");
setUser(data);
@@ -131,9 +141,22 @@ function Account({ trigger }: { trigger?: ReactNode }) {
<ThemeToggle withText />
</div>
<div className="flex flex-col items-start gap-1">
<Label className="text-sm">Icon Style</Label>
<Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}>
<SelectTrigger className="w-full">
<Label className={cn("text-sm", currentUser.plan !== "pro" && "text-muted-foreground")}>
Icon Style
</Label>
<Select
value={iconPreference}
onValueChange={(v) => setIconPreference(v as IconStyle)}
disabled={currentUser.plan !== "pro"}
>
<SelectTrigger
className={cn("w-full", currentUser.plan !== "pro" && "cursor-not-allowed opacity-60")}
title={
currentUser.plan !== "pro"
? "icon style customization is only available on Pro"
: undefined
}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
@@ -157,6 +180,14 @@ function Account({ trigger }: { trigger?: ReactNode }) {
</SelectItem>
</SelectContent>
</Select>
{currentUser.plan !== "pro" && (
<span className="text-xs text-muted-foreground">
<Link to="/plans" className="text-personality hover:underline">
Upgrade to Pro
</Link>{" "}
to customize icon style
</span>
)}
</div>
</div>

View File

@@ -58,7 +58,7 @@ import {
} from "@/lib/query/hooks";
import { queryKeys } from "@/lib/query/keys";
import { apiClient } from "@/lib/server";
import { capitalise, formatDuration, unCamelCase } from "@/lib/utils";
import { capitalise, cn, formatDuration, unCamelCase } from "@/lib/utils";
import { Switch } from "./ui/switch";
const FREE_TIER_LIMITS = {
@@ -943,36 +943,40 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</h2>
{isAdmin && (
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
From: {fromDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={fromDate}
onSelect={(date) => date && setFromDate(date)}
autoFocus
/>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
Download CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
Download JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isPro && (
<>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
From: {fromDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={fromDate}
onSelect={(date) => date && setFromDate(date)}
autoFocus
/>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
Download CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
Download JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
)}
</div>
@@ -990,7 +994,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</span>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
{isAdmin && isPro && (
<span className="text-sm font-mono text-muted-foreground mr-2">
{formatDuration(member.totalTimeMs)}
</span>
@@ -1518,6 +1522,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<TabsContent value="features">
<div className="border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2">Features</h2>
{!isPro && (
<div className="mb-3 p-2 bg-muted/50 rounded text-sm text-muted-foreground">
Feature toggling is only available on Pro.{" "}
<Link to="/plans" className="text-personality hover:underline">
Upgrade to customize features.
</Link>
</div>
)}
<div className="flex flex-col gap-2 w-full">
{Object.keys(DEFAULT_FEATURES).map((feature) => (
<div key={feature} className="flex items-center gap-2 p-1">
@@ -1539,9 +1551,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
);
await invalidateOrganisations();
}}
disabled={!isPro}
color={"#ff0000"}
/>
<span className={"text-sm"}>{unCamelCase(feature)}</span>
<span className={cn("text-sm", !isPro && "text-muted-foreground")}>
{unCamelCase(feature)}
</span>
</div>
))}
</div>

View File

@@ -90,8 +90,11 @@ export const pricingTiers: PricingTier[] = [
features: [
"1 organisation (owned or joined)",
"1 project",
"5 sprints",
"100 issues",
"Up to 5 team members",
"Static avatars only",
"Pixel icon style",
"Email support",
],
cta: "Get started free",
@@ -109,7 +112,11 @@ export const pricingTiers: PricingTier[] = [
"Everything in starter",
"Unlimited organisations",
"Unlimited projects",
"Unlimited sprints",
"Unlimited issues",
"Animated avatars",
"Custom icon styles",
"Feature toggling",
"Advanced time tracking & reports",
"Custom issue statuses",
"Priority email support",

View File

@@ -1,6 +1,7 @@
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -21,6 +22,7 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
const SPRINT_NAME_MAX_LENGTH = 64;
const FREE_TIER_SPRINT_LIMIT = 5;
const getStartOfDay = (date: Date) => {
const next = new Date(date);
@@ -301,6 +303,16 @@ export function SprintForm({
)}
</div>
{!isEdit && (
<FreeTierLimit
current={sprints.length}
limit={FREE_TIER_SPRINT_LIMIT}
itemName="sprint"
isPro={user.plan === "pro"}
showUpgrade={sprints.length >= FREE_TIER_SPRINT_LIMIT}
/>
)}
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
@@ -312,7 +324,13 @@ export function SprintForm({
disabled={
submitting ||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && submitAttempted) ||
(dateError !== "" && submitAttempted)
(dateError !== "" && submitAttempted) ||
(!isEdit && user.plan !== "pro" && sprints.length >= FREE_TIER_SPRINT_LIMIT)
}
title={
!isEdit && user.plan !== "pro" && sprints.length >= FREE_TIER_SPRINT_LIMIT
? `Free tier limited to ${FREE_TIER_SPRINT_LIMIT} sprints per project. Upgrade to Pro for unlimited sprints.`
: undefined
}
>
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}

View File

@@ -1,6 +1,7 @@
import { useRef, useState } from "react";
import { toast } from "sonner";
import Avatar from "@/components/avatar";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label";
@@ -8,6 +9,37 @@ import { useUploadAvatar } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
function isAnimatedGIF(file: File): Promise<boolean> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const buffer = reader.result as ArrayBuffer;
const arr = new Uint8Array(buffer);
// check for GIF89a or GIF87a header
const header = String.fromCharCode(...arr.slice(0, 6));
if (header !== "GIF89a" && header !== "GIF87a") {
resolve(false);
return;
}
// look for multiple images (animation indicator)
// GIFs have image descriptors starting with 0x2C
// and graphic control extensions starting with 0x21 0xF9
let frameCount = 0;
let i = 6; // skip header
while (i < arr.length - 1) {
if (arr[i] === 0x21 && arr[i + 1] === 0xf9) {
// graphic control extension - indicates animation frame
frameCount++;
}
i++;
}
resolve(frameCount > 1);
};
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check
});
}
export function UploadAvatar({
name,
username,
@@ -24,6 +56,7 @@ export function UploadAvatar({
skipOrgCheck?: boolean;
className?: string;
}) {
const { user } = useAuthenticatedSession();
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -34,6 +67,22 @@ export function UploadAvatar({
const file = e.target.files?.[0];
if (!file) return;
// check for animated GIF for free users
if (user.plan !== "pro" && file.type === "image/gif") {
const isAnimated = await isAnimatedGIF(file);
if (isAnimated) {
setError("Animated avatars are only available on Pro. Upgrade to upload animated avatars.");
toast.error("Animated avatars are only available on Pro. Upgrade to upload animated avatars.", {
dismissible: false,
});
// reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
}
setUploading(true);
setError(null);
@@ -50,9 +99,25 @@ export function UploadAvatar({
setError(message);
setUploading(false);
toast.error(`Error uploading avatar: ${message}`, {
dismissible: false,
});
// check if the error is about animated avatars for free users
if (message.toLowerCase().includes("animated") && message.toLowerCase().includes("pro")) {
toast.error(
<div className="flex flex-col gap-2">
<span>Animated avatars are only available on Pro.</span>
<a href="/plans" className="text-personality hover:underline">
Upgrade to Pro
</a>
</div>,
{
dismissible: false,
duration: 5000,
},
);
} else {
toast.error(`Error uploading avatar: ${message}`, {
dismissible: false,
});
}
}
};