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

@@ -14,4 +14,5 @@ export const FREE_TIER_LIMITS = {
projectsPerOrganisation: 1, projectsPerOrganisation: 1,
issuesPerOrganisation: 100, issuesPerOrganisation: 100,
membersPerOrganisation: 5, membersPerOrganisation: 5,
sprintsPerProject: 5,
} as const; } as const;

View File

@@ -152,3 +152,13 @@ export async function getUserOrganisationCount(userId: number): Promise<number>
.where(eq(OrganisationMember.userId, userId)); .where(eq(OrganisationMember.userId, userId));
return result?.count ?? 0; return result?.count ?? 0;
} }
export async function getOrganisationOwner(organisationId: number) {
const [owner] = await db
.select({ userId: OrganisationMember.userId })
.from(OrganisationMember)
.where(
and(eq(OrganisationMember.organisationId, organisationId), eq(OrganisationMember.role, "owner")),
);
return owner;
}

View File

@@ -1,5 +1,5 @@
import { Issue, Sprint } from "@sprint/shared"; import { Issue, Sprint } from "@sprint/shared";
import { and, desc, eq, gte, lte, ne } from "drizzle-orm"; import { and, desc, eq, gte, lte, ne, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
export async function createSprint( export async function createSprint(
@@ -72,3 +72,11 @@ export async function deleteSprint(sprintId: number) {
await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId)); await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId));
await db.delete(Sprint).where(eq(Sprint.id, sprintId)); await db.delete(Sprint).where(eq(Sprint.id, sprintId));
} }
export async function getProjectSprintCount(projectId: number) {
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(Sprint)
.where(eq(Sprint.projectId, projectId));
return result[0]?.count ?? 0;
}

View File

@@ -5,6 +5,8 @@ import {
getOrganisationById, getOrganisationById,
getOrganisationMemberRole, getOrganisationMemberRole,
getOrganisationMemberTimedSessions, getOrganisationMemberTimedSessions,
getOrganisationOwner,
getUserById,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation"; import { errorResponse, parseQueryParams } from "../../validation";
@@ -37,22 +39,30 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest)
return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403); return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403);
} }
// check if organisation owner has pro subscription
const owner = await getOrganisationOwner(organisationId);
const ownerUser = owner ? await getUserById(owner.userId) : null;
const isPro = ownerUser?.plan === "pro";
const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate); const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate);
const enriched = sessions.map((session) => { const enriched = sessions.map((session) => {
const timestamps = session.timestamps.map((t) => new Date(t)); const timestamps = session.timestamps.map((t) => new Date(t));
const actualWorkTimeMs = calculateWorkTimeMs(timestamps);
const actualBreakTimeMs = calculateBreakTimeMs(timestamps);
return { return {
id: session.id, id: session.id,
userId: session.userId, userId: session.userId,
issueId: session.issueId, issueId: session.issueId,
issueNumber: session.issueNumber, issueNumber: session.issueNumber,
projectKey: session.projectKey, projectKey: session.projectKey,
timestamps: session.timestamps, timestamps: isPro ? session.timestamps : [],
endedAt: session.endedAt, endedAt: isPro ? session.endedAt : null,
createdAt: session.createdAt, createdAt: isPro ? session.createdAt : null,
workTimeMs: calculateWorkTimeMs(timestamps), workTimeMs: isPro ? actualWorkTimeMs : 0,
breakTimeMs: calculateBreakTimeMs(timestamps), breakTimeMs: isPro ? actualBreakTimeMs : 0,
isRunning: session.endedAt === null && isTimerRunning(timestamps), isRunning: isPro ? session.endedAt === null && isTimerRunning(timestamps) : false,
}; };
}); });

View File

@@ -1,6 +1,11 @@
import { OrgUpdateRequestSchema } from "@sprint/shared"; import { OrgUpdateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries"; import {
getOrganisationById,
getOrganisationMemberRole,
getSubscriptionByUserId,
updateOrganisation,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationUpdate(req: AuthedRequest) { export default async function organisationUpdate(req: AuthedRequest) {
@@ -22,6 +27,19 @@ export default async function organisationUpdate(req: AuthedRequest) {
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403); return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
} }
// block free users from updating features
if (features !== undefined) {
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
if (!isPro) {
return errorResponse(
"Feature toggling is only available on Pro. Upgrade to customize features.",
"FEATURE_TOGGLE_PRO_ONLY",
403,
);
}
}
if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) { if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) {
return errorResponse( return errorResponse(
"at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided", "at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided",

View File

@@ -2,8 +2,11 @@ import { SprintCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createSprint, createSprint,
FREE_TIER_LIMITS,
getOrganisationMemberRole, getOrganisationMemberRole,
getProjectByID, getProjectByID,
getProjectSprintCount,
getSubscriptionByUserId,
hasOverlappingSprints, hasOverlappingSprints,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
@@ -28,6 +31,20 @@ export default async function sprintCreate(req: AuthedRequest) {
return errorResponse("Only owners and admins can create sprints", "PERMISSION_DENIED", 403); return errorResponse("Only owners and admins can create sprints", "PERMISSION_DENIED", 403);
} }
// check free tier sprint limit
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
if (!isPro) {
const sprintCount = await getProjectSprintCount(projectId);
if (sprintCount >= FREE_TIER_LIMITS.sprintsPerProject) {
return errorResponse(
`Free tier limited to ${FREE_TIER_LIMITS.sprintsPerProject} sprints per project. Upgrade to Pro for unlimited sprints.`,
"SPRINT_LIMIT_REACHED",
403,
);
}
}
const start = new Date(startDate); const start = new Date(startDate);
const end = new Date(endDate); const end = new Date(endDate);

View File

@@ -1,7 +1,7 @@
import { UserUpdateRequestSchema } from "@sprint/shared"; import { UserUpdateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { hashPassword } from "../../auth/utils"; import { hashPassword } from "../../auth/utils";
import { getUserById } from "../../db/queries"; import { getSubscriptionByUserId, getUserById } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function update(req: AuthedRequest) { export default async function update(req: AuthedRequest) {
@@ -23,6 +23,19 @@ export default async function update(req: AuthedRequest) {
); );
} }
// block free users from changing icon preference
if (iconPreference !== undefined && iconPreference !== user.iconPreference) {
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
if (!isPro) {
return errorResponse(
"icon style customization is only available on Pro. Upgrade to customize your icon style.",
"ICON_STYLE_PRO_ONLY",
403,
);
}
}
let passwordHash: string | undefined; let passwordHash: string | undefined;
if (password !== undefined) { if (password !== undefined) {
passwordHash = await hashPassword(password); passwordHash = await hashPassword(password);

View File

@@ -1,13 +1,23 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { BunRequest } from "bun";
import sharp from "sharp"; import sharp from "sharp";
import type { AuthedRequest } from "../../auth/middleware";
import { getSubscriptionByUserId } from "../../db/queries";
import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3"; import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3";
const MAX_FILE_SIZE = 5 * 1024 * 1024; const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const TARGET_SIZE = 256; const TARGET_SIZE = 256;
export default async function uploadAvatar(req: BunRequest) { async function isAnimatedGIF(buffer: Buffer): Promise<boolean> {
try {
const metadata = await sharp(buffer).metadata();
return metadata.pages !== undefined && metadata.pages > 1;
} catch {
return false;
}
}
export default async function uploadAvatar(req: AuthedRequest) {
if (req.method !== "POST") { if (req.method !== "POST") {
return new Response("method not allowed", { status: 405 }); return new Response("method not allowed", { status: 405 });
} }
@@ -29,14 +39,31 @@ export default async function uploadAvatar(req: BunRequest) {
}); });
} }
const inputBuffer = Buffer.from(await file.arrayBuffer());
// check if user is pro
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
// block animated avatars for free users
if (!isPro && file.type === "image/gif") {
const animated = await isAnimatedGIF(inputBuffer);
if (animated) {
return new Response(
JSON.stringify({
error: "Animated avatars are only available on Pro. Upgrade to upload animated avatars.",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
}
const isGIF = file.type === "image/gif"; const isGIF = file.type === "image/gif";
const outputExtension = isGIF ? "gif" : "png"; const outputExtension = isGIF ? "gif" : "png";
const outputMimeType = isGIF ? "image/gif" : "image/png"; const outputMimeType = isGIF ? "image/gif" : "image/png";
let resizedBuffer: Buffer; let resizedBuffer: Buffer;
try { try {
const inputBuffer = Buffer.from(await file.arrayBuffer());
if (isGIF) { if (isGIF) {
resizedBuffer = await sharp(inputBuffer, { animated: true }) resizedBuffer = await sharp(inputBuffer, { animated: true })
.resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" }) .resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" })

View File

@@ -16,6 +16,9 @@ import { useUpdateUser } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; 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 }) { function Account({ trigger }: { trigger?: ReactNode }) {
const { user: currentUser, setUser } = useAuthenticatedSession(); const { user: currentUser, setUser } = useAuthenticatedSession();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
@@ -35,7 +38,12 @@ function Account({ trigger }: { trigger?: ReactNode }) {
setName(currentUser.name); setName(currentUser.name);
setUsername(currentUser.username); setUsername(currentUser.username);
setAvatarUrl(currentUser.avatarURL || null); 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(""); setPassword("");
setError(""); setError("");
@@ -51,11 +59,13 @@ function Account({ trigger }: { trigger?: ReactNode }) {
} }
try { try {
// only send iconPreference for pro users
const effectiveIconPreference = currentUser.plan === "pro" ? iconPreference : undefined;
const data = await updateUser.mutateAsync({ const data = await updateUser.mutateAsync({
name: name.trim(), name: name.trim(),
password: password.trim() || undefined, password: password.trim() || undefined,
avatarURL, avatarURL,
iconPreference, iconPreference: effectiveIconPreference,
}); });
setError(""); setError("");
setUser(data); setUser(data);
@@ -131,9 +141,22 @@ function Account({ trigger }: { trigger?: ReactNode }) {
<ThemeToggle withText /> <ThemeToggle withText />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Label className="text-sm">Icon Style</Label> <Label className={cn("text-sm", currentUser.plan !== "pro" && "text-muted-foreground")}>
<Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}> Icon Style
<SelectTrigger className="w-full"> </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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper" side="bottom" align="start"> <SelectContent position="popper" side="bottom" align="start">
@@ -157,6 +180,14 @@ function Account({ trigger }: { trigger?: ReactNode }) {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </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>
</div> </div>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared"; import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
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 { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
@@ -21,6 +22,7 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SPRINT_NAME_MAX_LENGTH = 64; const SPRINT_NAME_MAX_LENGTH = 64;
const FREE_TIER_SPRINT_LIMIT = 5;
const getStartOfDay = (date: Date) => { const getStartOfDay = (date: Date) => {
const next = new Date(date); const next = new Date(date);
@@ -301,6 +303,16 @@ export function SprintForm({
)} )}
</div> </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"> <div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
@@ -312,7 +324,13 @@ export function SprintForm({
disabled={ disabled={
submitting || submitting ||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && submitAttempted) || ((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"} {submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}

View File

@@ -1,6 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -8,6 +9,37 @@ import { useUploadAvatar } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; 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({ export function UploadAvatar({
name, name,
username, username,
@@ -24,6 +56,7 @@ export function UploadAvatar({
skipOrgCheck?: boolean; skipOrgCheck?: boolean;
className?: string; className?: string;
}) { }) {
const { user } = useAuthenticatedSession();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -34,6 +67,22 @@ export function UploadAvatar({
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; 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); setUploading(true);
setError(null); setError(null);
@@ -50,9 +99,25 @@ export function UploadAvatar({
setError(message); setError(message);
setUploading(false); setUploading(false);
toast.error(`Error uploading avatar: ${message}`, { // check if the error is about animated avatars for free users
dismissible: false, 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,
});
}
} }
}; };

View File

@@ -177,6 +177,7 @@ export const apiContract = c.router({
responses: { responses: {
200: z.object({ avatarURL: z.string() }), 200: z.object({ avatarURL: z.string() }),
400: ApiErrorSchema, 400: ApiErrorSchema,
403: ApiErrorSchema,
}, },
headers: csrfHeaderSchema, headers: csrfHeaderSchema,
}, },