Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ export default function OrganizationSelector() {
>
Manage workspaces
</Button>
{/**
* 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" ? (
<Button
to="/settings/team"
variant="link"
className="w-full select-none justify-start rounded p-2 text-left font-medium text-primary-700 outline-none hover:bg-gray-50"
onClick={closeDropdown}
>
Invite your team
</Button>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
Expand Down
23 changes: 23 additions & 0 deletions apps/webapp/app/components/layout/sidebar/parent-nav-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SidebarMenuSubItem key={nested.title}>
<SidebarMenuSubButton asChild>
<span
title={typeof reason === "string" ? reason : undefined}
className="cursor-not-allowed font-medium !text-gray-400"
>
{nested.title}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
}

return (
<SidebarMenuSubItem key={nested.title}>
<SidebarMenuSubButton onClick={closeIfMobile} asChild>
Expand Down
44 changes: 37 additions & 7 deletions apps/webapp/app/components/welcome/choose-purpose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -208,7 +220,7 @@ export function ChoosePurpose({
</h3>
<p className="mt-3 text-base text-gray-600">
Your choice determines which features we prepare for you. You can
always switch later.
upgrade to Team later from your workspace settings.
</p>
<p className="mt-4 rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600">
If your organization already uses Shelf, you don't need to create a
Expand Down Expand Up @@ -246,6 +258,24 @@ export function ChoosePurpose({
</p>
) : null}

{teamIntent && selectedPlan === "personal" ? (
<div className="mt-3 flex w-full flex-col gap-2 rounded-lg border border-orange-200 bg-orange-50 p-3 text-left sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-orange-800">
You told us your team has {teamIntent.teamSize}. Personal
workspaces are for one person and can't invite anyone.
</p>
<Button
type="button"
variant="secondary"
size="sm"
className="whitespace-nowrap"
onClick={() => dispatch({ type: "select_plan", plan: "team" })}
>
Switch to Team
</Button>
</div>
) : null}

{showAddonsSection ? (
<>
<h4 className="mt-6 w-full text-left font-semibold text-gray-700">
Expand Down
17 changes: 15 additions & 2 deletions apps/webapp/app/hooks/use-sidebar-nav-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions apps/webapp/app/modules/onboarding/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 24 additions & 0 deletions apps/webapp/app/modules/onboarding/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
7 changes: 5 additions & 2 deletions apps/webapp/app/routes/_layout+/settings.team.invites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down
4 changes: 2 additions & 2 deletions apps/webapp/app/routes/_layout+/settings.team.nrm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}}
Expand Down
47 changes: 45 additions & 2 deletions apps/webapp/app/routes/_layout+/settings.team.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"
Expand All @@ -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);
Expand All @@ -49,7 +80,8 @@ export const organizationRolesMap: Record<string, UserFriendlyRoles> = {
};

export default function TeamSettings() {
const { isPersonalOrg, orgName } = useLoaderData<typeof loader>();
const { isPersonalOrg, orgName, upgradeCtaTo, upgradeCtaLabel } =
useLoaderData<typeof loader>();

const TABS: Item[] = [
...(!isPersonalOrg
Expand All @@ -74,6 +106,17 @@ export default function TeamSettings() {
Manage your existing team and give team members custody to certain
assets.
</p>
{isPersonalOrg ? (
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 py-8">
<PremiumFeatureTeaser
icon={<UsersIcon className="size-5" />}
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}
/>
</div>
) : null}
<HorizontalTabs items={TABS} />
<Outlet />
</div>
Expand Down
7 changes: 5 additions & 2 deletions apps/webapp/app/routes/_layout+/settings.team.users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading