diff --git a/apps/webapp/app/components/layout/sidebar/organization-selector.tsx b/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
index c1679ec587..39da479667 100644
--- a/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
+++ b/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
@@ -144,6 +144,23 @@ export default function OrganizationSelector() {
>
Manage workspaces
+ {/**
+ * Personal workspaces can't invite anyone, whatever the user pays.
+ * The label deliberately makes no claim about their plan: tier
+ * lives on the user while add-ons live on the organization, so
+ * "upgrade" would be wrong for a Team-tier user sitting in their
+ * Personal workspace. The Team page resolves the actual action.
+ */}
+ {currentOrganization.type === "PERSONAL" ? (
+
+ ) : null}
diff --git a/apps/webapp/app/components/layout/sidebar/parent-nav-item.tsx b/apps/webapp/app/components/layout/sidebar/parent-nav-item.tsx
index a6cf01fda7..3abcce85f6 100644
--- a/apps/webapp/app/components/layout/sidebar/parent-nav-item.tsx
+++ b/apps/webapp/app/components/layout/sidebar/parent-nav-item.tsx
@@ -103,6 +103,29 @@ function NestedRouteRenderer({
}) {
const isChildActive = useIsRouteActive(nested.to);
+ /**
+ * Disabled children (e.g. "Users" / "Pending invites" on a Personal
+ * workspace) stay visible but render muted and non-navigating, with the
+ * reason shown as a hover tooltip. This mirrors how disabled features are
+ * surfaced elsewhere instead of hiding the capability outright.
+ */
+ if (nested.disabled) {
+ const reason =
+ typeof nested.disabled === "object" ? nested.disabled.reason : undefined;
+ return (
+
Your choice determines which features we prepare for you. You can
- always switch later.
+ upgrade to Team later from your workspace settings.
If your organization already uses Shelf, you don't need to create a
@@ -246,6 +258,24 @@ export function ChoosePurpose({
+ You told us your team has {teamIntent.teamSize}. Personal
+ workspaces are for one person and can't invite anyone.
+
diff --git a/apps/webapp/app/hooks/use-sidebar-nav-items.tsx b/apps/webapp/app/hooks/use-sidebar-nav-items.tsx
index 60709f045c..dae51dabef 100644
--- a/apps/webapp/app/hooks/use-sidebar-nav-items.tsx
+++ b/apps/webapp/app/hooks/use-sidebar-nav-items.tsx
@@ -93,6 +93,19 @@ export function useSidebarNavItems() {
};
}, [canUseBookings, subscription]);
+ /**
+ * Personal workspaces can't invite registered users. Rather than hide the
+ * "Users" / "Pending invites" items, we show them disabled with an upgrade
+ * reason, mirroring how bookings are surfaced on Personal workspaces.
+ */
+ const teamInviteDisabled = useMemo(() => {
+ if (!isPersonalOrganization) {
+ return false;
+ }
+
+ return { reason: "Inviting users is available on Team workspaces" };
+ }, [isPersonalOrganization]);
+
const topMenuItems: NavItem[] = [
{
type: "child",
@@ -197,12 +210,12 @@ export function useSidebarNavItems() {
{
title: "Users",
to: "/settings/team/users",
- hidden: isPersonalOrganization,
+ disabled: teamInviteDisabled,
},
{
title: "Pending invites",
to: "/settings/team/invites",
- hidden: isPersonalOrganization,
+ disabled: teamInviteDisabled,
},
{
title: "Non-registered members",
diff --git a/apps/webapp/app/modules/onboarding/constants.test.ts b/apps/webapp/app/modules/onboarding/constants.test.ts
new file mode 100644
index 0000000000..7af0a050be
--- /dev/null
+++ b/apps/webapp/app/modules/onboarding/constants.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import { signalsTeamIntent } from "./constants";
+
+describe("signalsTeamIntent", () => {
+ it("returns false when the question was never answered", () => {
+ expect(signalsTeamIntent(null)).toBe(false);
+ expect(signalsTeamIntent(undefined)).toBe(false);
+ expect(signalsTeamIntent("")).toBe(false);
+ });
+
+ it("returns false for the solo option", () => {
+ expect(signalsTeamIntent("Just me (1)")).toBe(false);
+ });
+
+ it("returns false for free-text answers outside the known options", () => {
+ /**
+ * teamSize is captured with SelectWithOther, so stored values are not
+ * limited to TEAM_SIZE_OPTIONS. Production data contains answers like "1";
+ * treating those as a team would push a solo user toward a Team workspace
+ * and show them a nonsensical "your team has 1" caution.
+ */
+ expect(signalsTeamIntent("1")).toBe(false);
+ expect(signalsTeamIntent("just me")).toBe(false);
+ expect(signalsTeamIntent("2")).toBe(false);
+ });
+
+ it("returns true for the known multi-person options", () => {
+ expect(signalsTeamIntent("Small team (2-10)")).toBe(true);
+ expect(signalsTeamIntent("Department (11-50)")).toBe(true);
+ expect(signalsTeamIntent("Large organization (50+)")).toBe(true);
+ });
+});
diff --git a/apps/webapp/app/modules/onboarding/constants.ts b/apps/webapp/app/modules/onboarding/constants.ts
index c53c473eae..9298c30329 100644
--- a/apps/webapp/app/modules/onboarding/constants.ts
+++ b/apps/webapp/app/modules/onboarding/constants.ts
@@ -43,6 +43,30 @@ export const TIMELINE_OPTIONS = [
"Just exploring",
] as const;
+/**
+ * The team-size answers that mean "more than one person will use this
+ * workspace". Derived from {@link TEAM_SIZE_OPTIONS} so the two can't drift.
+ */
+export const MULTI_PERSON_TEAM_SIZES: readonly string[] =
+ TEAM_SIZE_OPTIONS.filter((option) => option !== "Just me (1)");
+
+/**
+ * Whether an onboarding team-size answer signals the user expects teammates.
+ *
+ * The team-size field is captured with `SelectWithOther`, so stored values are
+ * NOT limited to {@link TEAM_SIZE_OPTIONS}: real data contains free-text
+ * answers such as "1". Only the known multi-person options count, so an
+ * arbitrary answer never pushes a solo user toward a Team workspace.
+ *
+ * @param teamSize - The stored `UserBusinessIntel.teamSize` value, if any
+ * @returns true when the answer is a known multi-person option
+ */
+export function signalsTeamIntent(
+ teamSize: string | null | undefined
+): boolean {
+ return !!teamSize && MULTI_PERSON_TEAM_SIZES.includes(teamSize);
+}
+
export type RoleOption = (typeof ROLE_OPTIONS)[number];
export type TeamSizeOption = (typeof TEAM_SIZE_OPTIONS)[number];
export type PrimaryUseCaseOption = (typeof PRIMARY_USE_CASE_OPTIONS)[number];
diff --git a/apps/webapp/app/routes/_layout+/settings.team.invites.tsx b/apps/webapp/app/routes/_layout+/settings.team.invites.tsx
index 01c3a4c257..622ea328ab 100644
--- a/apps/webapp/app/routes/_layout+/settings.team.invites.tsx
+++ b/apps/webapp/app/routes/_layout+/settings.team.invites.tsx
@@ -66,9 +66,12 @@ export async function loader({ request, context }: LoaderFunctionArgs) {
});
}
- /** Cannot manage users for PERSONAL organization */
+ /**
+ * Personal workspaces can't manage invites. Send them to the Team page
+ * (which explains how to upgrade) instead of a contextless redirect.
+ */
if (organization?.type === "PERSONAL") {
- return redirect("/settings/general");
+ return redirect("/settings/team/nrm");
}
const { page, perPage, search, items, totalItems, totalPages } =
diff --git a/apps/webapp/app/routes/_layout+/settings.team.nrm.tsx b/apps/webapp/app/routes/_layout+/settings.team.nrm.tsx
index 6e9aefc03c..1a8bb295f7 100644
--- a/apps/webapp/app/routes/_layout+/settings.team.nrm.tsx
+++ b/apps/webapp/app/routes/_layout+/settings.team.nrm.tsx
@@ -202,8 +202,8 @@ export default function NrmSettings() {
className="overflow-x-visible md:overflow-x-auto"
ItemComponent={TeamMemberRow}
customEmptyStateContent={{
- title: "No team members on database",
- text: "What are you waiting for? Add your first team member now!",
+ title: "No non-registered members yet",
+ text: "Non-registered members are name-only records for assigning custody. They can't log in.",
newButtonRoute: "add-member",
newButtonContent: "Add NRM",
}}
diff --git a/apps/webapp/app/routes/_layout+/settings.team.tsx b/apps/webapp/app/routes/_layout+/settings.team.tsx
index 2175f50871..39a837f1f4 100644
--- a/apps/webapp/app/routes/_layout+/settings.team.tsx
+++ b/apps/webapp/app/routes/_layout+/settings.team.tsx
@@ -1,10 +1,14 @@
import { OrganizationRoles } from "@prisma/client";
+import type { Prisma } from "@prisma/client";
+import { UsersIcon } from "lucide-react";
import type { LoaderFunctionArgs } from "react-router";
import { data, Outlet, useLoaderData, useParams } from "react-router";
import { ErrorContent } from "~/components/errors";
+import { PremiumFeatureTeaser } from "~/components/home/premium-feature-teaser";
import HorizontalTabs from "~/components/layout/horizontal-tabs";
import type { Item } from "~/components/layout/horizontal-tabs/types";
import When from "~/components/when/when";
+import { getUserByID } from "~/modules/user/service.server";
import { appendToMetaTitle } from "~/utils/append-to-meta-title";
import { makeShelfError } from "~/utils/error";
import { payload, error } from "~/utils/http.server";
@@ -13,6 +17,7 @@ import {
PermissionEntity,
} from "~/utils/permissions/permission.data";
import { requirePermission } from "~/utils/roles.server";
+import { resolveTeamUpgradeCta } from "~/utils/team-upgrade-cta";
export type UserFriendlyRoles =
| "Administrator"
@@ -31,9 +36,35 @@ export const loader = async ({ request, context }: LoaderFunctionArgs) => {
entity: PermissionEntity.teamMember,
action: PermissionAction.read,
});
+
+ const isPersonalOrg = currentOrganization.type === "PERSONAL";
+
+ /**
+ * Personal workspaces see an upgrade teaser. Which CTA is correct depends
+ * on the user's tier and whether a trial is still available to them.
+ */
+ let upgradeCta = {
+ to: "/account-details/subscription",
+ label: "Start a Team trial",
+ };
+ if (isPersonalOrg) {
+ const user = await getUserByID(userId, {
+ select: {
+ tierId: true,
+ usedFreeTrial: true,
+ } satisfies Prisma.UserSelect,
+ });
+ upgradeCta = resolveTeamUpgradeCta({
+ tierId: user.tierId,
+ usedFreeTrial: user.usedFreeTrial,
+ });
+ }
+
return payload({
- isPersonalOrg: currentOrganization.type === "PERSONAL",
+ isPersonalOrg,
orgName: currentOrganization.name,
+ upgradeCtaTo: upgradeCta.to,
+ upgradeCtaLabel: upgradeCta.label,
});
} catch (cause) {
const reason = makeShelfError(cause);
@@ -49,7 +80,8 @@ export const organizationRolesMap: Record