Skip to content

Commit 712a1b1

Browse files
Add team seat mismatch UX
1 parent 2e0035b commit 712a1b1

8 files changed

Lines changed: 303 additions & 50 deletions

File tree

frontend/src/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NotificationProvider } from "./contexts/NotificationContext";
1515
import { ThemeProvider } from "./contexts/ThemeContext";
1616
import { ProxyEventListener } from "./components/ProxyEventListener";
1717
import { UpdateEventListener } from "./components/UpdateEventListener";
18+
import { TeamSeatMismatchAlert } from "./components/team/TeamSeatMismatchAlert";
1819
import { TTSProvider } from "./services/tts/TTSContext";
1920

2021
const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6";
@@ -108,6 +109,7 @@ export default function App() {
108109
<ProxyEventListener />
109110
<UpdateEventListener />
110111
<DeepLinkHandler />
112+
<TeamSeatMismatchAlert />
111113
<InnerApp />
112114
</BillingServiceProvider>
113115
</TTSProvider>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getBillingService } from "@/billing/billingService";
2+
import { openExternalUrl } from "@/utils/openUrl";
3+
4+
export async function openBillingPortal(): Promise<void> {
5+
const billingService = getBillingService();
6+
const url = await billingService.getPortalUrl();
7+
await openExternalUrl(url);
8+
}

frontend/src/components/AccountMenu.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type { TeamStatus } from "@/types/team";
5151
import { Alert, AlertDescription } from "@/components/ui/alert";
5252
import { TeamManagementDialog } from "@/components/team/TeamManagementDialog";
5353
import { ApiKeyManagementDialog } from "@/components/apikeys/ApiKeyManagementDialog";
54+
import { getTeamSeatMismatch } from "@/utils/teamSeats";
5455
import packageJson from "../../package.json";
5556
import { SIDEBAR_ACCOUNT_MENU_WIDTH_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout";
5657

@@ -114,7 +115,7 @@ export function AccountMenu() {
114115
const [showAboutMenu, setShowAboutMenu] = useState(false);
115116
const [portalError, setPortalError] = useState<string | null>(null);
116117

117-
const hasStripeAccount = billingStatus?.stripe_customer_id !== null;
118+
const hasStripeAccount = !!billingStatus?.stripe_customer_id;
118119
const productName = billingStatus?.product_name || "";
119120
const isPro = productName.toLowerCase().includes("pro");
120121
const isMax = productName.toLowerCase().includes("max");
@@ -157,6 +158,8 @@ export function AccountMenu() {
157158
// Show alert badge if user has team plan but hasn't created team yet
158159
const showTeamSetupAlert =
159160
isTeamPlan && teamStatus?.has_team_subscription && !teamStatus?.team_created;
161+
const teamSeatMismatch = getTeamSeatMismatch(teamStatus);
162+
const showTeamAttentionAlert = showTeamSetupAlert || !!teamSeatMismatch;
160163

161164
// Determine if API Management should be shown
162165
// On desktop/web/Android: always show
@@ -301,8 +304,12 @@ export function AccountMenu() {
301304
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[hsl(var(--sidebar-chrome))] text-[hsl(var(--on-sidebar-chrome))] shadow-none ring-0 transition-colors hover:bg-[hsl(var(--sidebar-chrome-hover))]"
302305
>
303306
<User className="h-4 w-4" />
304-
{showTeamSetupAlert && (
305-
<AlertCircle className="absolute -right-1 -top-1 h-4 w-4 rounded-full bg-background text-maple-warning" />
307+
{showTeamAttentionAlert && (
308+
<AlertCircle
309+
className={`absolute -right-1 -top-1 h-4 w-4 rounded-full bg-background ${
310+
teamSeatMismatch ? "text-destructive" : "text-maple-warning"
311+
}`}
312+
/>
306313
)}
307314
</button>
308315
</DropdownMenuTrigger>
@@ -362,14 +369,21 @@ export function AccountMenu() {
362369
<Users className="mr-2 h-4 w-4" />
363370
<span>Manage Team</span>
364371
</div>
365-
{showTeamSetupAlert && (
372+
{teamSeatMismatch ? (
373+
<Badge
374+
variant="secondary"
375+
className="bg-destructive py-0 px-1.5 text-xs font-medium text-destructive-onFilled"
376+
>
377+
Paused
378+
</Badge>
379+
) : showTeamSetupAlert ? (
366380
<Badge
367381
variant="secondary"
368382
className="bg-maple-warning py-0 px-1.5 text-xs font-medium text-maple-onWarning"
369383
>
370384
Setup Required
371385
</Badge>
372-
)}
386+
) : null}
373387
</div>
374388
</DropdownMenuItem>
375389
)}

frontend/src/components/team/TeamDashboard.tsx

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1-
import { useState } from "react";
1+
import { useRef, useState } from "react";
22
import { DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
33
import { Button } from "@/components/ui/button";
44
import { Badge } from "@/components/ui/badge";
55
import { Input } from "@/components/ui/input";
6-
import { UserPlus, AlertTriangle, Crown, User, Pencil, Check, X, Loader2 } from "lucide-react";
6+
import {
7+
UserPlus,
8+
AlertTriangle,
9+
Crown,
10+
User,
11+
Pencil,
12+
Check,
13+
X,
14+
Loader2,
15+
CreditCard,
16+
Users
17+
} from "lucide-react";
718
import { TeamInviteDialog } from "./TeamInviteDialog";
819
import { TeamMembersList } from "./TeamMembersList";
920
import { getBillingService } from "@/billing/billingService";
21+
import { openBillingPortal } from "@/billing/billingPortal";
22+
import { useLocalState } from "@/state/useLocalState";
23+
import {
24+
formatTeamSeatMismatchMessage,
25+
getTeamSeatCounts,
26+
getTeamSeatMismatch
27+
} from "@/utils/teamSeats";
1028
import { useQueryClient } from "@tanstack/react-query";
1129
import type { TeamStatus } from "@/types/team";
1230

@@ -20,7 +38,11 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
2038
const [editedName, setEditedName] = useState("");
2139
const [isSavingName, setIsSavingName] = useState(false);
2240
const [nameError, setNameError] = useState<string | null>(null);
41+
const [isPortalLoading, setIsPortalLoading] = useState(false);
42+
const [portalError, setPortalError] = useState<string | null>(null);
43+
const membersSectionRef = useRef<HTMLDivElement>(null);
2344
const queryClient = useQueryClient();
45+
const { billingStatus } = useLocalState();
2446

2547
if (!teamStatus) {
2648
return (
@@ -33,10 +55,15 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
3355
);
3456
}
3557

36-
const seatsUsed = teamStatus.seats_used || 0;
37-
const seatsPurchased = teamStatus.seats_purchased || 0;
58+
const seatCounts = getTeamSeatCounts(teamStatus);
59+
const seatMismatch = getTeamSeatMismatch(teamStatus);
60+
const seatsUsed = seatCounts.memberCount ?? 0;
61+
const seatsPurchased = seatCounts.billedSeatCount ?? 0;
3862
const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true;
3963
const seatUsagePercentage = seatsPurchased > 0 ? (seatsUsed / seatsPurchased) * 100 : 0;
64+
const canOpenBillingPortal = billingStatus
65+
? !!billingStatus.stripe_customer_id
66+
: !!teamStatus.has_team_subscription;
4067

4168
const handleStartEdit = () => {
4269
setEditedName(teamStatus.team_name || "");
@@ -90,6 +117,29 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
90117
}
91118
};
92119

120+
const handleOpenBilling = async () => {
121+
if (!canOpenBillingPortal) return;
122+
123+
try {
124+
setPortalError(null);
125+
setIsPortalLoading(true);
126+
await openBillingPortal();
127+
await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
128+
await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
129+
} catch (error) {
130+
console.error("Failed to open billing portal:", error);
131+
setPortalError(
132+
"Unable to open subscription management. Please try again or contact support@trymaple.ai."
133+
);
134+
} finally {
135+
setIsPortalLoading(false);
136+
}
137+
};
138+
139+
const handleReviewMembers = () => {
140+
membersSectionRef.current?.scrollIntoView({ block: "nearest" });
141+
};
142+
93143
// Simplified view for non-admin members
94144
if (!isAdmin) {
95145
return (
@@ -99,6 +149,20 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
99149
</DialogHeader>
100150

101151
<div className="mt-3 space-y-3 overflow-hidden">
152+
{seatMismatch && (
153+
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-destructive">
154+
<div className="flex items-start gap-2">
155+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
156+
<div className="space-y-1">
157+
<p className="text-sm font-medium">Team usage is paused</p>
158+
<p className="text-xs leading-relaxed">
159+
{formatTeamSeatMismatchMessage(seatMismatch, "member")}
160+
</p>
161+
</div>
162+
</div>
163+
</div>
164+
)}
165+
102166
{/* Compact team info */}
103167
<div className="bg-muted/50 rounded-lg p-3">
104168
<div className="flex items-center justify-between gap-2">
@@ -137,11 +201,45 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
137201
</DialogHeader>
138202

139203
<div className="mt-3 space-y-3 overflow-hidden">
140-
{/* Seat limit exceeded warning */}
141-
{teamStatus.seat_limit_exceeded && (
142-
<div className="rounded-md bg-destructive/10 p-2.5 text-destructive text-xs flex items-start gap-2">
143-
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
144-
<span>Seat limit exceeded. Remove members or purchase additional seats.</span>
204+
{seatMismatch && (
205+
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-destructive">
206+
<div className="flex items-start gap-2">
207+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
208+
<div className="min-w-0 flex-1 space-y-2">
209+
<div>
210+
<p className="text-sm font-medium">Team usage is paused</p>
211+
<p className="mt-1 text-xs leading-relaxed">
212+
{formatTeamSeatMismatchMessage(seatMismatch, "admin")}
213+
</p>
214+
</div>
215+
<div className="flex flex-col gap-2 sm:flex-row">
216+
<Button
217+
type="button"
218+
variant="outline"
219+
size="sm"
220+
className="h-8 border-destructive/30 text-destructive hover:text-destructive"
221+
onClick={handleReviewMembers}
222+
>
223+
<Users className="mr-1.5 h-3.5 w-3.5" />
224+
Manage Members
225+
</Button>
226+
{canOpenBillingPortal && (
227+
<Button
228+
type="button"
229+
variant="outline"
230+
size="sm"
231+
className="h-8 border-destructive/30 text-destructive hover:text-destructive"
232+
onClick={handleOpenBilling}
233+
disabled={isPortalLoading}
234+
>
235+
<CreditCard className="mr-1.5 h-3.5 w-3.5" />
236+
{isPortalLoading ? "Opening..." : "Add Seats"}
237+
</Button>
238+
)}
239+
</div>
240+
{portalError && <p className="text-xs leading-relaxed">{portalError}</p>}
241+
</div>
242+
</div>
145243
</div>
146244
)}
147245

@@ -233,7 +331,11 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
233331
</div>
234332
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
235333
<div
236-
className="h-full transition-all bg-emerald-500"
334+
className={
335+
seatMismatch
336+
? "h-full bg-destructive transition-all"
337+
: "h-full bg-emerald-500 transition-all"
338+
}
237339
style={{
238340
width: `${Math.min(seatUsagePercentage, 100)}%`
239341
}}
@@ -246,7 +348,7 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
246348
{isAdmin && (
247349
<Button
248350
onClick={() => setIsInviteDialogOpen(true)}
249-
disabled={teamStatus.seat_limit_exceeded}
351+
disabled={!!seatMismatch}
250352
size="sm"
251353
className="w-full"
252354
>
@@ -256,7 +358,9 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
256358
)}
257359

258360
{/* Members list */}
259-
<TeamMembersList teamStatus={teamStatus} />
361+
<div ref={membersSectionRef}>
362+
<TeamMembersList teamStatus={teamStatus} />
363+
</div>
260364
</div>
261365

262366
{/* Invite dialog */}

frontend/src/components/team/TeamInviteDialog.tsx

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import { Textarea } from "@/components/ui/textarea";
1414
import { Alert, AlertDescription } from "@/components/ui/alert";
1515
import { Loader2, AlertCircle, UserPlus, Info, CreditCard } from "lucide-react";
1616
import { getBillingService } from "@/billing/billingService";
17+
import { openBillingPortal } from "@/billing/billingPortal";
1718
import { useLocalState } from "@/state/useLocalState";
18-
import { isTauri } from "@/utils/platform";
1919
import type { TeamStatus } from "@/types/team";
2020

2121
interface TeamInviteDialogProps {
@@ -32,43 +32,20 @@ export function TeamInviteDialog({ open, onOpenChange, teamStatus }: TeamInviteD
3232
const [isPortalLoading, setIsPortalLoading] = useState(false);
3333
const queryClient = useQueryClient();
3434
const { billingStatus } = useLocalState();
35-
const seatsAvailable = teamStatus?.seats_available || 0;
36-
const hasStripeAccount = billingStatus?.stripe_customer_id !== null;
35+
const seatsAvailable = Math.max(0, teamStatus?.seats_available || 0);
36+
const canOpenBillingPortal = billingStatus
37+
? !!billingStatus.stripe_customer_id
38+
: !!teamStatus?.has_team_subscription;
3739

3840
const handleManageSubscription = async () => {
39-
if (!hasStripeAccount) return;
41+
if (!canOpenBillingPortal) return;
4042

4143
try {
4244
setError(null);
4345
setIsPortalLoading(true);
44-
const billingService = getBillingService();
45-
const url = await billingService.getPortalUrl();
46-
47-
// Use external browser for all Tauri platforms (mobile and desktop)
48-
if (isTauri()) {
49-
try {
50-
// Dynamic import to avoid issues in web environments
51-
const { invoke } = await import("@tauri-apps/api/core");
52-
await invoke("plugin:opener|open_url", { url })
53-
.then(() => {
54-
console.log("[TeamInvite] Successfully opened URL in external browser");
55-
})
56-
.catch((error: Error) => {
57-
console.error("[TeamInvite] Failed to open external browser:", error);
58-
// Fall back to window.open if opener plugin fails
59-
window.open(url, "_blank", "noopener,noreferrer");
60-
});
61-
return;
62-
} catch (importError) {
63-
console.error("[TeamInvite] Failed to import Tauri APIs:", importError);
64-
// Fall back to web approach if dynamic import fails
65-
window.open(url, "_blank", "noopener,noreferrer");
66-
return;
67-
}
68-
}
69-
70-
// Web flow
71-
window.open(url, "_blank", "noopener,noreferrer");
46+
await openBillingPortal();
47+
await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
48+
await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
7249
} catch (error) {
7350
console.error("Failed to open billing portal:", error);
7451
setError(
@@ -207,7 +184,7 @@ or comma-separated: john@example.com, jane@example.com`}
207184
before inviting new ones.
208185
</AlertDescription>
209186
</Alert>
210-
{hasStripeAccount && (
187+
{canOpenBillingPortal && (
211188
<Button
212189
variant="outline"
213190
size="sm"

0 commit comments

Comments
 (0)