mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
Merge branch 'development'
This commit is contained in:
22
bun.lock
22
bun.lock
@@ -61,10 +61,10 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@sprint/shared": "workspace:*",
|
"@sprint/shared": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.90.19",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@ts-rest/core": "^3.52.1",
|
"@ts-rest/core": "^3.52.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -76,21 +76,21 @@
|
|||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-resizable-panels": "^4.0.15",
|
"react-resizable-panels": "^4.5.3",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.13.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@types/node": "^25.0.1",
|
"@types/node": "^25.1.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4",
|
"vite": "^7.3.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
|
|||||||
@@ -18,5 +18,4 @@ 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>
|
||||||
|
|
||||||
# password for demo accounts in seed data
|
SEED_PASSWORD=replace-in-production
|
||||||
SEED_PASSWORD=change_me_in_production
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } 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 { useSession } from "@/components/session-provider";
|
import { useSession } from "@/components/session-provider";
|
||||||
import ThemeToggle from "@/components/theme-toggle";
|
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";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
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();
|
||||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
|
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,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"
|
||||||
@@ -111,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 */}
|
||||||
@@ -221,89 +217,20 @@ export default function Landing() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* pricing section */}
|
{/* pricing section */}
|
||||||
<div
|
{/* <div id="pricing" className="max-w-5xl mx-auto space-y-12 scroll-mt-4 border-t pt-24">
|
||||||
id="pricing"
|
|
||||||
className="max-w-5xl mx-auto space-y-16 flex flex-col items-center border-t pt-24 scroll-mt-4"
|
|
||||||
>
|
|
||||||
<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">
|
||||||
Choose the plan that fits your team. Scale as you grow.
|
Choose the plan that fits your team. Scale as you grow.
|
||||||
</p>
|
</p>
|
||||||
|
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">
|
||||||
{/* billing toggle */}
|
<Link to="/plans">View plans</Link>
|
||||||
<div className="flex items-center justify-center gap-4 pt-4">
|
</Button>
|
||||||
<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">
|
|
||||||
{pricingTiers.map((tier) => (
|
|
||||||
<PricingCard
|
|
||||||
key={tier.name}
|
|
||||||
tier={tier}
|
|
||||||
billingPeriod={billingPeriod}
|
|
||||||
onCtaClick={() => setLoginModalOpen(true)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* trust signals */}
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 w-full border-t pt-16 pb-8">
|
|
||||||
<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>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
{/* faq section */}
|
{/* faq section */}
|
||||||
<div className="w-full max-w-5xl flex justify-center border-t pt-24 scroll-mt-4" id="faq">
|
{/* <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">
|
||||||
@@ -315,8 +242,7 @@ export default function Landing() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
||||||
@@ -361,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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
27
todo.md
27
todo.md
@@ -1,19 +1,16 @@
|
|||||||
# HIGH PRIORITY
|
# HIGH PRIORITY
|
||||||
|
|
||||||
|
- trial system (IN HOUSE)
|
||||||
|
|
||||||
- BUGS:
|
- BUGS:
|
||||||
- FEATURES:
|
- FEATURES:
|
||||||
- make login/register into a modal that appears atop the landing page
|
|
||||||
- user preferences
|
|
||||||
- make pixel the default icon scheme
|
|
||||||
|
|
||||||
# LOW PRIORITY
|
# LOW PRIORITY
|
||||||
|
|
||||||
- organisation
|
|
||||||
- see members' time tracking numbers
|
|
||||||
- export times to csv, json, etc.
|
|
||||||
- issues
|
- issues
|
||||||
- assignee "note" for extra context on their role in the task
|
- assignee "note" for extra context on their role in the task
|
||||||
- deadline
|
- deadline
|
||||||
|
- attachments (description or comment)
|
||||||
- user preferences
|
- user preferences
|
||||||
- colour scheme
|
- colour scheme
|
||||||
- "assign to me by default" option for new issues
|
- "assign to me by default" option for new issues
|
||||||
@@ -25,7 +22,19 @@
|
|||||||
- pull request (github/gitlab/bitbucket)
|
- pull request (github/gitlab/bitbucket)
|
||||||
- view:
|
- view:
|
||||||
- open git diff in a new tab
|
- open git diff in a new tab
|
||||||
- figure out if it's possible to remove the "lib/server/..." helpers altogether, and have some sort of dynamic route maker in the shared package
|
|
||||||
- request logging
|
|
||||||
- explore payment providers (stripe is the only one i know)
|
|
||||||
- trial system
|
- trial system
|
||||||
|
|
||||||
|
# PRO BENEFITS COMING SOON:
|
||||||
|
|
||||||
|
- git integration
|
||||||
|
|
||||||
|
## still need to lock these behid paywall:
|
||||||
|
|
||||||
|
- Advanced time tracking & reports
|
||||||
|
- Custom issue types
|
||||||
|
|
||||||
|
# EMAILS:
|
||||||
|
|
||||||
|
- welcome to sprint
|
||||||
|
- thank you for subscribing to Sprint Pro. here is what you get.
|
||||||
|
- your trial is coming to an end. manage your subscription to renew.
|
||||||
|
|||||||
Reference in New Issue
Block a user