From d2ac521f1b1ea94dd4a6d15bfce98bba38f45dd8 Mon Sep 17 00:00:00 2001
From: Carlos Virreira
Date: Tue, 21 Jul 2026 13:39:00 +0200
Subject: [PATCH 1/2] feat(workspace): make the Personal-workspace invite limit
obvious
Personal workspaces are created automatically at signup and cannot have members, so
"how can someone join my workspace?" is a recurring support question. The rule was never
stated in the product, and Settings > Team removed the answer instead of showing it.
Onboarding:
- Pre-select Team when the onboarding team-size answer indicates more than one person.
- State the consequence on both plan cards instead of only describing each option.
- Replace "you can always switch later" with the route that actually exists.
- Add signalsTeamIntent() so free-text answers such as "1" are not read as a team.
Settings > Team:
- Explain the limit on the page people open, with a tier-aware upgrade CTA.
- Stop redirecting /settings/team/users and /invites to General with no message.
- Render Users and Pending invites locked rather than hiding them entirely.
- Correct the non-registered members empty state; those records cannot log in.
Workspace switcher:
- Show the plan the user actually pays for, plus a route to upgrade.
resolveTeamUpgradeCta() keeps the CTA honest per tier: Team-entitled users are sent to
workspace creation, and users who already spent their free trial are offered an upgrade
rather than a trial the subscription action would reject.
---
.../layout/sidebar/organization-selector.tsx | 38 +++++++++++-
.../layout/sidebar/parent-nav-item.tsx | 23 +++++++
.../app/components/welcome/choose-purpose.tsx | 44 +++++++++++---
.../app/hooks/use-sidebar-nav-items.tsx | 17 +++++-
.../app/modules/onboarding/constants.test.ts | 33 ++++++++++
.../app/modules/onboarding/constants.ts | 24 ++++++++
.../routes/_layout+/settings.team.invites.tsx | 7 ++-
.../app/routes/_layout+/settings.team.nrm.tsx | 4 +-
.../app/routes/_layout+/settings.team.tsx | 47 ++++++++++++++-
.../routes/_layout+/settings.team.users.tsx | 7 ++-
apps/webapp/app/routes/_welcome+/welcome.tsx | 16 +++++
.../webapp/app/utils/team-upgrade-cta.test.ts | 60 +++++++++++++++++++
apps/webapp/app/utils/team-upgrade-cta.ts | 56 +++++++++++++++++
13 files changed, 358 insertions(+), 18 deletions(-)
create mode 100644 apps/webapp/app/modules/onboarding/constants.test.ts
create mode 100644 apps/webapp/app/utils/team-upgrade-cta.test.ts
create mode 100644 apps/webapp/app/utils/team-upgrade-cta.ts
diff --git a/apps/webapp/app/components/layout/sidebar/organization-selector.tsx b/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
index c1679ec587..daa8422186 100644
--- a/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
+++ b/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
@@ -24,14 +24,34 @@ import {
useSidebar,
} from "./sidebar";
+/**
+ * Plan marker shown beside a Personal workspace name.
+ *
+ * Personal workspaces cannot invite anyone regardless of plan, so the marker
+ * states the plan the user actually pays for rather than assuming "free" from
+ * the workspace type. Team-tier users get no marker: they already pay for Team,
+ * and badging their Personal workspace would misrepresent what it is.
+ *
+ * @param tierId - The user's subscription tier
+ * @returns The label to render, or null when no marker should be shown
+ */
+function getPersonalPlanLabel(tierId: string | null | undefined) {
+ if (tierId === "free") return "Free";
+ if (tierId === "tier_1") return "Plus";
+ return null;
+}
+
export default function OrganizationSelector() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { open, openMobile, isMobile } = useSidebar();
- const { organizations, currentOrganizationId } =
+ const { organizations, currentOrganizationId, user } =
useLoaderData();
+ /** Plan marker for a Personal workspace; null when none should show. */
+ const personalPlanLabel = getPersonalPlanLabel(user?.tierId);
+
const fetcher = useFetcher();
const isSwitchingOrg = isFormProcessing(fetcher.state);
@@ -89,6 +109,12 @@ export default function OrganizationSelector() {
{currentOrganization.name}
+ {currentOrganization.type === "PERSONAL" &&
+ personalPlanLabel ? (
+
+ {personalPlanLabel}
+
+ ) : null}
>
@@ -144,6 +170,16 @@ export default function OrganizationSelector() {
>
Manage workspaces
+ {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 (
+
+
+
+ {nested.title}
+
+
+
+ );
+ }
+
return (
diff --git a/apps/webapp/app/components/welcome/choose-purpose.tsx b/apps/webapp/app/components/welcome/choose-purpose.tsx
index 4f650c3db3..e5b4c13b36 100644
--- a/apps/webapp/app/components/welcome/choose-purpose.tsx
+++ b/apps/webapp/app/components/welcome/choose-purpose.tsx
@@ -110,7 +110,7 @@ const PLAN_DETAILS: Record<
personal: {
title: "Personal",
description:
- "For testing or individual use. Includes 3 custom fields and branded QR labels.",
+ "For one person. You won't be able to invite teammates or use bookings. Includes 3 custom fields and branded QR labels.",
chip: "Free",
helper: "Personal workspaces are free and ready to use immediately.",
analytics: "cta-start-personal",
@@ -119,7 +119,7 @@ const PLAN_DETAILS: Record<
},
team: {
title: "Team",
- description: `For organizations and labs. Includes collaboration features with a ${config.freeTrialDays}-day free trial. No credit card required.`,
+ description: `Invite teammates, assign custody, and manage bookings together. Includes a ${config.freeTrialDays}-day free trial. No credit card required.`,
chip: `${config.freeTrialDays}-day trial`,
badge: "Recommended",
analytics: "cta-next-team",
@@ -128,21 +128,33 @@ const PLAN_DETAILS: Record<
},
};
+/**
+ * Onboarding plan picker (Personal vs Team) shown on `/welcome`.
+ *
+ * @param defaultSelectedPlan - Plan to pre-select on mount, derived from the
+ * user's onboarding "how many people" answer.
+ * @param teamIntent - Set when the user told us more than one person will use
+ * Shelf; used to caution against picking Personal (which can't invite anyone).
+ */
export function ChoosePurpose({
auditPrices,
barcodePrices,
usedAuditTrial,
usedBarcodeTrial,
+ defaultSelectedPlan = null,
+ teamIntent = null,
}: {
auditPrices: AddonPrices;
barcodePrices: AddonPrices;
usedAuditTrial: boolean;
usedBarcodeTrial: boolean;
+ defaultSelectedPlan?: SignupPlan | null;
+ teamIntent?: { teamSize: string } | null;
}) {
- const [state, dispatch] = useReducer(
- choosePurposeReducer,
- INITIAL_CHOOSE_PURPOSE_STATE
- );
+ const [state, dispatch] = useReducer(choosePurposeReducer, {
+ ...INITIAL_CHOOSE_PURPOSE_STATE,
+ selectedPlan: defaultSelectedPlan,
+ });
const {
selectedPlan,
wantsAudits,
@@ -208,7 +220,7 @@ export function ChoosePurpose({
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.
+
+
+
+ ) : null}
+
{showAddonsSection ? (
<>
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 = {
};
export default function TeamSettings() {
- const { isPersonalOrg, orgName } = useLoaderData();
+ const { isPersonalOrg, orgName, upgradeCtaTo, upgradeCtaLabel } =
+ useLoaderData();
const TABS: Item[] = [
...(!isPersonalOrg
@@ -74,6 +106,17 @@ export default function TeamSettings() {
Manage your existing team and give team members custody to certain
assets.
+ {isPersonalOrg ? (
+
+ }
+ headline="Inviting people needs a Team workspace"
+ description="Your workspace is Personal, meant for one person. Create a Team workspace to invite teammates, assign custody, and manage bookings together."
+ ctaLabel={upgradeCtaLabel}
+ ctaTo={upgradeCtaTo}
+ />
+
+ ) : null}
diff --git a/apps/webapp/app/routes/_layout+/settings.team.users.tsx b/apps/webapp/app/routes/_layout+/settings.team.users.tsx
index 9448f01da4..f160744c49 100644
--- a/apps/webapp/app/routes/_layout+/settings.team.users.tsx
+++ b/apps/webapp/app/routes/_layout+/settings.team.users.tsx
@@ -52,9 +52,12 @@ export async function loader({ request, context }: LoaderFunctionArgs) {
action: PermissionAction.read,
});
- /** Cannot manage users for PERSONAL organization */
+ /**
+ * Personal workspaces can't manage registered users. 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 searchParams = getCurrentSearchParams(request);
diff --git a/apps/webapp/app/routes/_welcome+/welcome.tsx b/apps/webapp/app/routes/_welcome+/welcome.tsx
index cf4d19726c..f9b3754f28 100644
--- a/apps/webapp/app/routes/_welcome+/welcome.tsx
+++ b/apps/webapp/app/routes/_welcome+/welcome.tsx
@@ -18,6 +18,7 @@ import {
createBarcodeAddonTrialSubscription,
getBarcodeAddonPrices,
} from "~/modules/barcode/addon.server";
+import { signalsTeamIntent } from "~/modules/onboarding/constants";
import { getOrganizationByUserId } from "~/modules/organization/service.server";
import { getUserByID } from "~/modules/user/service.server";
import { appendToMetaTitle } from "~/utils/append-to-meta-title";
@@ -62,12 +63,25 @@ export async function loader({ context }: LoaderFunctionArgs) {
// Personal org not found yet - that's ok during onboarding
}
+ // Read the onboarding "how many people" answer so we can steer the plan
+ // choice. Only the known multi-person options count: the field is
+ // free-text capable, so answers like "1" must not imply a team.
+ const userWithIntel = await getUserByID(userId, {
+ select: {
+ businessIntel: { select: { teamSize: true } },
+ } satisfies Prisma.UserSelect,
+ });
+ const teamSize = userWithIntel.businessIntel?.teamSize ?? null;
+ const teamIntent =
+ teamSize && signalsTeamIntent(teamSize) ? { teamSize } : null;
+
return data(
payload({
auditPrices,
barcodePrices,
usedAuditTrial,
usedBarcodeTrial,
+ teamIntent,
})
);
} catch (cause) {
@@ -187,6 +201,8 @@ export default function Welcome() {
barcodePrices={loaderData?.barcodePrices ?? { month: null, year: null }}
usedAuditTrial={loaderData?.usedAuditTrial ?? false}
usedBarcodeTrial={loaderData?.usedBarcodeTrial ?? false}
+ teamIntent={loaderData?.teamIntent ?? null}
+ defaultSelectedPlan={loaderData?.teamIntent ? "team" : null}
/>
);
diff --git a/apps/webapp/app/utils/team-upgrade-cta.test.ts b/apps/webapp/app/utils/team-upgrade-cta.test.ts
new file mode 100644
index 0000000000..9d53c8565a
--- /dev/null
+++ b/apps/webapp/app/utils/team-upgrade-cta.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveTeamUpgradeCta } from "./team-upgrade-cta";
+
+describe("resolveTeamUpgradeCta", () => {
+ it("sends Team-entitled users straight to workspace creation", () => {
+ /**
+ * tier_2 and custom already pay for Team, so the only thing missing is the
+ * workspace itself. They must never be sent to billing.
+ */
+ for (const tierId of ["tier_2", "custom"] as const) {
+ for (const usedFreeTrial of [true, false]) {
+ expect(resolveTeamUpgradeCta({ tierId, usedFreeTrial })).toEqual({
+ to: "/account-details/workspace",
+ label: "Create a Team workspace",
+ });
+ }
+ }
+ });
+
+ it("offers the trial to free users who still have one", () => {
+ expect(
+ resolveTeamUpgradeCta({ tierId: "free", usedFreeTrial: false })
+ ).toEqual({
+ to: "/account-details/subscription",
+ label: "Start a Team trial",
+ });
+ });
+
+ it("never offers a second trial once it has been spent", () => {
+ /**
+ * The subscription action throws "You have already used your free trial",
+ * so a trial CTA here would send the user to a dead end.
+ */
+ expect(
+ resolveTeamUpgradeCta({ tierId: "free", usedFreeTrial: true })
+ ).toEqual({
+ to: "/account-details/subscription",
+ label: "Upgrade to Team",
+ });
+ });
+
+ it("treats a paying Plus customer as an upgrade, not a trial", () => {
+ expect(
+ resolveTeamUpgradeCta({ tierId: "tier_1", usedFreeTrial: true })
+ ).toEqual({
+ to: "/account-details/subscription",
+ label: "Upgrade to Team",
+ });
+ });
+
+ it("still offers Plus a trial if they somehow never used one", () => {
+ expect(
+ resolveTeamUpgradeCta({ tierId: "tier_1", usedFreeTrial: false })
+ ).toEqual({
+ to: "/account-details/subscription",
+ label: "Start a Team trial",
+ });
+ });
+});
diff --git a/apps/webapp/app/utils/team-upgrade-cta.ts b/apps/webapp/app/utils/team-upgrade-cta.ts
new file mode 100644
index 0000000000..ce70cc233f
--- /dev/null
+++ b/apps/webapp/app/utils/team-upgrade-cta.ts
@@ -0,0 +1,56 @@
+/**
+ * Team upgrade call-to-action resolution.
+ *
+ * A Personal workspace can never invite registered users, so every Personal
+ * workspace is shown an upgrade path. Which path is correct depends on what the
+ * user already pays for and whether a free trial is still available to them.
+ * Getting this wrong is user-visible: offering a "trial" to someone who already
+ * spent theirs dead-ends, because the subscription action rejects a second one.
+ *
+ * @see {@link file://./../routes/_layout+/settings.team.tsx}
+ * @see {@link file://./../routes/_layout+/account-details.subscription.tsx}
+ */
+import type { TierId } from "@prisma/client";
+
+/** Where the Personal-workspace upgrade CTA should point, and what it says. */
+export type TeamUpgradeCta = {
+ to: string;
+ label: string;
+};
+
+/**
+ * Resolves the upgrade CTA for a user sitting in a Personal workspace.
+ *
+ * - `tier_2` / `custom`: already entitled to a Team workspace, they simply
+ * haven't created one, so send them straight to workspace creation.
+ * - `free` / `tier_1` with an unused trial: starting the trial is the real
+ * action.
+ * - `free` / `tier_1` who already spent the trial: it must read as an upgrade.
+ * Paying Plus customers are almost always here, and telling them to "start a
+ * trial" would be both wrong and a dead end.
+ *
+ * @param args.tierId - The user's subscription tier
+ * @param args.usedFreeTrial - Whether the user has already consumed their trial
+ * @returns The destination and label for the CTA
+ */
+export function resolveTeamUpgradeCta({
+ tierId,
+ usedFreeTrial,
+}: {
+ tierId: TierId;
+ usedFreeTrial: boolean;
+}): TeamUpgradeCta {
+ const needsPlanChange = tierId === "free" || tierId === "tier_1";
+
+ if (!needsPlanChange) {
+ return {
+ to: "/account-details/workspace",
+ label: "Create a Team workspace",
+ };
+ }
+
+ return {
+ to: "/account-details/subscription",
+ label: usedFreeTrial ? "Upgrade to Team" : "Start a Team trial",
+ };
+}
From d55e5d92d6e518b8efdea5ecfc361f0520927f11 Mon Sep 17 00:00:00 2001
From: Carlos Virreira
Date: Tue, 21 Jul 2026 13:59:22 +0200
Subject: [PATCH 2/2] fix(workspace): stop claiming a plan in the sidebar
The plan marker read the workspace type, not what the user pays. Tier lives on the
user (User.tierId) while add-ons live on the organization (auditsEnabled,
barcodesEnabled), and the personal-with-addons onboarding path enables an add-on on a
Personal workspace without ever changing tierId. A free-tier customer paying for
Barcodes would therefore have been labelled "Free", and no single label can be correct
across every tier and add-on combination.
Remove the marker rather than try to compute one. The locked Users and Pending invites
items are already an always-accurate standing signal and make no billing claim.
Also neutralise the switcher action to "Invite your team". "Upgrade" was wrong for a
Team-tier user sitting in their Personal workspace, who needs to create a workspace
rather than change plan.
Entitlement is now claimed in exactly one place, resolveTeamUpgradeCta, which answers
only whether the user can create a Team workspace. That is driven by
TierLimit.maxOrganizations and is unaffected by add-ons.
---
.../layout/sidebar/organization-selector.tsx | 37 +++++--------------
apps/webapp/app/utils/team-upgrade-cta.ts | 8 ++++
2 files changed, 17 insertions(+), 28 deletions(-)
diff --git a/apps/webapp/app/components/layout/sidebar/organization-selector.tsx b/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
index daa8422186..39da479667 100644
--- a/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
+++ b/apps/webapp/app/components/layout/sidebar/organization-selector.tsx
@@ -24,34 +24,14 @@ import {
useSidebar,
} from "./sidebar";
-/**
- * Plan marker shown beside a Personal workspace name.
- *
- * Personal workspaces cannot invite anyone regardless of plan, so the marker
- * states the plan the user actually pays for rather than assuming "free" from
- * the workspace type. Team-tier users get no marker: they already pay for Team,
- * and badging their Personal workspace would misrepresent what it is.
- *
- * @param tierId - The user's subscription tier
- * @returns The label to render, or null when no marker should be shown
- */
-function getPersonalPlanLabel(tierId: string | null | undefined) {
- if (tierId === "free") return "Free";
- if (tierId === "tier_1") return "Plus";
- return null;
-}
-
export default function OrganizationSelector() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { open, openMobile, isMobile } = useSidebar();
- const { organizations, currentOrganizationId, user } =
+ const { organizations, currentOrganizationId } =
useLoaderData();
- /** Plan marker for a Personal workspace; null when none should show. */
- const personalPlanLabel = getPersonalPlanLabel(user?.tierId);
-
const fetcher = useFetcher();
const isSwitchingOrg = isFormProcessing(fetcher.state);
@@ -109,12 +89,6 @@ export default function OrganizationSelector() {
{currentOrganization.name}
- {currentOrganization.type === "PERSONAL" &&
- personalPlanLabel ? (
-
- {personalPlanLabel}
-
- ) : null}
>
@@ -170,6 +144,13 @@ 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/utils/team-upgrade-cta.ts b/apps/webapp/app/utils/team-upgrade-cta.ts
index ce70cc233f..0d0292f7c7 100644
--- a/apps/webapp/app/utils/team-upgrade-cta.ts
+++ b/apps/webapp/app/utils/team-upgrade-cta.ts
@@ -7,6 +7,14 @@
* Getting this wrong is user-visible: offering a "trial" to someone who already
* spent theirs dead-ends, because the subscription action rejects a second one.
*
+ * Deliberately NOT considered here: the paid add-ons (`Organization.auditsEnabled`,
+ * `Organization.barcodesEnabled`). Those live on the organization and can be active
+ * on a Personal workspace while the user is still on the free tier, so they make
+ * someone a paying customer without changing what this resolves. They do not affect
+ * entitlement to a Team workspace, which is driven purely by the tier's
+ * `TierLimit.maxOrganizations`. This is also why nothing outside this function
+ * should try to render "the plan" as a single label: there isn't one.
+ *
* @see {@link file://./../routes/_layout+/settings.team.tsx}
* @see {@link file://./../routes/_layout+/account-details.subscription.tsx}
*/