mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
Merge branch 'development'
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { IconStyle } from "@sprint/shared";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
// import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
@@ -38,11 +38,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
setName(currentUser.name);
|
||||
setUsername(currentUser.username);
|
||||
setAvatarUrl(currentUser.avatarURL || null);
|
||||
// free users are locked to pixel icon style
|
||||
const effectiveIconStyle =
|
||||
currentUser.plan === "pro"
|
||||
? ((currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE)
|
||||
: DEFAULT_ICON_STYLE;
|
||||
const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE;
|
||||
setIconPreference(effectiveIconStyle);
|
||||
|
||||
setPassword("");
|
||||
@@ -59,13 +55,11 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
// only send iconPreference for pro users
|
||||
const effectiveIconPreference = currentUser.plan === "pro" ? iconPreference : undefined;
|
||||
const data = await updateUser.mutateAsync({
|
||||
name: name.trim(),
|
||||
password: password.trim() || undefined,
|
||||
avatarURL,
|
||||
iconPreference: effectiveIconPreference,
|
||||
iconPreference,
|
||||
});
|
||||
setError("");
|
||||
setUser(data);
|
||||
@@ -141,22 +135,9 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
<ThemeToggle withText />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className={cn("text-sm", currentUser.plan !== "pro" && "text-muted-foreground")}>
|
||||
Icon Style
|
||||
</Label>
|
||||
<Select
|
||||
value={iconPreference}
|
||||
onValueChange={(v) => setIconPreference(v as IconStyle)}
|
||||
disabled={currentUser.plan !== "pro"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("w-full", currentUser.plan !== "pro" && "cursor-not-allowed opacity-60")}
|
||||
title={
|
||||
currentUser.plan !== "pro"
|
||||
? "icon style customization is only available on Pro"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Label className="text-sm">Icon Style</Label>
|
||||
<Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}>
|
||||
<SelectTrigger className={cn("w-full")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start">
|
||||
@@ -180,21 +161,21 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentUser.plan !== "pro" && (
|
||||
{/* {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>
|
||||
|
||||
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
|
||||
{/* Show subscription management link */}
|
||||
<div className="pt-2">
|
||||
{/* subscription management link commented out for beta */}
|
||||
{/* <div className="pt-2">
|
||||
{currentUser.plan === "pro" ? (
|
||||
<Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700">
|
||||
<Link to="/plans">Manage subscription</Link>
|
||||
@@ -204,7 +185,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
<Link to="/plans">Upgrade to Pro</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant={"outline"} type={"submit"} className="px-12">
|
||||
|
||||
@@ -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 { 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 { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { SprintSelect } from "@/components/sprint-select";
|
||||
@@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
import {
|
||||
useCreateIssue,
|
||||
useIssues,
|
||||
// useIssues,
|
||||
useOrganisationMembers,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { parseError } from "@/lib/server";
|
||||
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 }) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
@@ -41,12 +41,12 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
const selectedProject = useSelectedProject();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
const { data: issues = [] } = useIssues(selectedProject?.Project.id);
|
||||
// const { data: issues = [] } = useIssues(selectedProject?.Project.id);
|
||||
const createIssue = useCreateIssue();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const issueCount = issues.length;
|
||||
const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT;
|
||||
// const isPro = user.plan === "pro";
|
||||
// const issueCount = issues.length;
|
||||
// const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT;
|
||||
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
@@ -149,14 +149,8 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
{trigger || (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selectedProject || isAtIssueLimit}
|
||||
title={
|
||||
isAtIssueLimit
|
||||
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
|
||||
: !selectedProject
|
||||
? "Select a project first"
|
||||
: undefined
|
||||
}
|
||||
disabled={!selectedProject}
|
||||
title={!selectedProject ? "Select a project first" : undefined}
|
||||
>
|
||||
Create Issue
|
||||
</Button>
|
||||
@@ -168,7 +162,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
<DialogTitle>Create Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!isPro && selectedProject && (
|
||||
{/* {!isPro && selectedProject && (
|
||||
<div className="mb-2">
|
||||
<FreeTierLimit
|
||||
current={issueCount}
|
||||
@@ -178,7 +172,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
showUpgrade={isAtIssueLimit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid">
|
||||
@@ -301,16 +295,10 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
isAtIssueLimit ||
|
||||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
|
||||
submitAttempted) ||
|
||||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
|
||||
}
|
||||
title={
|
||||
isAtIssueLimit
|
||||
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { useSelection } from "@/components/selection-provider";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
// import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -19,7 +19,7 @@ import { useOrganisations } from "@/lib/query/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import OrgIcon from "./org-icon";
|
||||
|
||||
const FREE_TIER_ORG_LIMIT = 1;
|
||||
// const free_tier_org_limit = 1;
|
||||
|
||||
export function OrganisationSelect({
|
||||
placeholder = "Select Organisation",
|
||||
@@ -44,11 +44,10 @@ export function OrganisationSelect({
|
||||
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { selectedOrganisationId, selectOrganisation } = useSelection();
|
||||
const { user } = useAuthenticatedSession();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const orgCount = organisationsData.length;
|
||||
const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT;
|
||||
// const { user } = useAuthenticatedSession();
|
||||
// const isPro = user.plan === "pro";
|
||||
// const orgCount = organisationsData.length;
|
||||
// const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT;
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
@@ -116,7 +115,7 @@ export function OrganisationSelect({
|
||||
{organisations.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
|
||||
{!isPro && (
|
||||
{/* {!isPro && (
|
||||
<div className="px-2 py-2">
|
||||
<FreeTierLimit
|
||||
current={orgCount}
|
||||
@@ -126,21 +125,11 @@ export function OrganisationSelect({
|
||||
showUpgrade={isAtOrgLimit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<OrganisationForm
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={"w-full"}
|
||||
size={"sm"}
|
||||
disabled={isAtOrgLimit}
|
||||
title={
|
||||
isAtOrgLimit
|
||||
? "Free tier limited to 1 organisation. Upgrade to Pro for unlimited."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" className={"w-full"} size={"sm"} disabled={false}>
|
||||
Create Organisation
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
} from "@sprint/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
// import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
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 { OrganisationForm } from "@/components/organisation-form";
|
||||
import { OrganisationSelect } from "@/components/organisation-select";
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
useDeleteOrganisation,
|
||||
useDeleteProject,
|
||||
useDeleteSprint,
|
||||
useIssues,
|
||||
// useIssues,
|
||||
useOrganisationMembers,
|
||||
useOrganisationMemberTimeTracking,
|
||||
useOrganisations,
|
||||
@@ -58,15 +58,15 @@ import {
|
||||
} from "@/lib/query/hooks";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
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";
|
||||
|
||||
const FREE_TIER_LIMITS = {
|
||||
organisationsPerUser: 1,
|
||||
projectsPerOrganisation: 1,
|
||||
issuesPerOrganisation: 100,
|
||||
membersPerOrganisation: 5,
|
||||
} as const;
|
||||
// const FREE_TIER_LIMITS = {
|
||||
// organisationsPerUser: 1,
|
||||
// projectsPerOrganisation: 1,
|
||||
// issuesPerOrganisation: 100,
|
||||
// membersPerOrganisation: 5,
|
||||
// } as const;
|
||||
|
||||
function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
@@ -76,7 +76,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { data: sprints = [] } = useSprints(selectedProjectId);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
|
||||
const { data: issues = [] } = useIssues(selectedProjectId);
|
||||
// const { data: issues = [] } = useIssues(selectedProjectId);
|
||||
const updateOrganisation = useUpdateOrganisation();
|
||||
const updateMemberRole = useUpdateOrganisationMemberRole();
|
||||
const removeMember = useRemoveOrganisationMember();
|
||||
@@ -86,11 +86,11 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const replaceIssueStatus = useReplaceIssueStatus();
|
||||
const replaceIssueType = useReplaceIssueType();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const orgCount = organisationsData.length;
|
||||
const projectCount = projectsData.length;
|
||||
const issueCount = issues.length;
|
||||
const memberCount = membersData.length;
|
||||
// const isPro = user.plan === "pro";
|
||||
// const orgCount = organisationsData.length;
|
||||
// const projectCount = projectsData.length;
|
||||
// const issueCount = issues.length;
|
||||
// const memberCount = membersData.length;
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
@@ -842,7 +842,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</div>
|
||||
|
||||
{/* Free tier limits section */}
|
||||
{!isPro && (
|
||||
{/* {!isPro && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-600">Plan Limits</h3>
|
||||
@@ -881,7 +881,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
@@ -943,40 +943,36 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</h2>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isPro && (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
From: {fromDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={fromDate}
|
||||
onSelect={(date) => date && setFromDate(date)}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
|
||||
Download CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
|
||||
Download JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
From: {fromDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={fromDate}
|
||||
onSelect={(date) => date && setFromDate(date)}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
|
||||
Download CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
|
||||
Download JSON
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -994,7 +990,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && isPro && (
|
||||
{isAdmin && (
|
||||
<span className="text-sm font-mono text-muted-foreground mr-2">
|
||||
{formatDuration(member.totalTimeMs)}
|
||||
</span>
|
||||
@@ -1033,7 +1029,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
{!isPro && (
|
||||
{/* {!isPro && (
|
||||
<div className="px-1">
|
||||
<FreeTierLimit
|
||||
current={memberCount}
|
||||
@@ -1043,7 +1039,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
<AddMember
|
||||
organisationId={selectedOrganisation.Organisation.id}
|
||||
existingMembers={members.map((m) => m.User.username)}
|
||||
@@ -1058,15 +1054,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
void invalidateMembers();
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||
title={
|
||||
!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation
|
||||
? "Free tier limited to 5 members per organisation. Upgrade to Pro for unlimited."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button variant="outline">
|
||||
Add user <Icon icon="plus" className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
@@ -1522,14 +1510,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
<TabsContent value="features">
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">Features</h2>
|
||||
{!isPro && (
|
||||
{/* {!isPro && (
|
||||
<div className="mb-3 p-2 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
Feature toggling is only available on Pro.{" "}
|
||||
<Link to="/plans" className="text-personality hover:underline">
|
||||
Upgrade to customize features.
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{Object.keys(DEFAULT_FEATURES).map((feature) => (
|
||||
<div key={feature} className="flex items-center gap-2 p-1">
|
||||
@@ -1551,12 +1539,9 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
);
|
||||
await invalidateOrganisations();
|
||||
}}
|
||||
disabled={!isPro}
|
||||
color={"#ff0000"}
|
||||
/>
|
||||
<span className={cn("text-sm", !isPro && "text-muted-foreground")}>
|
||||
{unCamelCase(feature)}
|
||||
</span>
|
||||
<span className="text-sm">{unCamelCase(feature)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { useSelection } from "@/components/selection-provider";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
// import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/lib/query/hooks";
|
||||
|
||||
const FREE_TIER_PROJECT_LIMIT = 1;
|
||||
// const free_tier_project_limit = 1;
|
||||
|
||||
export function ProjectSelect({
|
||||
placeholder = "Select Project",
|
||||
@@ -33,11 +33,10 @@ export function ProjectSelect({
|
||||
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
|
||||
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const { user } = useAuthenticatedSession();
|
||||
|
||||
const isPro = user.plan === "pro";
|
||||
const projectCount = projectsData.length;
|
||||
const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT;
|
||||
// const { user } = useAuthenticatedSession();
|
||||
// const isPro = user.plan === "pro";
|
||||
// const projectCount = projectsData.length;
|
||||
// const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT;
|
||||
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
@@ -91,7 +90,7 @@ export function ProjectSelect({
|
||||
{projects.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
|
||||
{!isPro && selectedOrganisationId && (
|
||||
{/* {!isPro && selectedOrganisationId && (
|
||||
<div className="px-2 py-2">
|
||||
<FreeTierLimit
|
||||
current={projectCount}
|
||||
@@ -101,7 +100,7 @@ export function ProjectSelect({
|
||||
showUpgrade={isAtProjectLimit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<ProjectForm
|
||||
organisationId={selectedOrganisationId ?? undefined}
|
||||
@@ -110,14 +109,8 @@ export function ProjectSelect({
|
||||
size={"sm"}
|
||||
variant="ghost"
|
||||
className={"w-full"}
|
||||
disabled={!selectedOrganisationId || isAtProjectLimit}
|
||||
title={
|
||||
isAtProjectLimit
|
||||
? "Free tier limited to 1 project per organisation. Upgrade to Pro for unlimited."
|
||||
: !selectedOrganisationId
|
||||
? "Select an organisation first"
|
||||
: undefined
|
||||
}
|
||||
disabled={!selectedOrganisationId}
|
||||
title={!selectedOrganisationId ? "Select an organisation first" : undefined}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
// import { FreeTierLimit } from "@/components/free-tier-limit";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -22,7 +22,7 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SPRINT_NAME_MAX_LENGTH = 64;
|
||||
const FREE_TIER_SPRINT_LIMIT = 5;
|
||||
// const free_tier_sprint_limit = 5;
|
||||
|
||||
const getStartOfDay = (date: Date) => {
|
||||
const next = new Date(date);
|
||||
@@ -303,7 +303,7 @@ export function SprintForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
{/* {!isEdit && (
|
||||
<FreeTierLimit
|
||||
current={sprints.length}
|
||||
limit={FREE_TIER_SPRINT_LIMIT}
|
||||
@@ -311,7 +311,7 @@ export function SprintForm({
|
||||
isPro={user.plan === "pro"}
|
||||
showUpgrade={sprints.length >= FREE_TIER_SPRINT_LIMIT}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
@@ -324,13 +324,7 @@ export function SprintForm({
|
||||
disabled={
|
||||
submitting ||
|
||||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && 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
|
||||
(dateError !== "" && submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { IssueForm } from "@/components/issue-form";
|
||||
import LogOutButton from "@/components/log-out-button";
|
||||
@@ -11,7 +11,7 @@ import { useSelection } from "@/components/selection-provider";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { SprintForm } from "@/components/sprint-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -123,11 +123,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<Link to="/plans">Upgrade</Link>
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-sm">
|
||||
<SmallUserDisplay user={user} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
// import { useSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -9,36 +9,36 @@ import { useUploadAvatar } from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function isAnimatedGIF(file: File): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result as ArrayBuffer;
|
||||
const arr = new Uint8Array(buffer);
|
||||
// check for GIF89a or GIF87a header
|
||||
const header = String.fromCharCode(...arr.slice(0, 6));
|
||||
if (header !== "GIF89a" && header !== "GIF87a") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
// look for multiple images (animation indicator)
|
||||
// GIFs have image descriptors starting with 0x2C
|
||||
// and graphic control extensions starting with 0x21 0xF9
|
||||
let frameCount = 0;
|
||||
let i = 6; // skip header
|
||||
while (i < arr.length - 1) {
|
||||
if (arr[i] === 0x21 && arr[i + 1] === 0xf9) {
|
||||
// graphic control extension - indicates animation frame
|
||||
frameCount++;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
resolve(frameCount > 1);
|
||||
};
|
||||
reader.onerror = () => resolve(false);
|
||||
reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check
|
||||
});
|
||||
}
|
||||
// function isAnimatedGIF(file: File): Promise<boolean> {
|
||||
// return new Promise((resolve) => {
|
||||
// const reader = new FileReader();
|
||||
// reader.onload = () => {
|
||||
// const buffer = reader.result as ArrayBuffer;
|
||||
// const arr = new Uint8Array(buffer);
|
||||
// // check for GIF89a or GIF87a header
|
||||
// const header = String.fromCharCode(...arr.slice(0, 6));
|
||||
// if (header !== "GIF89a" && header !== "GIF87a") {
|
||||
// resolve(false);
|
||||
// return;
|
||||
// }
|
||||
// // look for multiple images (animation indicator)
|
||||
// // GIFs have image descriptors starting with 0x2C
|
||||
// // and graphic control extensions starting with 0x21 0xF9
|
||||
// let frameCount = 0;
|
||||
// let i = 6; // skip header
|
||||
// while (i < arr.length - 1) {
|
||||
// if (arr[i] === 0x21 && arr[i + 1] === 0xf9) {
|
||||
// // graphic control extension - indicates animation frame
|
||||
// frameCount++;
|
||||
// }
|
||||
// i++;
|
||||
// }
|
||||
// resolve(frameCount > 1);
|
||||
// };
|
||||
// reader.onerror = () => resolve(false);
|
||||
// reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check
|
||||
// });
|
||||
// }
|
||||
|
||||
export function UploadAvatar({
|
||||
name,
|
||||
@@ -56,7 +56,7 @@ export function UploadAvatar({
|
||||
skipOrgCheck?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { user } = useSession();
|
||||
// const { user } = useSession();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -68,20 +68,20 @@ export function UploadAvatar({
|
||||
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;
|
||||
}
|
||||
}
|
||||
// if (user?.plan !== "pro" && file.type === "image/gif") {
|
||||
// const isAnimated = await isAnimatedGIF(file);
|
||||
// if (isAnimated) {
|
||||
// setError("Animated avatars are only available on Pro. Upgrade to upload animated avatars.");
|
||||
// toast.error("Animated avatars are only available on Pro. Upgrade to upload animated avatars.", {
|
||||
// dismissible: false,
|
||||
// });
|
||||
// // reset file input
|
||||
// if (fileInputRef.current) {
|
||||
// fileInputRef.current.value = "";
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
@@ -99,25 +99,9 @@ export function UploadAvatar({
|
||||
setError(message);
|
||||
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}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
toast.error(`Error uploading avatar: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user