From ab911bd9dd7097ced5bfe830bd4ca941bba1fd16 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 23 May 2026 10:38:36 +0000 Subject: [PATCH] fix(web): /select-org informative rows + same dialog + UserNav hover match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from the user pass: 1. /select-org workspace rows showed name + id-substring + "Open →". The id slice was opaque filler. Replaced with role (uppercase tracked), plan (when present), and a relative "joined Nd ago" timestamp. The currently-active workspace gets a sky-tinted row + "Current" pill + "Resume →" caption so it's obvious where you are when you opened the manager. 2. /select-org's inline "Create workspace" form replaced with a single dashed-border "New workspace" button that opens the same NewWorkspaceDialog the OrgSwitcher uses. The two entry points now share one component — no more "manage workspaces" leading to an input that did the same thing the OrgSwitcher dialog did, just less polished. First-time empty state (no orgs, no invites) becomes a focused single-CTA card: small workspace icon + "Create your first workspace" + a slate-900 button + a hint about invitations appearing here once sent. 3. UserNav hover background was bg-white/70 — barely visible on the cream sidebar. Matches the nav rows' bg-slate-200/40 now so the bottom user row reads as part of the same nav strip instead of a separate widget. --- web/src/app/select-org/page.tsx | 363 ++++++++++++++------------ web/src/components/layout/UserNav.tsx | 2 +- 2 files changed, 203 insertions(+), 162 deletions(-) diff --git a/web/src/app/select-org/page.tsx b/web/src/app/select-org/page.tsx index 4df03d24..95846ffa 100644 --- a/web/src/app/select-org/page.tsx +++ b/web/src/app/select-org/page.tsx @@ -1,15 +1,19 @@ -// Select / create organization gate. +// Select / create / manage organizations. // -// Shown when a logged-in user has no current organization. Three -// affordances on one page: +// Three responsibilities on one screen: // -// 1. Pending invitations — accept-to-join (one-click). -// 2. Existing memberships — pick one to enter the dashboard. -// 3. Create new — name + slate-900 button, mints an org and drops -// the user straight into it. +// 1. Pending invitations — accept-to-join. +// 2. Existing memberships — pick one to enter; each row shows +// role + plan + age so the list is actually informative, not +// a "click random row" gamble. +// 3. Create new — single slate-900 button that opens the same +// NewWorkspaceDialog the OrgSwitcher uses. No inline form +// anymore (was redundant with the dialog and made the two +// entry points behave differently). // -// The router redirects here from /app whenever organizations.length -// is 0 and there's no currentOrganization set. +// The router redirects here from /app whenever the user has no +// current organization, so this page must handle both "first +// landing, no orgs" and "ongoing management" cases. import React from "react"; import { useNavigate } from "react-router-dom"; @@ -17,63 +21,55 @@ import { Loader2Icon, MailIcon, PlusIcon, UsersIcon } from "lucide-react"; import toast from "react-hot-toast"; import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations"; import useMyInvitations from "@/lib/api/hooks/app/organizations/useMyInvitations"; -import useCreateOrganization from "@/lib/api/hooks/app/organizations/useCreateOrganization"; import useAcceptInvitation from "@/lib/api/hooks/app/organizations/useAcceptInvitation"; import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization"; import { useAppStore } from "@/stores"; import { Logo } from "@/components/svg"; -import { Label, TextInput } from "@/components/ui/field"; +import { NewWorkspaceDialog } from "@/components/app/organizations/NewWorkspaceDialog"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +function relativeAge(d: Date | string): string { + const date = typeof d === "string" ? new Date(d) : d; + const diff = Date.now() - date.getTime(); + const days = Math.floor(diff / 86_400_000); + if (days < 1) return "today"; + if (days < 7) return `${days}d ago`; + if (days < 30) return `${Math.floor(days / 7)}w ago`; + if (days < 365) return `${Math.floor(days / 30)}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} + +function initials(name: string): string { + return name + .split(" ") + .filter(Boolean) + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + export default function SelectOrgPage() { const navigate = useNavigate(); - const newOrgRequested = - typeof window !== "undefined" && - new URLSearchParams(window.location.search).get("new") === "1"; + const [createOpen, setCreateOpen] = React.useState(false); const orgs = useOrganizations(); const invites = useMyInvitations(); const setOrganizations = useAppStore((s) => s.setOrganizations); const setCurrentOrganization = useAppStore((s) => s.setCurrentOrganization); + const currentOrg = useAppStore((s) => s.currentOrganization); - const create = useCreateOrganization(); const accept = useAcceptInvitation(); const switchOrg = useSwitchOrganization(); const orgList = orgs.data ?? []; const inviteList = invites.data ?? []; - // Cache invalidation in the hooks already refreshes the query, but - // we also seed the appStore so the rest of the dashboard sees the - // org immediately on navigation. React.useEffect(() => { if (orgList.length > 0) setOrganizations(orgList); }, [orgList, setOrganizations]); - const [name, setName] = React.useState(""); - - async function onCreate() { - const t = name.trim(); - if (t.length < 2) { - toast.error("Name is required"); - return; - } - try { - const org = await toast.promise(create.mutateAsync({ name: t }), { - loading: "Creating workspace…", - success: "Workspace created", - error: (e: AppError) => buildError(e), - }); - // Activate the newly created org and enter the dashboard. - await switchOrg.mutateAsync(org.id).catch(() => undefined); - setCurrentOrganization(org); - navigate("/app/emails", { replace: true }); - } catch { - /* surfaced */ - } - } - async function onAccept(invitationId: string) { try { await toast.promise(accept.mutateAsync({ invitation_id: invitationId }), { @@ -81,7 +77,6 @@ export default function SelectOrgPage() { success: "Joined", error: (e: AppError) => buildError(e), }); - // Re-load orgs after acceptance and route into the first one. const fresh = await orgs.refetch(); const list = fresh.data ?? []; if (list.length > 0) { @@ -107,6 +102,7 @@ export default function SelectOrgPage() { } const loading = orgs.isPending || invites.isPending; + const noWorkspaces = !loading && orgList.length === 0 && inviteList.length === 0; return (
@@ -126,136 +122,181 @@ export default function SelectOrgPage() {
-

- Pick a workspace to continue -

-

- Workspaces hold your campaigns, mailboxes and team. Create one to start, - or join an existing one if you were invited. -

- {loading && (
)} - {/* Pending invitations */} - {!loading && inviteList.length > 0 && ( -
-
- - Invitations ({inviteList.length}) -
-
- {inviteList.map((inv) => ( -
-
- {(inv.organization_name ?? "?").slice(0, 2).toUpperCase()} -
-
-
- {inv.organization_name ?? "Workspace"} -
-
- Pending invitation · {inv.role} -
-
- -
- ))} -
-
+ {!loading && noWorkspaces && ( + setCreateOpen(true)} /> )} - {/* Existing memberships */} - {!loading && orgList.length > 0 && ( -
-
- - Your workspaces ({orgList.length}) -
-
- {orgList.map((o) => ( - - ))} -
-
- )} - - {/* Create new */} -
-
- - - {newOrgRequested || orgList.length === 0 ? "Create your first workspace" : "Create another workspace"} - -
-
-
- - { - if (e.key === "Enter") onCreate(); - }} - /> -
-

- You'll be the owner. You can rename it later. + {!loading && !noWorkspaces && ( + <> +

+ Pick a workspace +

+

+ Workspaces hold your campaigns, mailboxes and team.

-
- -
-
-
+ + {/* Pending invitations */} + {inviteList.length > 0 && ( +
+
+ + Invitations + + {inviteList.length} + +
+
+ {inviteList.map((inv) => ( +
+
+ {initials(inv.organization_name ?? "?")} +
+
+
+ {inv.organization_name ?? "Workspace"} +
+
+ Pending · {inv.role} +
+
+ +
+ ))} +
+
+ )} + + {/* Existing memberships — informative rows. Role + + plan + how long ago you joined replace the + opaque "id slice + Open →" filler. */} + {orgList.length > 0 && ( +
+
+ + Your workspaces + {orgList.length} +
+
+ {orgList.map((o) => { + const isCurrent = currentOrg?.id === o.id; + return ( + + ); + })} +
+
+ )} + + {/* Create new — single action button. Same dialog + the OrgSwitcher uses; no inline form anymore. */} + + + )}
+ + setCreateOpen(false)} /> + + ); +} + +// Cleaner empty state for first-time users: single CTA, no list +// scaffolding for empty arrays, and a hint about invites. +function EmptyFirstRun({ onCreate }: { onCreate: () => void }) { + return ( +
+
+ +
+

+ Create your first workspace +

+

+ Workspaces hold your campaigns, mailboxes and team. You'll be the + owner and can invite teammates anytime. +

+ +

+ Already invited? The invitation will appear here once it's sent. +

); } diff --git a/web/src/components/layout/UserNav.tsx b/web/src/components/layout/UserNav.tsx index 057b13ac..9d2dd42f 100644 --- a/web/src/components/layout/UserNav.tsx +++ b/web/src/components/layout/UserNav.tsx @@ -41,7 +41,7 @@ export function UserNav() { return ( -