mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
more Free/Pro plan limitations
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user