diff --git a/.repo_ignore b/.repo_ignore new file mode 100644 index 000000000..9e4a6603d --- /dev/null +++ b/.repo_ignore @@ -0,0 +1,111 @@ +# Global ignore defaults +**/node_modules/ +**/.npm/ +**/__pycache__/ +**/.pytest_cache/ +**/.mypy_cache/ + +# Build caches +**/.gradle/ +**/.nuget/ +**/.cargo/ +**/.stack-work/ +**/.ccache/ + +# IDE and Editor caches +**/.idea/ +**/.vscode/ +**/*.swp +**/*~ + +# Temp files +**/*.tmp +**/*.temp +**/*.bak + +**/*.meta +**/package-lock.json + + +# Dependencies +node_modules/ +frontend/node_modules/ + +# Build outputs +/frontend/dist/ +/frontend/build/ + +# Environment variables +.env +.env.local +.env.*.local +*.key + +# IDE files +.vscode/ +.idea/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Operating system files +.DS_Store +Thumbs.db + +# JavaScript +package-lock.json + +# tests +/tests/test-results/ +/tests/playwright-report/ +/tests/blob-report/ +/tests/playwright/.cache/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyc + +# BrowserUse test artifacts +/tests/browseruse/logs/ +/tests/browseruse/*.json +/tests/browseruse/*.png +/tests/browseruse/test_summary.txt + + +# Logs +frontend/logs +frontend/*.log +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* +frontend/pnpm-debug.log* +frontend/lerna-debug.log* + +frontend/dist +frontend/dist-ssr +frontend/*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.million + +# Build caching apparently? +*.tsbuildinfo + +**/.claude/settings.local.json + +frontend/src-tauri +.venv/ diff --git a/frontend/src/billing/billingApi.ts b/frontend/src/billing/billingApi.ts index 613c93c92..12b1c0760 100644 --- a/frontend/src/billing/billingApi.ts +++ b/frontend/src/billing/billingApi.ts @@ -295,31 +295,307 @@ export async function createZapriteCheckoutSession( window.location.href = checkout_url; } -export async function fetchTeamPlanAvailable(thirdPartyToken: string): Promise { - try { - const response = await fetch( - `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/team_plan_available`, - { - headers: { - Authorization: `Bearer ${thirdPartyToken}`, - "Content-Type": "application/json" - } +// Team Management API Functions +import type { + TeamStatus, + CreateTeamRequest, + CreateTeamResponse, + InviteMembersRequest, + InviteMembersResponse, + TeamMembersResponse, + CheckInviteResponse, + AcceptInviteRequest, + UpdateTeamNameResponse +} from "@/types/team"; + +export async function fetchTeamStatus(thirdPartyToken: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/status`, + { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" } - ); + } + ); - if (!response.ok) { - const errorText = await response.text(); - console.error("Team plan availability error response:", errorText); - if (response.status === 401) { - throw new Error("Unauthorized"); + if (!response.ok) { + const errorText = await response.text(); + console.error("Team status error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to fetch team status: ${errorText}`); + } + + return response.json(); +} + +export async function createTeam( + thirdPartyToken: string, + data: CreateTeamRequest +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/create`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Create team error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to create team: ${errorText}`); + } + + return response.json(); +} + +export async function inviteTeamMembers( + thirdPartyToken: string, + data: InviteMembersRequest +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Invite members error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 400) { + // Parse error message for user-friendly display + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.message || errorText); + } catch (parseError) { + console.error("Failed to parse error response:", parseError); + throw new Error(errorText); } - throw new Error(`Failed to check team plan availability: ${errorText}`); } + throw new Error(`Failed to invite members: ${errorText}`); + } - const { available } = await response.json(); - return available; - } catch (error) { - console.error("Error checking team plan availability:", error); - throw error; + return response.json(); +} + +export async function fetchTeamMembers(thirdPartyToken: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/members`, + { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Fetch team members error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to fetch team members: ${errorText}`); + } + + return response.json(); +} + +export async function checkTeamInvite( + thirdPartyToken: string, + inviteId: string +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites/${inviteId}/check`, + { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Check invite error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to check invite: ${errorText}`); } + + return response.json(); +} + +export async function acceptTeamInvite( + thirdPartyToken: string, + inviteId: string, + data: AcceptInviteRequest +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites/${inviteId}/accept`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Accept invite error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + + // Try to parse JSON error response for any status code + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.error || errorData.message || errorText); + } catch (parseError) { + console.error("Failed to parse error response:", parseError); + // If not JSON, use the text as-is + throw new Error(errorText || "Failed to accept invitation"); + } + } + + return response.json(); +} + +export async function removeTeamMember(thirdPartyToken: string, userId: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/members/${userId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Remove member error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 403) { + throw new Error("Only team admins can remove members"); + } + throw new Error(`Failed to remove member: ${errorText}`); + } +} + +export async function leaveTeam(thirdPartyToken: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/leave`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Leave team error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to leave team: ${errorText}`); + } +} + +export async function revokeTeamInvite(thirdPartyToken: string, inviteId: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites/${inviteId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Revoke invite error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 403) { + throw new Error("Only team admins can revoke invites"); + } + throw new Error(`Failed to revoke invite: ${errorText}`); + } +} + +export async function updateTeamName( + thirdPartyToken: string, + name: string +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/update`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ name: name.trim() }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Update team name error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 400) { + // Parse error message for user-friendly display + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.message || errorText); + } catch (parseError) { + console.error("Failed to parse error response:", parseError); + if (errorText.includes("team admin")) { + throw new Error("You are not a team admin"); + } + if (errorText.includes("between 1 and 100 characters")) { + throw new Error("Team name must be between 1 and 100 characters"); + } + throw new Error(errorText); + } + } + throw new Error(`Failed to update team name: ${errorText}`); + } + + return response.json(); } diff --git a/frontend/src/billing/billingService.ts b/frontend/src/billing/billingService.ts index c0c459a20..bfa85f2fb 100644 --- a/frontend/src/billing/billingService.ts +++ b/frontend/src/billing/billingService.ts @@ -7,8 +7,28 @@ import { createZapriteCheckoutSession, BillingStatus, BillingProduct, - fetchTeamPlanAvailable + fetchTeamStatus, + createTeam, + inviteTeamMembers, + fetchTeamMembers, + checkTeamInvite, + acceptTeamInvite, + removeTeamMember, + leaveTeam, + revokeTeamInvite, + updateTeamName } from "./billingApi"; +import type { + TeamStatus, + CreateTeamRequest, + CreateTeamResponse, + InviteMembersRequest, + InviteMembersResponse, + TeamMembersResponse, + CheckInviteResponse, + AcceptInviteRequest, + UpdateTeamNameResponse +} from "@/types/team"; const TOKEN_STORAGE_KEY = "maple_billing_token"; @@ -92,13 +112,50 @@ class BillingService { ); } - async getTeamPlanAvailable(): Promise { - return this.executeWithToken((token) => fetchTeamPlanAvailable(token)); - } - clearToken(): void { sessionStorage.removeItem(TOKEN_STORAGE_KEY); } + + // Team Management Methods + async getTeamStatus(): Promise { + return this.executeWithToken((token) => fetchTeamStatus(token)); + } + + async createTeam(data: CreateTeamRequest): Promise { + return this.executeWithToken((token) => createTeam(token, data)); + } + + async inviteTeamMembers(data: InviteMembersRequest): Promise { + return this.executeWithToken((token) => inviteTeamMembers(token, data)); + } + + async getTeamMembers(): Promise { + return this.executeWithToken((token) => fetchTeamMembers(token)); + } + + async checkTeamInvite(inviteId: string): Promise { + return this.executeWithToken((token) => checkTeamInvite(token, inviteId)); + } + + async acceptTeamInvite(inviteId: string, data: AcceptInviteRequest): Promise { + return this.executeWithToken((token) => acceptTeamInvite(token, inviteId, data)); + } + + async removeTeamMember(userId: string): Promise { + return this.executeWithToken((token) => removeTeamMember(token, userId)); + } + + async leaveTeam(): Promise { + return this.executeWithToken((token) => leaveTeam(token)); + } + + async revokeTeamInvite(inviteId: string): Promise { + return this.executeWithToken((token) => revokeTeamInvite(token, inviteId)); + } + + async updateTeamName(name: string): Promise { + return this.executeWithToken((token) => updateTeamName(token, name)); + } } // Singleton instance diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 671f46ad2..1846df4d0 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -1,4 +1,13 @@ -import { LogOut, Trash, User, CreditCard, ArrowUpCircle, Mail } from "lucide-react"; +import { + LogOut, + Trash, + User, + CreditCard, + ArrowUpCircle, + Mail, + Users, + AlertCircle +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -29,10 +38,12 @@ import { AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { useLocalState } from "@/state/useLocalState"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { getBillingService } from "@/billing/billingService"; import { useState } from "react"; +import type { TeamStatus } from "@/types/team"; +import { TeamManagementDialog } from "@/components/team/TeamManagementDialog"; function ConfirmDeleteDialog() { const { clearHistory } = useLocalState(); @@ -70,13 +81,29 @@ export function AccountMenu() { const router = useRouter(); const { billingStatus } = useLocalState(); const [isPortalLoading, setIsPortalLoading] = useState(false); + const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); const hasStripeAccount = billingStatus?.stripe_customer_id !== null; const productName = billingStatus?.product_name || ""; const isPro = productName.toLowerCase().includes("pro"); const isStarter = productName.toLowerCase().includes("starter"); - const showUpgrade = !isPro; - const showManage = (isPro || isStarter) && hasStripeAccount; + const isTeamPlan = productName.toLowerCase().includes("team"); + const showUpgrade = !isPro && !isTeamPlan; + const showManage = (isPro || isStarter || isTeamPlan) && hasStripeAccount; + + // Fetch team status if user has team plan + const { data: teamStatus } = useQuery({ + queryKey: ["teamStatus"], + queryFn: async () => { + const billingService = getBillingService(); + return await billingService.getTeamStatus(); + }, + enabled: isTeamPlan && !!os.auth.user && !!billingStatus + }); + + // Show alert badge if user has team plan but hasn't created team yet + const showTeamSetupAlert = + isTeamPlan && teamStatus?.has_team_subscription && !teamStatus?.team_created; const handleManageSubscription = async () => { if (!hasStripeAccount) return; @@ -167,14 +194,17 @@ export function AccountMenu() { - - Maple AI + {teamStatus?.team_name || "Maple AI"} @@ -197,6 +227,24 @@ export function AccountMenu() { {isPortalLoading ? "Loading..." : "Manage Subscription"} )} + {isTeamPlan && ( + setIsTeamDialogOpen(true)}> +
+
+ + Manage Team +
+ {showTeamSetupAlert && ( + + Setup Required + + )} +
+
+ )} @@ -218,6 +266,11 @@ export function AccountMenu() {
+ diff --git a/frontend/src/components/BillingStatus.tsx b/frontend/src/components/BillingStatus.tsx index 33a892fb4..81fa41183 100644 --- a/frontend/src/components/BillingStatus.tsx +++ b/frontend/src/components/BillingStatus.tsx @@ -4,11 +4,14 @@ import { Button } from "@/components/ui/button"; import { BillingDebugger } from "./BillingDebugger"; import { useLocalState } from "@/state/useLocalState"; import { getBillingService } from "@/billing/billingService"; +import { useOpenSecret } from "@opensecret/react"; +import type { TeamStatus } from "@/types/team"; export function BillingStatus() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { setBillingStatus } = useLocalState(); + const os = useOpenSecret(); const { data: billingStatus, isLoading } = useQuery({ queryKey: ["billingStatus"], @@ -20,12 +23,28 @@ export function BillingStatus() { } }); + // Check if user has team plan + const isTeamPlan = billingStatus?.product_name?.toLowerCase().includes("team"); + + // Fetch team status if user has team plan + const { data: teamStatus } = useQuery({ + queryKey: ["teamStatus"], + queryFn: async () => { + const billingService = getBillingService(); + return await billingService.getTeamStatus(); + }, + enabled: isTeamPlan && !!os.auth.user && !!billingStatus + }); + if (isLoading || !billingStatus) { - return ( - - ); + return import.meta.env.DEV ? ( + { + queryClient.setQueryData(["billingStatus"], newStatus); + }} + /> + ) : null; } const isFree = billingStatus.product_name.toLowerCase().includes("free"); @@ -44,22 +63,32 @@ export function BillingStatus() { } return "You've run out of messages, upgrade to keep chatting!"; } + + // Show team name for team plans + if (isTeamPlan && teamStatus?.team_name) { + return teamStatus.team_name; + } + return `${billingStatus.product_name} Plan`; }; - return ( -
- + // Build the content + const content = ( + <> + {/* Only show billing status button for free plan or when they can't chat */} + {(isFree || !billingStatus.can_chat) && ( + + )} {import.meta.env.DEV && ( )} -
+ ); + + // Return early if no visible content (except debugger in dev) + if (!isFree && billingStatus.can_chat && !import.meta.env.DEV) { + return null; + } + + return
{content}
; } diff --git a/frontend/src/components/team/TeamDashboard.tsx b/frontend/src/components/team/TeamDashboard.tsx new file mode 100644 index 000000000..2a6a224f6 --- /dev/null +++ b/frontend/src/components/team/TeamDashboard.tsx @@ -0,0 +1,270 @@ +import { useState } from "react"; +import { DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { UserPlus, AlertTriangle, Crown, User, Pencil, Check, X, Loader2 } from "lucide-react"; +import { TeamInviteDialog } from "./TeamInviteDialog"; +import { TeamMembersList } from "./TeamMembersList"; +import { getBillingService } from "@/billing/billingService"; +import { useQueryClient } from "@tanstack/react-query"; +import type { TeamStatus } from "@/types/team"; + +interface TeamDashboardProps { + teamStatus?: TeamStatus; +} + +export function TeamDashboard({ teamStatus }: TeamDashboardProps) { + const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); + const [isEditingName, setIsEditingName] = useState(false); + const [editedName, setEditedName] = useState(""); + const [isSavingName, setIsSavingName] = useState(false); + const [nameError, setNameError] = useState(null); + const queryClient = useQueryClient(); + + if (!teamStatus) { + return ( + <> + + Team Dashboard + Loading team information... + + + ); + } + + const seatsUsed = teamStatus.seats_used || 0; + const seatsPurchased = teamStatus.seats_purchased || 0; + const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true; + const seatUsagePercentage = seatsPurchased > 0 ? (seatsUsed / seatsPurchased) * 100 : 0; + + const handleStartEdit = () => { + setEditedName(teamStatus.team_name || ""); + setIsEditingName(true); + setNameError(null); + }; + + const handleCancelEdit = () => { + setIsEditingName(false); + setEditedName(""); + setNameError(null); + }; + + const handleSaveName = async () => { + const trimmedName = editedName.trim(); + + // Validation + if (!trimmedName) { + setNameError("Team name cannot be empty"); + return; + } + + if (trimmedName.length > 100) { + setNameError("Team name must be 100 characters or less"); + return; + } + + if (trimmedName === teamStatus.team_name) { + handleCancelEdit(); + return; + } + + setIsSavingName(true); + setNameError(null); + + try { + const billingService = getBillingService(); + await billingService.updateTeamName(trimmedName); + + // Invalidate queries to refresh the data + await queryClient.invalidateQueries({ queryKey: ["teamStatus"] }); + await queryClient.invalidateQueries({ queryKey: ["billingStatus"] }); + + setIsEditingName(false); + setEditedName(""); + } catch (error) { + console.error("Failed to update team name:", error); + setNameError(error instanceof Error ? error.message : "Failed to update team name"); + } finally { + setIsSavingName(false); + } + }; + + // Simplified view for non-admin members + if (!isAdmin) { + return ( + <> + + Team Information + + +
+ {/* Compact team info */} +
+
+
+

{teamStatus.team_name}

+ {teamStatus.created_at && ( +

+ Member since {new Date(teamStatus.created_at).toLocaleDateString()} +

+ )} +
+ + + Member + +
+
+ + {/* Leave team section */} +
+

+ Need to leave this team? You'll need an invitation to rejoin. +

+ +
+
+ + ); + } + + // Full admin view + return ( + <> + + Team Dashboard + + +
+ {/* Seat limit exceeded warning */} + {teamStatus.seat_limit_exceeded && ( +
+ + Seat limit exceeded. Remove members or purchase additional seats. +
+ )} + + {/* Compact header with all info */} +
+
+
+ {isEditingName ? ( +
+
+ setEditedName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveName(); + if (e.key === "Escape") handleCancelEdit(); + }} + className="h-7 text-sm font-medium" + maxLength={100} + autoFocus + disabled={isSavingName} + /> + {isSavingName ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+ + {editedName.trim().length}/100 characters + + {nameError && {nameError}} +
+
+ ) : ( +
+

{teamStatus.team_name}

+ +
+ )} + {!isEditingName && teamStatus.created_at && ( +

+ Created {new Date(teamStatus.created_at).toLocaleDateString()} +

+ )} +
+ {!isEditingName && ( + + + Admin + + )} +
+ + {/* Seat usage bar */} +
+
+ Seat Usage + + {seatsUsed}/{seatsPurchased} ({Math.round(seatUsagePercentage)}%) + +
+
+
+
+
+
+ + {/* Action buttons */} + {isAdmin && ( + + )} + + {/* Members list */} + +
+ + {/* Invite dialog */} + + + ); +} diff --git a/frontend/src/components/team/TeamInviteDialog.tsx b/frontend/src/components/team/TeamInviteDialog.tsx new file mode 100644 index 000000000..6c6e5d91b --- /dev/null +++ b/frontend/src/components/team/TeamInviteDialog.tsx @@ -0,0 +1,260 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, AlertCircle, UserPlus, Info, CreditCard } from "lucide-react"; +import { getBillingService } from "@/billing/billingService"; +import { useLocalState } from "@/state/useLocalState"; +import { isTauri } from "@tauri-apps/api/core"; +import { type } from "@tauri-apps/plugin-os"; +import type { TeamStatus } from "@/types/team"; + +interface TeamInviteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamStatus?: TeamStatus; +} + +export function TeamInviteDialog({ open, onOpenChange, teamStatus }: TeamInviteDialogProps) { + const [emails, setEmails] = useState(""); + const [isInviting, setIsInviting] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isPortalLoading, setIsPortalLoading] = useState(false); + const queryClient = useQueryClient(); + const { billingStatus } = useLocalState(); + + const seatsAvailable = teamStatus?.seats_available || 0; + const hasStripeAccount = billingStatus?.stripe_customer_id !== null; + + const handleManageSubscription = async () => { + if (!hasStripeAccount) return; + + try { + setIsPortalLoading(true); + const billingService = getBillingService(); + const url = await billingService.getPortalUrl(); + + // Check if we're in a Tauri environment on iOS + try { + const isTauriEnv = await isTauri(); + if (isTauriEnv) { + const platform = await type(); + if (platform === "ios") { + // For iOS, use the opener plugin + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("plugin:opener|open_url", { url }); + return; + } + } + } catch { + // Not in Tauri or error checking, continue with web flow + } + + // Web or desktop flow + window.open(url, "_blank", "noopener,noreferrer"); + } catch (error) { + console.error("Failed to open billing portal:", error); + } finally { + setIsPortalLoading(false); + } + }; + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + + const emailList = emails + .split(/[\n,]/) + .map((email) => email.trim()) + .filter((email) => email.length > 0); + + if (emailList.length === 0) { + setError("Please enter at least one email address"); + return; + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = emailList.filter((email) => !emailRegex.test(email)); + if (invalidEmails.length > 0) { + setError(`Invalid email format: ${invalidEmails.join(", ")}`); + return; + } + + // Check seat availability + if (emailList.length > seatsAvailable) { + setError( + `Cannot invite ${emailList.length} members. Only ${seatsAvailable} ${ + seatsAvailable === 1 ? "seat is" : "seats are" + } available.` + ); + return; + } + + setIsInviting(true); + setError(null); + setSuccessMessage(null); + + try { + const billingService = getBillingService(); + const response = await billingService.inviteTeamMembers({ emails: emailList }); + + // Invalidate team status and members queries + await queryClient.invalidateQueries({ queryKey: ["teamStatus"] }); + await queryClient.invalidateQueries({ queryKey: ["teamMembers"] }); + + const inviteCount = response.invites.length; + setSuccessMessage( + `Successfully sent ${inviteCount} ${inviteCount === 1 ? "invite" : "invites"}` + ); + + // Clear the form + setEmails(""); + } catch (err) { + console.error("Failed to invite members:", err); + setError(err instanceof Error ? err.message : "Failed to send invites"); + } finally { + setIsInviting(false); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!isInviting) { + if (newOpen && !open) { + // Reset form when opening + setEmails(""); + setError(null); + setSuccessMessage(null); + } else if (!newOpen && open) { + // When closing, delay clearing success message to prevent flash + onOpenChange(newOpen); + setTimeout(() => { + setEmails(""); + setError(null); + setSuccessMessage(null); + }, 300); // Wait for dialog close animation + return; + } + onOpenChange(newOpen); + } + }; + + return ( + + + + Invite Team Members + + Send invitations to new team members. They'll receive an email to join your team. + + + +
+
+
+ +