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 ( + + + + {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({

) : null} + {teamIntent && selectedPlan === "personal" ? ( +
+

+ 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..0d0292f7c7 --- /dev/null +++ b/apps/webapp/app/utils/team-upgrade-cta.ts @@ -0,0 +1,64 @@ +/** + * 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. + * + * 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} + */ +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", + }; +}