removed all free tier restrictions

This commit is contained in:
2026-01-29 15:20:42 +00:00
parent 232e00a2b0
commit 2aa13e34bf
21 changed files with 413 additions and 639 deletions

View File

@@ -17,3 +17,5 @@ STRIPE_SECRET_KEY=your_stripe_secret_key
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
EMAIL_FROM=Sprint <support@sprintpm.org> EMAIL_FROM=Sprint <support@sprintpm.org>
SEED_PASSWORD=replace-in-production

View File

@@ -2,11 +2,11 @@ import { IssueCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createIssue, createIssue,
FREE_TIER_LIMITS, // FREE_TIER_LIMITS,
getOrganisationIssueCount, // getOrganisationIssueCount,
getOrganisationMemberRole, getOrganisationMemberRole,
getProjectByID, getProjectByID,
getUserById, // getUserById,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
@@ -34,17 +34,17 @@ export default async function issueCreate(req: AuthedRequest) {
} }
// check free tier limit // check free tier limit
const user = await getUserById(req.userId); // const user = await getUserById(req.userId);
if (user && user.plan !== "pro") { // if (user && user.plan !== "pro") {
const issueCount = await getOrganisationIssueCount(project.organisationId); // const issueCount = await getOrganisationIssueCount(project.organisationId);
if (issueCount >= FREE_TIER_LIMITS.issuesPerOrganisation) { // if (issueCount >= FREE_TIER_LIMITS.issuesPerOrganisation) {
return errorResponse( // return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.issuesPerOrganisation} issues per organisation. upgrade to pro for unlimited issues.`, // `free tier is limited to ${FREE_TIER_LIMITS.issuesPerOrganisation} issues per organisation. upgrade to pro for unlimited issues.`,
"FREE_TIER_ISSUE_LIMIT", // "FREE_TIER_ISSUE_LIMIT",
403, // 403,
); // );
} // }
} // }
const issue = await createIssue( const issue = await createIssue(
project.id, project.id,

View File

@@ -2,10 +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, // FREE_TIER_LIMITS,
getOrganisationById, getOrganisationById,
getOrganisationMemberRole, getOrganisationMemberRole,
getOrganisationMembers, // getOrganisationMembers,
getUserById, getUserById,
} from "../../db/queries"; } from "../../db/queries";
import { updateSeatCount } from "../../lib/seats"; import { updateSeatCount } from "../../lib/seats";
@@ -42,17 +42,17 @@ export default async function organisationAddMember(req: AuthedRequest) {
} }
// check free tier member limit // check free tier member limit
const requester = await getUserById(req.userId); // const requester = await getUserById(req.userId);
if (requester && requester.plan !== "pro") { // if (requester && requester.plan !== "pro") {
const members = await getOrganisationMembers(organisationId); // const members = await getOrganisationMembers(organisationId);
if (members.length >= FREE_TIER_LIMITS.membersPerOrganisation) { // if (members.length >= FREE_TIER_LIMITS.membersPerOrganisation) {
return errorResponse( // return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.membersPerOrganisation} members per organisation. upgrade to pro for unlimited members.`, // `free tier is limited to ${FREE_TIER_LIMITS.membersPerOrganisation} members per organisation. upgrade to pro for unlimited members.`,
"FREE_TIER_MEMBER_LIMIT", // "FREE_TIER_MEMBER_LIMIT",
403, // 403,
); // );
} // }
} // }
const member = await createOrganisationMember(organisationId, userId, role); const member = await createOrganisationMember(organisationId, userId, role);

View File

@@ -2,10 +2,10 @@ import { OrgCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createOrganisationWithOwner, createOrganisationWithOwner,
FREE_TIER_LIMITS, // FREE_TIER_LIMITS,
getOrganisationBySlug, getOrganisationBySlug,
getUserById, // getUserById,
getUserOrganisationCount, // getUserOrganisationCount,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
@@ -21,17 +21,17 @@ export default async function organisationCreate(req: AuthedRequest) {
} }
// check free tier limit // check free tier limit
const user = await getUserById(req.userId); // const user = await getUserById(req.userId);
if (user && user.plan !== "pro") { // if (user && user.plan !== "pro") {
const orgCount = await getUserOrganisationCount(req.userId); // const orgCount = await getUserOrganisationCount(req.userId);
if (orgCount >= FREE_TIER_LIMITS.organisationsPerUser) { // if (orgCount >= FREE_TIER_LIMITS.organisationsPerUser) {
return errorResponse( // return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.organisationsPerUser} organisation. upgrade to pro for unlimited organisations.`, // `free tier is limited to ${FREE_TIER_LIMITS.organisationsPerUser} organisation. upgrade to pro for unlimited organisations.`,
"FREE_TIER_ORG_LIMIT", // "FREE_TIER_ORG_LIMIT",
403, // 403,
); // );
} // }
} // }
const organisation = await createOrganisationWithOwner(name, slug, req.userId, description); const organisation = await createOrganisationWithOwner(name, slug, req.userId, description);

View File

@@ -5,8 +5,8 @@ import {
getOrganisationById, getOrganisationById,
getOrganisationMemberRole, getOrganisationMemberRole,
getOrganisationMemberTimedSessions, getOrganisationMemberTimedSessions,
getOrganisationOwner, // getOrganisationOwner,
getUserById, // getUserById,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation"; import { errorResponse, parseQueryParams } from "../../validation";
@@ -40,9 +40,9 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest)
} }
// check if organisation owner has pro subscription // check if organisation owner has pro subscription
const owner = await getOrganisationOwner(organisationId); // const owner = await getOrganisationOwner(organisationId);
const ownerUser = owner ? await getUserById(owner.userId) : null; // const ownerUser = owner ? await getUserById(owner.userId) : null;
const isPro = ownerUser?.plan === "pro"; // const isPro = ownerUser?.plan === "pro";
const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate); const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate);
@@ -57,12 +57,12 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest)
issueId: session.issueId, issueId: session.issueId,
issueNumber: session.issueNumber, issueNumber: session.issueNumber,
projectKey: session.projectKey, projectKey: session.projectKey,
timestamps: isPro ? session.timestamps : [], timestamps: session.timestamps,
endedAt: isPro ? session.endedAt : null, endedAt: session.endedAt,
createdAt: isPro ? session.createdAt : null, createdAt: session.createdAt,
workTimeMs: isPro ? actualWorkTimeMs : 0, workTimeMs: actualWorkTimeMs,
breakTimeMs: isPro ? actualBreakTimeMs : 0, breakTimeMs: actualBreakTimeMs,
isRunning: isPro ? session.endedAt === null && isTimerRunning(timestamps) : false, isRunning: session.endedAt === null && isTimerRunning(timestamps),
}; };
}); });

View File

@@ -2,9 +2,9 @@ import { ProjectCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createProject, createProject,
FREE_TIER_LIMITS, // FREE_TIER_LIMITS,
getOrganisationMemberRole, getOrganisationMemberRole,
getOrganisationProjectCount, // getOrganisationProjectCount,
getProjectByKey, getProjectByKey,
getUserById, getUserById,
} from "../../db/queries"; } from "../../db/queries";
@@ -30,18 +30,19 @@ export default async function projectCreate(req: AuthedRequest) {
} }
// check free tier limit // check free tier limit
const creator = await getUserById(req.userId); // const creator = await getUserById(req.userId);
if (creator && creator.plan !== "pro") { // if (creator && creator.plan !== "pro") {
const projectCount = await getOrganisationProjectCount(organisationId); // const projectCount = await getOrganisationProjectCount(organisationId);
if (projectCount >= FREE_TIER_LIMITS.projectsPerOrganisation) { // if (projectCount >= FREE_TIER_LIMITS.projectsPerOrganisation) {
return errorResponse( // return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.projectsPerOrganisation} project per organisation. upgrade to pro for unlimited projects.`, // `free tier is limited to ${FREE_TIER_LIMITS.projectsPerOrganisation} project per organisation. upgrade to pro for unlimited projects.`,
"FREE_TIER_PROJECT_LIMIT", // "FREE_TIER_PROJECT_LIMIT",
403, // 403,
); // );
} // }
} // }
const creator = await getUserById(req.userId);
if (!creator) { if (!creator) {
return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404); return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);
} }

View File

@@ -2,11 +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, // FREE_TIER_LIMITS,
getOrganisationMemberRole, getOrganisationMemberRole,
getProjectByID, getProjectByID,
getProjectSprintCount, // getProjectSprintCount,
getSubscriptionByUserId, // getSubscriptionByUserId,
hasOverlappingSprints, hasOverlappingSprints,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
@@ -32,18 +32,18 @@ export default async function sprintCreate(req: AuthedRequest) {
} }
// check free tier sprint limit // check free tier sprint limit
const subscription = await getSubscriptionByUserId(req.userId); // const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active"; // const isPro = subscription?.status === "active";
if (!isPro) { // if (!isPro) {
const sprintCount = await getProjectSprintCount(projectId); // const sprintCount = await getProjectSprintCount(projectId);
if (sprintCount >= FREE_TIER_LIMITS.sprintsPerProject) { // if (sprintCount >= FREE_TIER_LIMITS.sprintsPerProject) {
return errorResponse( // return errorResponse(
`Free tier limited to ${FREE_TIER_LIMITS.sprintsPerProject} sprints per project. Upgrade to Pro for unlimited sprints.`, // `Free tier limited to ${FREE_TIER_LIMITS.sprintsPerProject} sprints per project. Upgrade to Pro for unlimited sprints.`,
"SPRINT_LIMIT_REACHED", // "SPRINT_LIMIT_REACHED",
403, // 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 { getSubscriptionByUserId, getUserById } from "../../db/queries"; import { 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) {
@@ -24,17 +24,17 @@ export default async function update(req: AuthedRequest) {
} }
// block free users from changing icon preference // block free users from changing icon preference
if (iconPreference !== undefined && iconPreference !== user.iconPreference) { // if (iconPreference !== undefined && iconPreference !== user.iconPreference) {
const subscription = await getSubscriptionByUserId(req.userId); // const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active"; // const isPro = subscription?.status === "active";
if (!isPro) { // if (!isPro) {
return errorResponse( // return errorResponse(
"icon style customization is only available on Pro. Upgrade to customize your icon style.", // "icon style customization is only available on Pro. Upgrade to customize your icon style.",
"ICON_STYLE_PRO_ONLY", // "ICON_STYLE_PRO_ONLY",
403, // 403,
); // );
} // }
} // }
let passwordHash: string | undefined; let passwordHash: string | undefined;
if (password !== undefined) { if (password !== undefined) {

View File

@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import sharp from "sharp"; import sharp from "sharp";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getSubscriptionByUserId } from "../../db/queries"; // 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;
@@ -42,21 +42,21 @@ export default async function uploadAvatar(req: AuthedRequest) {
const inputBuffer = Buffer.from(await file.arrayBuffer()); const inputBuffer = Buffer.from(await file.arrayBuffer());
// check if user is pro // check if user is pro
const subscription = await getSubscriptionByUserId(req.userId); // const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active"; // const isPro = subscription?.status === "active";
// block animated avatars for free users // block animated avatars for free users
if (!isPro && file.type === "image/gif") { // if (!isPro && file.type === "image/gif") {
const animated = await isAnimatedGIF(inputBuffer); // const animated = await isAnimatedGIF(inputBuffer);
if (animated) { // if (animated) {
return new Response( // return new Response(
JSON.stringify({ // JSON.stringify({
error: "Animated avatars are only available on Pro. Upgrade to upload animated avatars.", // error: "Animated avatars are only available on Pro. Upgrade to upload animated avatars.",
}), // }),
{ status: 403, headers: { "Content-Type": "application/json" } }, // { 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";

View File

@@ -1,7 +1,7 @@
import type { IconStyle } from "@sprint/shared"; import type { IconStyle } from "@sprint/shared";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; // import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import ThemeToggle from "@/components/theme-toggle"; import ThemeToggle from "@/components/theme-toggle";
@@ -38,11 +38,7 @@ 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);
// free users are locked to pixel icon style const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE;
const effectiveIconStyle =
currentUser.plan === "pro"
? ((currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE)
: DEFAULT_ICON_STYLE;
setIconPreference(effectiveIconStyle); setIconPreference(effectiveIconStyle);
setPassword(""); setPassword("");
@@ -59,13 +55,11 @@ 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: effectiveIconPreference, iconPreference,
}); });
setError(""); setError("");
setUser(data); setUser(data);
@@ -141,22 +135,9 @@ 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={cn("text-sm", currentUser.plan !== "pro" && "text-muted-foreground")}> <Label className="text-sm">Icon Style</Label>
Icon Style <Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}>
</Label> <SelectTrigger className={cn("w-full")}>
<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">
@@ -180,21 +161,21 @@ function Account({ trigger }: { trigger?: ReactNode }) {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{currentUser.plan !== "pro" && ( {/* {currentUser.plan !== "pro" && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
<Link to="/plans" className="text-personality hover:underline"> <Link to="/plans" className="text-personality hover:underline">
Upgrade to Pro Upgrade to Pro
</Link>{" "} </Link>{" "}
to customize icon style to customize icon style
</span> </span>
)} )} */}
</div> </div>
</div> </div>
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>} {error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
{/* Show subscription management link */} {/* subscription management link commented out for beta */}
<div className="pt-2"> {/* <div className="pt-2">
{currentUser.plan === "pro" ? ( {currentUser.plan === "pro" ? (
<Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700"> <Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700">
<Link to="/plans">Manage subscription</Link> <Link to="/plans">Manage subscription</Link>
@@ -204,7 +185,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
<Link to="/plans">Upgrade to Pro</Link> <Link to="/plans">Upgrade to Pro</Link>
</Button> </Button>
)} )}
</div> </div> */}
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">
<Button variant={"outline"} type={"submit"} className="px-12"> <Button variant={"outline"} type={"submit"} className="px-12">

View File

@@ -2,7 +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 { 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";
@@ -24,7 +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, // useIssues,
useOrganisationMembers, useOrganisationMembers,
useSelectedOrganisation, useSelectedOrganisation,
useSelectedProject, useSelectedProject,
@@ -33,7 +33,7 @@ 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; // 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();
@@ -41,12 +41,12 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
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 { data: issues = [] } = useIssues(selectedProject?.Project.id);
const createIssue = useCreateIssue(); const createIssue = useCreateIssue();
const isPro = user.plan === "pro"; // const isPro = user.plan === "pro";
const issueCount = issues.length; // const issueCount = issues.length;
const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT; // 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 ?? {};
@@ -149,14 +149,8 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
{trigger || ( {trigger || (
<Button <Button
variant="outline" variant="outline"
disabled={!selectedProject || isAtIssueLimit} disabled={!selectedProject}
title={ title={!selectedProject ? "Select a project first" : undefined}
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>
@@ -168,7 +162,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
<DialogTitle>Create Issue</DialogTitle> <DialogTitle>Create Issue</DialogTitle>
</DialogHeader> </DialogHeader>
{!isPro && selectedProject && ( {/* {!isPro && selectedProject && (
<div className="mb-2"> <div className="mb-2">
<FreeTierLimit <FreeTierLimit
current={issueCount} current={issueCount}
@@ -178,7 +172,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
showUpgrade={isAtIssueLimit} showUpgrade={isAtIssueLimit}
/> />
</div> </div>
)} )} */}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid"> <div className="grid">
@@ -301,16 +295,10 @@ 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>

View File

@@ -1,9 +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 { 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 { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -19,7 +19,7 @@ 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; // const free_tier_org_limit = 1;
export function OrganisationSelect({ export function OrganisationSelect({
placeholder = "Select Organisation", placeholder = "Select Organisation",
@@ -44,11 +44,10 @@ 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 { user } = useAuthenticatedSession();
// const isPro = user.plan === "pro";
const isPro = user.plan === "pro"; // const orgCount = organisationsData.length;
const orgCount = organisationsData.length; // const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT;
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)),
@@ -116,7 +115,7 @@ export function OrganisationSelect({
{organisations.length > 0 && <SelectSeparator />} {organisations.length > 0 && <SelectSeparator />}
</SelectGroup> </SelectGroup>
{!isPro && ( {/* {!isPro && (
<div className="px-2 py-2"> <div className="px-2 py-2">
<FreeTierLimit <FreeTierLimit
current={orgCount} current={orgCount}
@@ -126,21 +125,11 @@ export function OrganisationSelect({
showUpgrade={isAtOrgLimit} showUpgrade={isAtOrgLimit}
/> />
</div> </div>
)} )} */}
<OrganisationForm <OrganisationForm
trigger={ trigger={
<Button <Button variant="ghost" className={"w-full"} size={"sm"} disabled={false}>
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>
} }

View File

@@ -8,10 +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 { 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 { 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";
@@ -44,7 +44,7 @@ import {
useDeleteOrganisation, useDeleteOrganisation,
useDeleteProject, useDeleteProject,
useDeleteSprint, useDeleteSprint,
useIssues, // useIssues,
useOrganisationMembers, useOrganisationMembers,
useOrganisationMemberTimeTracking, useOrganisationMemberTimeTracking,
useOrganisations, useOrganisations,
@@ -58,15 +58,15 @@ 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, cn, 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 = { // const FREE_TIER_LIMITS = {
organisationsPerUser: 1, // organisationsPerUser: 1,
projectsPerOrganisation: 1, // projectsPerOrganisation: 1,
issuesPerOrganisation: 100, // issuesPerOrganisation: 100,
membersPerOrganisation: 5, // membersPerOrganisation: 5,
} as const; // } as const;
function Organisations({ trigger }: { trigger?: ReactNode }) { function Organisations({ trigger }: { trigger?: ReactNode }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
@@ -76,7 +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 { data: issues = [] } = useIssues(selectedProjectId);
const updateOrganisation = useUpdateOrganisation(); const updateOrganisation = useUpdateOrganisation();
const updateMemberRole = useUpdateOrganisationMemberRole(); const updateMemberRole = useUpdateOrganisationMemberRole();
const removeMember = useRemoveOrganisationMember(); const removeMember = useRemoveOrganisationMember();
@@ -86,11 +86,11 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const replaceIssueStatus = useReplaceIssueStatus(); const replaceIssueStatus = useReplaceIssueStatus();
const replaceIssueType = useReplaceIssueType(); const replaceIssueType = useReplaceIssueType();
const isPro = user.plan === "pro"; // const isPro = user.plan === "pro";
const orgCount = organisationsData.length; // const orgCount = organisationsData.length;
const projectCount = projectsData.length; // const projectCount = projectsData.length;
const issueCount = issues.length; // const issueCount = issues.length;
const memberCount = membersData.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)),
@@ -842,7 +842,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</div> </div>
{/* Free tier limits section */} {/* Free tier limits section */}
{!isPro && ( {/* {!isPro && (
<div className="mt-4 pt-4 border-t border-border"> <div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-600">Plan Limits</h3> <h3 className="text-sm font-600">Plan Limits</h3>
@@ -881,7 +881,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
/> />
</div> </div>
</div> </div>
)} )} */}
{isAdmin && ( {isAdmin && (
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
@@ -943,8 +943,6 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</h2> </h2>
{isAdmin && ( {isAdmin && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isPro && (
<>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -975,8 +973,6 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -994,7 +990,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 && isPro && ( {isAdmin && (
<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>
@@ -1033,7 +1029,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</div> </div>
{isAdmin && ( {isAdmin && (
<> <>
{!isPro && ( {/* {!isPro && (
<div className="px-1"> <div className="px-1">
<FreeTierLimit <FreeTierLimit
current={memberCount} current={memberCount}
@@ -1043,7 +1039,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation} showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
/> />
</div> </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)}
@@ -1058,15 +1054,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
void invalidateMembers(); void invalidateMembers();
}} }}
trigger={ trigger={
<Button <Button variant="outline">
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>
} }
@@ -1522,14 +1510,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 && ( {/* {!isPro && (
<div className="mb-3 p-2 bg-muted/50 rounded text-sm text-muted-foreground"> <div className="mb-3 p-2 bg-muted/50 rounded text-sm text-muted-foreground">
Feature toggling is only available on Pro.{" "} Feature toggling is only available on Pro.{" "}
<Link to="/plans" className="text-personality hover:underline"> <Link to="/plans" className="text-personality hover:underline">
Upgrade to customize features. Upgrade to customize features.
</Link> </Link>
</div> </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">
@@ -1551,12 +1539,9 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
); );
await invalidateOrganisations(); await invalidateOrganisations();
}} }}
disabled={!isPro}
color={"#ff0000"} color={"#ff0000"}
/> />
<span className={cn("text-sm", !isPro && "text-muted-foreground")}> <span className="text-sm">{unCamelCase(feature)}</span>
{unCamelCase(feature)}
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FreeTierLimit } from "@/components/free-tier-limit"; // 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 { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -16,7 +16,7 @@ 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; // const free_tier_project_limit = 1;
export function ProjectSelect({ export function ProjectSelect({
placeholder = "Select Project", placeholder = "Select Project",
@@ -33,11 +33,10 @@ 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 { user } = useAuthenticatedSession();
// const isPro = user.plan === "pro";
const isPro = user.plan === "pro"; // const projectCount = projectsData.length;
const projectCount = projectsData.length; // const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT;
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)),
@@ -91,7 +90,7 @@ export function ProjectSelect({
{projects.length > 0 && <SelectSeparator />} {projects.length > 0 && <SelectSeparator />}
</SelectGroup> </SelectGroup>
{!isPro && selectedOrganisationId && ( {/* {!isPro && selectedOrganisationId && (
<div className="px-2 py-2"> <div className="px-2 py-2">
<FreeTierLimit <FreeTierLimit
current={projectCount} current={projectCount}
@@ -101,7 +100,7 @@ export function ProjectSelect({
showUpgrade={isAtProjectLimit} showUpgrade={isAtProjectLimit}
/> />
</div> </div>
)} )} */}
<ProjectForm <ProjectForm
organisationId={selectedOrganisationId ?? undefined} organisationId={selectedOrganisationId ?? undefined}
@@ -110,14 +109,8 @@ export function ProjectSelect({
size={"sm"} size={"sm"}
variant="ghost" variant="ghost"
className={"w-full"} className={"w-full"}
disabled={!selectedOrganisationId || isAtProjectLimit} disabled={!selectedOrganisationId}
title={ title={!selectedOrganisationId ? "Select an organisation first" : undefined}
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>

View File

@@ -1,7 +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 { 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";
@@ -22,7 +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 free_tier_sprint_limit = 5;
const getStartOfDay = (date: Date) => { const getStartOfDay = (date: Date) => {
const next = new Date(date); const next = new Date(date);
@@ -303,7 +303,7 @@ export function SprintForm({
)} )}
</div> </div>
{!isEdit && ( {/* {!isEdit && (
<FreeTierLimit <FreeTierLimit
current={sprints.length} current={sprints.length}
limit={FREE_TIER_SPRINT_LIMIT} limit={FREE_TIER_SPRINT_LIMIT}
@@ -311,7 +311,7 @@ export function SprintForm({
isPro={user.plan === "pro"} isPro={user.plan === "pro"}
showUpgrade={sprints.length >= FREE_TIER_SPRINT_LIMIT} 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>
@@ -324,13 +324,7 @@ 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,5 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import Account from "@/components/account"; import Account from "@/components/account";
import { IssueForm } from "@/components/issue-form"; import { IssueForm } from "@/components/issue-form";
import LogOutButton from "@/components/log-out-button"; import LogOutButton from "@/components/log-out-button";
@@ -11,7 +11,7 @@ import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { SprintForm } from "@/components/sprint-form"; import { SprintForm } from "@/components/sprint-form";
import { Button } from "@/components/ui/button"; // import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -123,11 +123,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
)} )}
</div> </div>
<div className={`flex gap-${BREATHING_ROOM} items-center`}> <div className={`flex gap-${BREATHING_ROOM} items-center`}>
{user.plan !== "pro" && ( {/* {user.plan !== "pro" && (
<Button asChild className="bg-personality hover:bg-personality/90 text-background font-600"> <Button asChild className="bg-personality hover:bg-personality/90 text-background font-600">
<Link to="/plans">Upgrade</Link> <Link to="/plans">Upgrade</Link>
</Button> </Button>
)} )} */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="text-sm"> <DropdownMenuTrigger className="text-sm">
<SmallUserDisplay user={user} /> <SmallUserDisplay user={user} />

View File

@@ -1,7 +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 { useSession } from "@/components/session-provider"; // import { useSession } 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";
@@ -9,36 +9,36 @@ 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> { // function isAnimatedGIF(file: File): Promise<boolean> {
return new Promise((resolve) => { // return new Promise((resolve) => {
const reader = new FileReader(); // const reader = new FileReader();
reader.onload = () => { // reader.onload = () => {
const buffer = reader.result as ArrayBuffer; // const buffer = reader.result as ArrayBuffer;
const arr = new Uint8Array(buffer); // const arr = new Uint8Array(buffer);
// check for GIF89a or GIF87a header // // check for GIF89a or GIF87a header
const header = String.fromCharCode(...arr.slice(0, 6)); // const header = String.fromCharCode(...arr.slice(0, 6));
if (header !== "GIF89a" && header !== "GIF87a") { // if (header !== "GIF89a" && header !== "GIF87a") {
resolve(false); // resolve(false);
return; // return;
} // }
// look for multiple images (animation indicator) // // look for multiple images (animation indicator)
// GIFs have image descriptors starting with 0x2C // // GIFs have image descriptors starting with 0x2C
// and graphic control extensions starting with 0x21 0xF9 // // and graphic control extensions starting with 0x21 0xF9
let frameCount = 0; // let frameCount = 0;
let i = 6; // skip header // let i = 6; // skip header
while (i < arr.length - 1) { // while (i < arr.length - 1) {
if (arr[i] === 0x21 && arr[i + 1] === 0xf9) { // if (arr[i] === 0x21 && arr[i + 1] === 0xf9) {
// graphic control extension - indicates animation frame // // graphic control extension - indicates animation frame
frameCount++; // frameCount++;
} // }
i++; // i++;
} // }
resolve(frameCount > 1); // resolve(frameCount > 1);
}; // };
reader.onerror = () => resolve(false); // reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check // reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check
}); // });
} // }
export function UploadAvatar({ export function UploadAvatar({
name, name,
@@ -56,7 +56,7 @@ export function UploadAvatar({
skipOrgCheck?: boolean; skipOrgCheck?: boolean;
className?: string; className?: string;
}) { }) {
const { user } = useSession(); // const { user } = useSession();
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);
@@ -68,20 +68,20 @@ export function UploadAvatar({
if (!file) return; if (!file) return;
// check for animated GIF for free users // check for animated GIF for free users
if (user?.plan !== "pro" && file.type === "image/gif") { // if (user?.plan !== "pro" && file.type === "image/gif") {
const isAnimated = await isAnimatedGIF(file); // const isAnimated = await isAnimatedGIF(file);
if (isAnimated) { // if (isAnimated) {
setError("Animated avatars are only available on Pro. Upgrade to upload animated avatars."); // 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.", { // toast.error("Animated avatars are only available on Pro. Upgrade to upload animated avatars.", {
dismissible: false, // dismissible: false,
}); // });
// reset file input // // reset file input
if (fileInputRef.current) { // if (fileInputRef.current) {
fileInputRef.current.value = ""; // fileInputRef.current.value = "";
} // }
return; // return;
} // }
} // }
setUploading(true); setUploading(true);
setError(null); setError(null);
@@ -99,26 +99,10 @@ export function UploadAvatar({
setError(message); setError(message);
setUploading(false); setUploading(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}`, { toast.error(`Error uploading avatar: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}
}; };
return ( return (

View File

@@ -13,7 +13,7 @@ import Font from "@/pages/Font";
import Issues from "@/pages/Issues"; import Issues from "@/pages/Issues";
import Landing from "@/pages/Landing"; import Landing from "@/pages/Landing";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
import Plans from "@/pages/Plans"; // import plans from "@/pages/Plans";
import Test from "@/pages/Test"; import Test from "@/pages/Test";
import Timeline from "@/pages/Timeline"; import Timeline from "@/pages/Timeline";
@@ -31,14 +31,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<Route path="/the-boring-stuff" element={<BoringStuff />} /> <Route path="/the-boring-stuff" element={<BoringStuff />} />
{/* authed routes */} {/* authed routes */}
<Route {/* <Route
path="/plans" path="/plans"
element={ element={
<RequireAuth> <RequireAuth>
<Plans /> <Plans />
</RequireAuth> </RequireAuth>
} }
/> /> */}
<Route <Route
path="/issues" path="/issues"
element={ element={

View File

@@ -65,10 +65,10 @@ export default function BoringStuff() {
<strong className="text-foreground">Your account:</strong> You're responsible for keeping your <strong className="text-foreground">Your account:</strong> You're responsible for keeping your
login details secure. Don't share your account. login details secure. Don't share your account.
</p> </p>
<p> {/* <p>
<strong className="text-foreground">Payments:</strong> Pro plans are billed monthly or <strong className="text-foreground">Payments:</strong> Pro plans are billed monthly or
annually. Cancel anytime from your account settings. No refunds for partial months. annually. Cancel anytime from your account settings. No refunds for partial months.
</p> </p> */}
<p> <p>
<strong className="text-foreground">Service availability:</strong> We aim for 99.9% uptime but <strong className="text-foreground">Service availability:</strong> We aim for 99.9% uptime but
can't guarantee it. We may occasionally need downtime for maintenance. can't guarantee it. We may occasionally need downtime for maintenance.

View File

@@ -6,26 +6,26 @@ import ThemeToggle from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
const faqs = [ // const faqs = [
{ // {
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.",
}, // },
{ // {
question: "What if I need more users?", // question: "What if I need more users?",
answer: // answer:
"Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your billing automatically.", // "Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your billing automatically.",
}, // },
{ // {
question: "Can I cancel anytime?", // question: "Can I cancel anytime?",
answer: // answer:
"Absolutely. Cancel anytime with no questions asked. You'll keep access until the end of your billing period.", // "Absolutely. Cancel anytime with no questions asked. You'll keep access until the end of your billing period.",
}, // },
{ // {
question: "Do you offer refunds?", // question: "Do you offer refunds?",
answer: "Yes, we offer a 30-day money-back guarantee. If Sprint isn't right for you, just let us know.", // answer: "Yes, we offer a 30-day money-back guarantee. If Sprint isn't right for you, just let us know.",
}, // },
]; // ];
export default function Landing() { export default function Landing() {
const { user, isLoading } = useSession(); const { user, isLoading } = useSession();
@@ -52,12 +52,12 @@ export default function Landing() {
> >
Features Features
</a> </a>
<a {/* <a
href="#pricing" href="#pricing"
className="hidden md:block text-sm font-500 hover:text-personality transition-colors" className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
> >
Pricing Pricing
</a> </a> */}
<a <a
href="#faq" href="#faq"
className="hidden md:block text-sm font-500 hover:text-personality transition-colors" className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
@@ -107,14 +107,14 @@ export default function Landing() {
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}> <Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
Get started Get started
</Button> </Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6"> {/* <Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">
<a href="#pricing">See pricing</a> <a href="#pricing">See pricing</a>
</Button> </Button> */}
</> </>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground">Free forever · Upgrade when you need more</p> {/* <p className="text-sm text-muted-foreground">Free forever · Upgrade when you need more</p> */}
</div> </div>
{/* problem section */} {/* problem section */}
@@ -217,7 +217,7 @@ export default function Landing() {
</div> </div>
{/* pricing section */} {/* pricing section */}
<div id="pricing" className="max-w-5xl mx-auto space-y-12 scroll-mt-4 border-t pt-24"> {/* <div id="pricing" className="max-w-5xl mx-auto space-y-12 scroll-mt-4 border-t pt-24">
<div className="text-center space-y-6"> <div className="text-center space-y-6">
<h2 className="text-5xl font-basteleur font-700">Simple, transparent pricing</h2> <h2 className="text-5xl font-basteleur font-700">Simple, transparent pricing</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
@@ -227,10 +227,10 @@ export default function Landing() {
<Link to="/plans">View plans</Link> <Link to="/plans">View plans</Link>
</Button> </Button>
</div> </div>
</div> </div> */}
{/* faq section */} {/* faq section */}
<div id="faq" className="w-full max-w-5xl flex justify-center scroll-mt-4 border-t pt-24"> {/* <div id="faq" className="w-full max-w-5xl flex justify-center scroll-mt-4 border-t pt-24">
<div className="w-full max-w-4xl flex flex-col items-center space-y-12"> <div className="w-full max-w-4xl flex flex-col items-center space-y-12">
<h2 className="text-5xl font-basteleur font-700 text-center">Frequently Asked Questions</h2> <h2 className="text-5xl font-basteleur font-700 text-center">Frequently Asked Questions</h2>
<div className="grid gap-8 max-w-3xl"> <div className="grid gap-8 max-w-3xl">
@@ -242,7 +242,7 @@ export default function Landing() {
))} ))}
</div> </div>
</div> </div>
</div> </div> */}
{/* TODO:> commented out until we have actual testimonies */} {/* TODO:> commented out until we have actual testimonies */}
{/* social proof placeholder */} {/* social proof placeholder */}
@@ -287,9 +287,9 @@ export default function Landing() {
</Button> </Button>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> {/* <p className="text-sm text-muted-foreground">
Free forever · Upgrade when you need more · Cancel anytime Free forever · Upgrade when you need more · Cancel anytime
</p> </p> */}
</div> </div>
</div> </div>
</main> </main>

View File

@@ -1,126 +1,125 @@
import { format } from "date-fns"; // import { format } from "date-fns";
import { useMemo, useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link } from "react-router-dom";
import { LoginModal } from "@/components/login-modal"; import { LoginModal } from "@/components/login-modal";
import { PricingCard, pricingTiers } from "@/components/pricing-card"; // import { PricingCard, pricingTiers } from "@/components/pricing-card";
import { useSession } from "@/components/session-provider"; import { useSession } from "@/components/session-provider";
import { // import {
AlertDialog, // AlertDialog,
AlertDialogAction, // AlertDialogAction,
AlertDialogCancel, // AlertDialogCancel,
AlertDialogContent, // AlertDialogContent,
AlertDialogDescription, // AlertDialogDescription,
AlertDialogFooter, // AlertDialogFooter,
AlertDialogHeader, // AlertDialogHeader,
AlertDialogTitle, // AlertDialogTitle,
AlertDialogTrigger, // AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; // } from "@/components/ui/alert-dialog";
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 { Switch } from "@/components/ui/switch"; // import { Switch } from "@/components/ui/switch";
import { // import {
useCancelSubscription, // useCancelSubscription,
useCreateCheckoutSession, // useCreateCheckoutSession,
useCreatePortalSession, // useCreatePortalSession,
useSubscription, // useSubscription,
} from "@/lib/query/hooks"; // } from "@/lib/query/hooks";
import { cn } from "@/lib/utils"; // import { cn } from "@/lib/utils";
export default function Plans() { export default function Plans() {
const { user, isLoading } = useSession(); const { user, isLoading } = useSession();
const navigate = useNavigate();
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
const [processingTier, setProcessingTier] = useState<string | null>(null); // const navigate = useNavigate();
// const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
// const [processingTier, setProcessingTier] = useState<string | null>(null);
const { data: subscriptionData } = useSubscription(); // const { data: subscriptionData } = useSubscription();
const createCheckoutSession = useCreateCheckoutSession(); // const createCheckoutSession = useCreateCheckoutSession();
const createPortalSession = useCreatePortalSession(); // const createPortalSession = useCreatePortalSession();
const cancelSubscription = useCancelSubscription(); // const cancelSubscription = useCancelSubscription();
const subscription = subscriptionData?.subscription ?? null; // const subscription = subscriptionData?.subscription ?? null;
const isProUser = // const isProUser =
user?.plan === "pro" || subscription?.status === "active" || subscription?.status === "trialing"; // user?.plan === "pro" || subscription?.status === "active" || subscription?.status === "trialing";
const isCancellationScheduled = Boolean(subscription?.cancelAtPeriodEnd); // const isCancellationScheduled = Boolean(subscription?.cancelAtPeriodEnd);
const isCanceled = subscription?.status === "canceled"; // const isCanceled = subscription?.status === "canceled";
const cancellationEndDate = useMemo(() => { // const cancellationEndDate = useMemo(() => {
if (!subscription?.currentPeriodEnd) return null; // if (!subscription?.currentPeriodEnd) return null;
const date = new Date(subscription.currentPeriodEnd); // const date = new Date(subscription.currentPeriodEnd);
if (Number.isNaN(date.getTime())) return null; // if (Number.isNaN(date.getTime())) return null;
return format(date, "d MMM yyyy"); // return format(date, "d MMM yyyy");
}, [subscription?.currentPeriodEnd]); // }, [subscription?.currentPeriodEnd]);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelError, setCancelError] = useState<string | null>(null); // const [cancelError, setCancelError] = useState<string | null>(null);
const handleTierAction = async (tierName: string) => { // const handleTierAction = async (tierName: string) => {
if (!user) { // if (!user) {
setLoginModalOpen(true); // setLoginModalOpen(true);
return; // return;
} // }
//
// if (tierName === "Pro") {
// if (isProUser) {
// // open customer portal
// setProcessingTier(tierName);
// try {
// const result = await createPortalSession.mutateAsync();
// if (result.url) {
// window.location.href = result.url;
// } else {
// setProcessingTier(null);
// }
// } catch {
// setProcessingTier(null);
// }
// } else {
// // start checkout
// setProcessingTier(tierName);
// try {
// const result = await createCheckoutSession.mutateAsync({ billingPeriod });
// if (result.url) {
// window.location.href = result.url;
// } else {
// setProcessingTier(null);
// }
// } catch {
// setProcessingTier(null);
// }
// }
// }
// // starter tier - just go to issues if not already there
// if (tierName === "Starter") {
// navigate("/issues");
// }
// };
if (tierName === "Pro") { // const handleCancelSubscription = async () => {
if (isProUser) { // setCancelError(null);
// open customer portal // try {
setProcessingTier(tierName); // await cancelSubscription.mutateAsync();
try { // setCancelDialogOpen(false);
const result = await createPortalSession.mutateAsync(); // } catch (error) {
if (result.url) { // const message = error instanceof Error ? error.message : "failed to cancel subscription";
window.location.href = result.url; // setCancelError(message);
} else { // }
setProcessingTier(null); // };
}
} catch {
setProcessingTier(null);
}
} else {
// start checkout
setProcessingTier(tierName);
try {
const result = await createCheckoutSession.mutateAsync({ billingPeriod });
if (result.url) {
window.location.href = result.url;
} else {
setProcessingTier(null);
}
} catch {
setProcessingTier(null);
}
}
}
// starter tier - just go to issues if not already there
if (tierName === "Starter") {
navigate("/issues");
}
};
const handleCancelSubscription = async () => { // const modifiedTiers = pricingTiers.map((tier) => {
setCancelError(null); // const isCurrentPlan = tier.name === "Pro" && isProUser;
try { // const isStarterCurrent = tier.name === "Starter" && !!user && !isProUser;
await cancelSubscription.mutateAsync(); //
setCancelDialogOpen(false); // return {
} catch (error) { // ...tier,
const message = error instanceof Error ? error.message : "failed to cancel subscription"; // highlighted: isCurrentPlan || (!isProUser && tier.name === "Pro"),
setCancelError(message); // cta: isCurrentPlan
} // ? "Manage subscription"
}; // : isStarterCurrent
// ? "Current plan"
// modify pricing tiers based on user's current plan // : tier.name === "Pro"
const modifiedTiers = pricingTiers.map((tier) => { // ? "Upgrade to Pro"
const isCurrentPlan = tier.name === "Pro" && isProUser; // : tier.cta,
const isStarterCurrent = tier.name === "Starter" && !!user && !isProUser; // };
// });
return {
...tier,
highlighted: isCurrentPlan || (!isProUser && tier.name === "Pro"),
cta: isCurrentPlan
? "Manage subscription"
: isStarterCurrent
? "Current plan"
: tier.name === "Pro"
? "Upgrade to Pro"
: tier.cta,
};
});
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
@@ -153,149 +152,7 @@ export default function Plans() {
</header> </header>
<main className="flex-1 flex flex-col items-center py-16 pt-14 px-4"> <main className="flex-1 flex flex-col items-center py-16 pt-14 px-4">
<div className="max-w-6xl w-full space-y-16"> {/* pricing content commented out for beta */}
<div className="text-center space-y-6">
<h1 className="text-5xl font-basteleur font-700">
{user ? "Choose your plan" : "Simple, transparent pricing"}
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
{user
? isProUser
? "You are currently on the Pro plan. Manage your subscription or switch plans below."
: "You are currently on the Starter plan. Upgrade to Pro for unlimited access."
: "Choose the plan that fits your team. Scale as you grow."}
</p>
{/* billing toggle */}
<div className="flex items-center justify-center gap-4 pt-4">
<button
type="button"
onClick={() => setBillingPeriod("monthly")}
className={cn(
"text-lg transition-colors",
billingPeriod === "monthly" ? "text-foreground font-700" : "text-muted-foreground",
)}
>
monthly
</button>
<Switch
size="lg"
checked={billingPeriod === "annual"}
onCheckedChange={(checked) => setBillingPeriod(checked ? "annual" : "monthly")}
className="bg-border data-[state=checked]:bg-border! data-[state=unchecked]:bg-border!"
thumbClassName="bg-personality dark:bg-personality data-[state=checked]:bg-personality! data-[state=unchecked]:bg-personality!"
aria-label="toggle billing period"
/>
<button
type="button"
onClick={() => setBillingPeriod("annual")}
className={cn(
"text-lg transition-colors",
billingPeriod === "annual" ? "text-foreground font-700" : "text-muted-foreground",
)}
>
annual
</button>
<span className="text-sm px-3 py-1 bg-personality/10 text-personality rounded-full font-600">
Save 17%
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl mx-auto">
{modifiedTiers.map((tier) => (
<PricingCard
key={tier.name}
tier={tier}
billingPeriod={billingPeriod}
onCtaClick={() => handleTierAction(tier.name)}
disabled={processingTier !== null || tier.name === "Starter"}
loading={processingTier === tier.name}
/>
))}
</div>
{user && isProUser && (
<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>
<p className="text-sm text-muted-foreground">
{isCancellationScheduled || isCanceled
? `Cancelled, benefits end on ${cancellationEndDate ?? "your billing end date"}.`
: "Canceling will keep access until the end of your billing period."}
</p>
</div>
<AlertDialog
open={cancelDialogOpen}
onOpenChange={(open: boolean) => {
setCancelDialogOpen(open);
if (!open) setCancelError(null);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={cancelSubscription.isPending || isCancellationScheduled || isCanceled}
>
{isCancellationScheduled || isCanceled
? "Cancellation scheduled"
: "Cancel subscription"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel subscription?</AlertDialogTitle>
<AlertDialogDescription>
You will keep Pro access until the end of your current billing period.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep subscription</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleCancelSubscription}>
{cancelSubscription.isPending ? "Canceling..." : "Confirm cancel"}
</AlertDialogAction>
</AlertDialogFooter>
{cancelError && <p className="text-sm text-destructive">{cancelError}</p>}
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
{/* trust signals */}
<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>
<p className="text-sm text-muted-foreground">Your data is safe with us</p>
</div>
<div className="flex flex-col items-center text-center gap-2">
<Icon
icon="creditCardDelete"
iconStyle={"pixel"}
className="size-8"
color="var(--personality)"
/>
<p className="font-700">Free Starter Plan</p>
<p className="text-sm text-muted-foreground">Get started instantly</p>
</div>
<div className="flex flex-col items-center text-center gap-2">
<Icon icon="rotateCcw" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
<p className="font-700">Money Back Guarantee</p>
<p className="text-sm text-muted-foreground">30-day no-risk policy</p>
</div>
</div>
<div className="w-full max-w-4xl mx-auto border-t pt-4 pb-2 text-center">
<Link
to="/the-boring-stuff"
className="text-sm text-muted-foreground hover:text-personality transition-colors"
>
The boring stuff Privacy Policy & ToS
</Link>
</div>
</div>
</main> </main>
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} /> <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />