From 2674857fa68ea333ecfb555484597f94c4f1c0b6 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 27 May 2026 12:47:54 -0700 Subject: [PATCH] Wire dashboard account settings shell, payments, and handler route. Co-authored-by: Cursor --- .../app/(main)/handler/[...stack]/page.tsx | 17 +- .../dashboard-account-settings-page.tsx | 320 ++++++++++ .../payments/payments-page.tsx | 69 +++ .../payments/payments-panel.tsx | 556 ++++++++++++++++++ .../supporting/dashboard-team-switcher.tsx | 160 +++++ 5 files changed, 1120 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/components/dashboard-account-settings/dashboard-account-settings-page.tsx create mode 100644 apps/dashboard/src/components/dashboard-account-settings/payments/payments-page.tsx create mode 100644 apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx create mode 100644 apps/dashboard/src/components/dashboard-account-settings/supporting/dashboard-team-switcher.tsx diff --git a/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx b/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx index 69d5acbe8..32ae8343f 100644 --- a/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx +++ b/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx @@ -1,7 +1,17 @@ import { StyledLink } from "@/components/link"; import { StackHandler } from "@stackframe/stack"; +import { DashboardAccountSettingsPage } from "@/components/dashboard-account-settings/dashboard-account-settings-page"; + +export default async function Handler(props: { + params: Promise<{ stack?: string[] }>, +}) { + const params = await props.params; + const stack = params.stack || []; + + if (stack.join("/") === "account-settings") { + return ; + } -export default function Handler(props: unknown) { const extraInfo = <>

By signing in, you agree to the

Terms of Service and Privacy Policy

@@ -17,7 +27,10 @@ export default function Handler(props: unknown) {
); diff --git a/apps/dashboard/src/components/dashboard-account-settings/dashboard-account-settings-page.tsx b/apps/dashboard/src/components/dashboard-account-settings/dashboard-account-settings-page.tsx new file mode 100644 index 000000000..5da3465b7 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/dashboard-account-settings-page.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { Skeleton } from "@/components/ui/skeleton"; +import { + UserCircle, + ShieldCheck, + Bell, + Monitor, + Key, + Gear, + CreditCard, + Plus, +} from "@phosphor-icons/react"; +import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { useStackApp, useUser } from "@stackframe/stack"; +import { SidebarLayout } from './sidebar-layout'; +import { ActiveSessionsPage } from "./active-sessions/active-sessions-page"; +import { ApiKeysPage } from "./api-keys/api-keys-page"; +import { EmailsAndAuthPage } from './email-and-auth/email-and-auth-page'; +import { NotificationsPage } from './notifications/notifications-page'; +import { ProfilePage } from "./profile-page/profile-page"; +import { SettingsPage } from './settings/settings-page'; +import { PaymentsPage } from "./payments/payments-page"; +import { TeamPage } from "./teams/team-page"; +import { TeamCreationPage } from "./teams/team-creation-page"; +import { TeamIcon } from "./supporting/team-icon"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; + +const iconMap = { + Contact: UserCircle, + ShieldCheck, + Bell, + Monitor, + Key, + Settings: Gear, + CreditCard, + Plus, +} as const; + +const Icon = ({ name }: { name: keyof typeof iconMap }) => { + const PhosphorIcon = iconMap[name]; + return ; +}; + +export function DashboardAccountSettingsPage(props: { + mockUser?: { + displayName?: string, + profileImageUrl?: string, + }, + mockApiKeys?: Array<{ + id: string, + description: string, + createdAt: string, + expiresAt?: string, + manuallyRevokedAt?: string, + }>, + mockProject?: { + config: { + allowUserApiKeys: boolean, + clientTeamCreationEnabled?: boolean, + }, + }, + mockSessions?: Array<{ + id: string, + isCurrentSession: boolean, + isImpersonation?: boolean, + createdAt: string, + lastUsedAt?: string, + geoInfo?: { + ip?: string, + cityName?: string, + }, + }>, +}) { + const userFromHook = useUser({ or: props.mockUser ? 'return-null' : 'redirect' }); + const stackApp = useStackApp(); + const projectFromHook = stackApp.useProject(); + + const user = props.mockUser ? null : userFromHook; + const project = props.mockProject || projectFromHook; + + const teams = user?.useTeams() || []; + const teamsKey = useMemo(() => teams.map(team => team.id).join("|"), [teams]); + const teamsById = useMemo(() => teams, [teamsKey]); + const userRef = useRef(userFromHook ?? null); + const userId = userFromHook?.id ?? null; + + const [paymentsAvailability, setPaymentsAvailability] = useState<{ + userHasProducts: boolean, + teamIdsWithProducts: Set, + isReady: boolean, + }>(() => ({ + userHasProducts: false, + teamIdsWithProducts: new Set(), + isReady: !!props.mockUser, + })); + + useEffect(() => { + userRef.current = userFromHook ?? null; + }, [userFromHook]); + + useEffect(() => { + if (props.mockUser || !userId) { + return; + } + let cancelled = false; + runAsynchronouslyWithAlert(async () => { + const currentUser = userRef.current; + if (!currentUser || currentUser.id !== userId) { + return; + } + const [userProducts, teamsWithProducts] = await Promise.all([ + currentUser.listProducts({ limit: 1 }), + Promise.all(teamsById.map(async (team) => { + const isTeamAdmin = await currentUser.hasPermission(team, "team_admin"); + if (!isTeamAdmin) { + return null; + } + const teamProducts = await team.listProducts({ limit: 1 }); + const hasTeamProducts = teamProducts.some((product) => product.customerType === "team"); + return hasTeamProducts ? team.id : null; + })), + ]); + if (cancelled) { + return; + } + const userHasProducts = userProducts.some((product) => product.customerType === "user"); + const teamIdsWithProducts = new Set(teamsWithProducts.filter((id): id is string => id !== null)); + setPaymentsAvailability({ + userHasProducts, + teamIdsWithProducts, + isReady: true, + }); + }); + return () => { + cancelled = true; + }; + }, [props.mockUser, teamsById, userId]); + + const teamsWithProducts = useMemo( + () => teamsById.filter(team => paymentsAvailability.teamIdsWithProducts.has(team.id)), + [paymentsAvailability.teamIdsWithProducts, teamsById], + ); + + const shouldShowPaymentsTab = props.mockUser + || (paymentsAvailability.isReady + && (paymentsAvailability.userHasProducts || teamsWithProducts.length > 0)); + + if (!props.mockUser && !userFromHook) { + return null; + } + + const sidebarItems = [ + { + title: 'My Profile', + type: 'item' as const, + id: 'profile', + icon: , + content: , + }, + { + title: 'Emails & Auth', + type: 'item' as const, + id: 'auth', + icon: , + content: }> + + , + }, + { + title: 'Notifications', + type: 'item' as const, + id: 'notifications', + icon: , + content: }> + + , + }, + { + title: 'Active Sessions', + type: 'item' as const, + id: 'sessions', + icon: , + content: }> + + , + }, + ...(project.config.allowUserApiKeys ? [{ + title: 'API Keys', + type: 'item' as const, + id: 'api-keys', + icon: , + content: }> + + , + }] as const : []), + ...(shouldShowPaymentsTab ? [{ + title: 'Payments', + type: 'item' as const, + id: 'payments', + icon: , + content: }> + + , + }] as const : []), + { + title: 'Settings', + type: 'item' as const, + id: 'settings', + icon: , + content: , + }, + ...( (teams.length > 0 || project.config.clientTeamCreationEnabled) ? [{ + title: 'Teams', + type: 'divider' as const, + }] as const : [] ), + ...teams.map(team => ({ + title: ( +
+ + {team.displayName} +
+ ), + type: 'item' as const, + id: `team-${team.id}`, + content: }> + + , + } as const)), + ...( project.config.clientTeamCreationEnabled ? [{ + title: 'Create a team', + icon: , + type: 'item' as const, + id: 'team-creation', + content: }> + + , + }] as const : [] ), + ].filter(p => p.type === 'divider' || (p as any).content); + + return ( +
+ +
+ ); +} + +function PageLayout(props: { children: React.ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +function EmailsAndAuthPageSkeleton() { + return ( + + + + + + ); +} + +function ActiveSessionsPageSkeleton() { + return ( + + + + ); +} + +function ApiKeysPageSkeleton() { + return ( + + + + ); +} + +function NotificationsPageSkeleton() { + return ( + + + + ); +} + +function PaymentsPageSkeleton() { + return ( + + + + ); +} + +function TeamPageSkeleton() { + return ( + + + + + ); +} + +function TeamCreationSkeleton() { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/payments/payments-page.tsx b/apps/dashboard/src/components/dashboard-account-settings/payments/payments-page.tsx new file mode 100644 index 000000000..2d0683973 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/payments/payments-page.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect, useState } from "react"; +import { Team } from "@stackframe/stack"; +import { useUser } from "@stackframe/stack"; +import { PageLayout } from "../page-layout"; +import { PaymentsPanel } from "./payments-panel"; +import { DashboardTeamSwitcher } from "../supporting/dashboard-team-switcher"; + +export function PaymentsPage(props: { mockMode?: boolean, availableTeams?: Team[], allowPersonal?: boolean }) { + const user = useUser({ or: props.mockMode ? "return-null" : "redirect" }); + const teams = props.availableTeams ?? user?.useTeams() ?? []; + const allowPersonal = props.allowPersonal ?? true; + const hasTeams = teams.length > 0; + const [selectedTeam, setSelectedTeam] = useState(null); + const effectiveSelectedTeam = selectedTeam ?? (!allowPersonal ? (teams[0] ?? null) : null); + const customer = effectiveSelectedTeam ?? (allowPersonal ? user : null); + const customerType = effectiveSelectedTeam ? "team" : "user"; + + useEffect(() => { + if (props.mockMode) { + return; + } + if (!allowPersonal && !selectedTeam && teams.length > 0) { + setSelectedTeam(teams[0]); + return; + } + if (selectedTeam && !teams.some(team => team.id === selectedTeam.id)) { + setSelectedTeam(allowPersonal ? null : (teams[0] ?? null)); + } + }, [allowPersonal, props.mockMode, selectedTeam, teams]); + + if (props.mockMode) { + return ( + + + + ); + } + + if (!customer) { + return null; + } + + return ( + + {hasTeams ? ( +
+ Account Billing context + { + setSelectedTeam(team); + }} + /> +
+ ) : null} + +
+ ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx b/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx new file mode 100644 index 000000000..e36d4b8d9 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/payments/payments-panel.tsx @@ -0,0 +1,556 @@ +'use client'; + +import { KnownErrors } from "@stackframe/stack-shared"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CardElement, Elements, useElements, useStripe } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; +import { useMemo, useState } from "react"; +import { useStackApp } from "@stackframe/stack"; +import { getPublicEnvVar } from "@/lib/env"; +import { Section } from "../section"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { ActionDialog } from "@/components/ui/action-dialog"; +import { CreditCard, Receipt, CaretRight, WarningCircle } from "@phosphor-icons/react"; + +type CustomerInvoiceStatus = "draft" | "open" | "paid" | "uncollectible" | "void" | null; +type CustomerInvoicesListOptions = { limit?: number; startingAfter?: string }; +type CustomerInvoicesList = any[]; + +type PaymentMethodSummary = { + id: string, + brand: string | null, + last4: string | null, + exp_month: number | null, + exp_year: number | null, +} | null; + +function formatPaymentMethod(pm: NonNullable) { + const details = [ + pm.brand ? pm.brand.toUpperCase() : null, + pm.last4 ? `•••• ${pm.last4}` : null, + pm.exp_month && pm.exp_year ? `exp ${pm.exp_month}/${pm.exp_year}` : null, + ].filter(Boolean); + return details.join(" · "); +} + +const formatInvoiceStatus = (status: CustomerInvoiceStatus) => { + if (status === "draft") return "Draft"; + if (status === "open") return "Open"; + if (status === "paid") return "Paid"; + if (status === "uncollectible") return "Uncollectible"; + if (status === "void") return "Void"; + return "Unknown"; +}; + +const formatInvoiceAmount = (amountTotal: number | null | undefined) => { + if (typeof amountTotal !== "number" || Number.isNaN(amountTotal)) { + return "Unknown"; + } + const normalized = amountTotal / 100; + const formatted = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(normalized); + return `$${formatted}`; +}; + +const formatInvoiceDate = (date: Date | null | undefined) => { + if (!date || Number.isNaN(date.getTime())) { + return "Unknown"; + } + return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(date); +}; + +type CustomerBilling = { + hasCustomer: boolean, + defaultPaymentMethod: PaymentMethodSummary, +}; + +type CustomerPaymentMethodSetupIntent = { + clientSecret: string, + stripeAccountId: string, +}; + +type CustomerLike = { + id: string, + useBilling: () => CustomerBilling, + useProducts: () => Array<{ + id: string | null, + quantity: number, + displayName: string, + customerType: "user" | "team" | "custom", + type?: "one_time" | "subscription", + switchOptions?: Array<{ + productId: string, + displayName: string, + prices: Record + }>, + subscription: null | { + subscriptionId: string | null, + currentPeriodEnd: Date | null, + cancelAtPeriodEnd: boolean, + isCancelable: boolean, + }, + }>, + useInvoices: (options?: CustomerInvoicesListOptions) => CustomerInvoicesList, + createPaymentMethodSetupIntent: () => Promise, + setDefaultPaymentMethodFromSetupIntent: (setupIntentId: string) => Promise, + switchSubscription: (options: { fromProductId: string, toProductId: string, priceId?: string, quantity?: number }) => Promise, +}; + +function SetDefaultPaymentMethodForm(props: { + clientSecret: string, + onSetupIntentSucceeded: (setupIntentId: string) => Promise, +}) { + const stripe = useStripe(); + const elements = useElements(); + const [errorMessage, setErrorMessage] = useState(null); + const darkMode = "color-scheme" in document.documentElement.style && document.documentElement.style["color-scheme"] === "dark"; + + return ( +
+
+ +
+ +
+
+ {errorMessage && ( + + {errorMessage} + + )} + +
+ ); +} + +export function PaymentsPanel(props: { + title?: string, + customer?: CustomerLike, + customerType?: "user" | "team", + mockMode?: boolean, +}) { + if (props.mockMode) { + return ; + } + if (!props.customer) { + return null; + } + return ; +} + +function MockPaymentsPanel(props: { title?: string }) { + const defaultPaymentMethod: PaymentMethodSummary = { + id: "pm_mock", + brand: "visa", + last4: "4242", + exp_month: 12, + exp_year: 2030, + }; + + return ( +
+ {props.title &&

{props.title}

} +
+
+
+ +
+ {formatPaymentMethod(defaultPaymentMethod)} + +
+
+ +
+
+
+
+ Pro + Renews on Jan 1, 2030 +
+ +
+
+
+ Credits pack + One-time purchase +
+
+
+
+
+ ); +} + +function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, customerType: "user" | "team" }) { + const stackApp = useStackApp(); + const billing = props.customer.useBilling(); + const defaultPaymentMethod = billing.defaultPaymentMethod; + const products = props.customer.useProducts(); + const invoices = props.customer.useInvoices({ limit: 10 }); + const productsForCustomerType = products.filter(product => product.customerType === props.customerType); + + const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); + const [setupIntentClientSecret, setSetupIntentClientSecret] = useState(null); + const [setupIntentStripeAccountId, setSetupIntentStripeAccountId] = useState(null); + const [cancelTarget, setCancelTarget] = useState<{ productId: string, subscriptionId?: string } | null>(null); + const [switchFromProductId, setSwitchFromProductId] = useState(null); + const [switchToProductId, setSwitchToProductId] = useState(null); + + const stripePromise = useMemo(() => { + if (!setupIntentStripeAccountId) return null; + const publishableKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY"); + if (!publishableKey) return null; + return loadStripe(publishableKey, { stripeAccount: setupIntentStripeAccountId }); + }, [setupIntentStripeAccountId]); + + const handleAsyncError = (error: unknown) => { + if (error instanceof KnownErrors.DefaultPaymentMethodRequired) { + alert("No default payment method added. Add a payment method before switching plans."); + return; + } + alert(`An unhandled error occurred. Please ${process.env.NODE_ENV === "development" ? "check the browser console for the full error." : "report this to the developer."}\n\n${error}`); + }; + + const openPaymentDialog = () => { + runAsynchronously(async () => { + setPaymentDialogOpen(true); + const res = await props.customer.createPaymentMethodSetupIntent(); + setSetupIntentClientSecret(res.clientSecret); + setSetupIntentStripeAccountId(res.stripeAccountId); + }, { onError: handleAsyncError }); + }; + + const closePaymentDialog = () => { + setPaymentDialogOpen(false); + setSetupIntentClientSecret(null); + setSetupIntentStripeAccountId(null); + }; + + const openSwitchDialog = (productId: string, firstOptionId: string | null) => { + setSwitchFromProductId(productId); + setSwitchToProductId(firstOptionId); + }; + + const closeSwitchDialog = () => { + setSwitchFromProductId(null); + setSwitchToProductId(null); + }; + + const switchSourceProduct = switchFromProductId + ? productsForCustomerType.find((product) => product.id === switchFromProductId) ?? null + : null; + const switchOptions = switchSourceProduct?.switchOptions ?? []; + const selectedSwitchOption = switchOptions.find((option) => option.productId === switchToProductId) ?? null; + const selectedPriceId = selectedSwitchOption ? (Object.keys(selectedSwitchOption.prices)[0] ?? null) : null; + + return ( +
+ {props.title &&

{props.title}

} + +
+
+
+ +
+ + {defaultPaymentMethod ? formatPaymentMethod(defaultPaymentMethod) : "No payment method on file"} + + +
+ + { + if (!open) closePaymentDialog(); + }} + title="Update payment method" + > + {!setupIntentClientSecret || !setupIntentStripeAccountId || !stripePromise ? ( +
+ + +
+ ) : ( + + { + await props.customer.setDefaultPaymentMethodFromSetupIntent(setupIntentId); + closePaymentDialog(); + }} + /> + + )} +
+
+ + {productsForCustomerType.length > 0 && ( +
+
+ {productsForCustomerType.map((product, index) => { + const quantitySuffix = product.quantity !== 1 ? ` ×${product.quantity}` : ""; + const isSubscription = product.type === "subscription"; + const isCancelable = isSubscription && !!product.subscription?.isCancelable; + const canSwitchPlans = isSubscription && defaultPaymentMethod && !!product.id && (product.switchOptions?.length ?? 0) > 0; + const renewsAt = isSubscription ? (product.subscription?.currentPeriodEnd ?? null) : null; + const subtitle = + product.type === "one_time" + ? "One-time purchase" + : renewsAt + ? `Renews on ${new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)}` + : "Subscription"; + + return ( +
+
+ {product.displayName}{quantitySuffix} + {subtitle} +
+ +
+ {canSwitchPlans && ( + + )} + {isCancelable && ( + + )} +
+
+ ); + })} +
+ + { + if (!open) setCancelTarget(null); + }} + title="Cancel subscription" + danger + cancelButton + okButton={{ + label: "Cancel subscription", + onClick: async () => { + if (!cancelTarget) return; + const { productId, subscriptionId } = cancelTarget; + if (props.customerType === "team") { + await stackApp.cancelSubscription({ teamId: props.customer.id, productId, subscriptionId }); + } else { + await stackApp.cancelSubscription({ productId, subscriptionId }); + } + setCancelTarget(null); + }, + }} + > + + Canceling will stop future renewals for this subscription. This action is safe and will take effect at the end of the billing period. + + + + { + if (!open) closeSwitchDialog(); + }} + title="Change plan" + cancelButton + okButton={{ + label: "Switch plan", + onClick: async () => { + const fromProductId = switchFromProductId; + const toProductId = switchToProductId; + if (!fromProductId || !toProductId) return; + if (!selectedPriceId) return; + const result = await Result.fromThrowingAsync(() => props.customer.switchSubscription({ + fromProductId, + toProductId, + priceId: selectedPriceId, + })); + if (result.status === "error") { + handleAsyncError(result.error); + return "prevent-close"; + } + closeSwitchDialog(); + }, + props: { + disabled: !switchFromProductId || !switchToProductId || !selectedPriceId, + }, + }} + > +
+ {switchOptions.length === 0 ? ( + + No other plans available for this subscription. + + ) : ( +
+ + +
+ )} +
+
+
+ )} + + {invoices.length > 0 && ( +
+
+

+ Past Invoices +

+

+ Review your receipts and past billing logs. +

+
+ +
+ + + + Date + Status + Amount + + + + + {(invoices as any[]).map((invoice: any, index: number) => { + const createdAtTime = new Date(invoice.createdAt).getTime(); + const invoiceKey = Number.isNaN(createdAtTime) ? `invoice-${index}` : `invoice-${createdAtTime}-${index}`; + return ( + + + {formatInvoiceDate(invoice.createdAt)} + + + + {formatInvoiceStatus(invoice.status)} + + + + {formatInvoiceAmount(invoice.amountTotal)} + + + {invoice.hostedInvoiceUrl ? ( + + ) : ( + Unavailable + )} + + + ); + })} + +
+
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/supporting/dashboard-team-switcher.tsx b/apps/dashboard/src/components/dashboard-account-settings/supporting/dashboard-team-switcher.tsx new file mode 100644 index 000000000..410fdabf9 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/supporting/dashboard-team-switcher.tsx @@ -0,0 +1,160 @@ +'use client'; + +import React, { Suspense, useMemo } from "react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Team, useStackApp } from "@stackframe/stack"; +import { TeamIcon } from "./team-icon"; +import { Gear, Plus } from "@phosphor-icons/react"; + +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; + +type MockTeam = { + id: string; + displayName: string; + profileImageUrl?: string | null; +}; + +type DashboardTeamSwitcherProps = { + team?: Team; + teamId?: string; + teams?: Team[]; + allowNull?: AllowNull; + nullLabel?: string; + triggerClassName?: string; + onChange?: (team: AllowNull extends true ? Team | null : Team) => Promise; + // Mock data props + mockUser?: { + team?: MockTeam; + }; + mockTeams?: MockTeam[]; + mockProject?: { + config: { + clientTeamCreationEnabled: boolean; + }; + }; +}; + +export function DashboardTeamSwitcher(props: DashboardTeamSwitcherProps) { + return ( + }> + + + ); +} + +function Fallback() { + return ; +} + +function Inner(props: DashboardTeamSwitcherProps) { + const app = useStackApp(); + const navigate = app.useNavigate(); + const project = app.useProject(); + + const rawTeams = props.teams || []; + const selectedTeam = props.team || rawTeams.find(team => team.id === props.teamId); + const teams = useMemo(() => [...rawTeams].sort((a, b) => (b.id === selectedTeam?.id ? 1 : -1)), [rawTeams, selectedTeam]); + + return ( + + ); +}