mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Wire dashboard account settings shell, payments, and handler route.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
10688411be
commit
2674857fa6
@ -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 <DashboardAccountSettingsPage />;
|
||||
}
|
||||
|
||||
export default function Handler(props: unknown) {
|
||||
const extraInfo = <>
|
||||
<p className="text-xs">By signing in, you agree to the</p>
|
||||
<p className="text-xs"><StyledLink href="https://www.iubenda.com/privacy-policy/19290387/cookie-policy">Terms of Service</StyledLink> and <StyledLink href="https://www.iubenda.com/privacy-policy/19290387">Privacy Policy</StyledLink></p>
|
||||
@ -17,7 +27,10 @@ export default function Handler(props: unknown) {
|
||||
<div data-stack-handler-page className="min-h-screen">
|
||||
<StackHandler
|
||||
fullPage
|
||||
componentProps={{ SignIn: { extraInfo }, SignUp: { extraInfo } }}
|
||||
componentProps={{
|
||||
SignIn: { extraInfo },
|
||||
SignUp: { extraInfo },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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 <PhosphorIcon className="h-4 w-4 shrink-0" />;
|
||||
};
|
||||
|
||||
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<string>,
|
||||
isReady: boolean,
|
||||
}>(() => ({
|
||||
userHasProducts: false,
|
||||
teamIdsWithProducts: new Set<string>(),
|
||||
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<string>(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: <Icon name="Contact"/>,
|
||||
content: <ProfilePage mockUser={props.mockUser}/>,
|
||||
},
|
||||
{
|
||||
title: 'Emails & Auth',
|
||||
type: 'item' as const,
|
||||
id: 'auth',
|
||||
icon: <Icon name="ShieldCheck"/>,
|
||||
content: <Suspense fallback={<EmailsAndAuthPageSkeleton/>}>
|
||||
<EmailsAndAuthPage mockMode={!!props.mockUser}/>
|
||||
</Suspense>,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
type: 'item' as const,
|
||||
id: 'notifications',
|
||||
icon: <Icon name="Bell"/>,
|
||||
content: <Suspense fallback={<NotificationsPageSkeleton/>}>
|
||||
<NotificationsPage/>
|
||||
</Suspense>,
|
||||
},
|
||||
{
|
||||
title: 'Active Sessions',
|
||||
type: 'item' as const,
|
||||
id: 'sessions',
|
||||
icon: <Icon name="Monitor"/>,
|
||||
content: <Suspense fallback={<ActiveSessionsPageSkeleton/>}>
|
||||
<ActiveSessionsPage mockSessions={props.mockSessions} mockMode={!!props.mockUser}/>
|
||||
</Suspense>,
|
||||
},
|
||||
...(project.config.allowUserApiKeys ? [{
|
||||
title: 'API Keys',
|
||||
type: 'item' as const,
|
||||
id: 'api-keys',
|
||||
icon: <Icon name="Key" />,
|
||||
content: <Suspense fallback={<ApiKeysPageSkeleton/>}>
|
||||
<ApiKeysPage mockApiKeys={props.mockApiKeys} mockMode={!!props.mockUser} />
|
||||
</Suspense>,
|
||||
}] as const : []),
|
||||
...(shouldShowPaymentsTab ? [{
|
||||
title: 'Payments',
|
||||
type: 'item' as const,
|
||||
id: 'payments',
|
||||
icon: <Icon name="CreditCard" />,
|
||||
content: <Suspense fallback={<PaymentsPageSkeleton/>}>
|
||||
<PaymentsPage
|
||||
mockMode={!!props.mockUser}
|
||||
allowPersonal={paymentsAvailability.userHasProducts}
|
||||
availableTeams={teamsWithProducts}
|
||||
/>
|
||||
</Suspense>,
|
||||
}] as const : []),
|
||||
{
|
||||
title: 'Settings',
|
||||
type: 'item' as const,
|
||||
id: 'settings',
|
||||
icon: <Icon name="Settings"/>,
|
||||
content: <SettingsPage mockMode={!!props.mockUser}/>,
|
||||
},
|
||||
...( (teams.length > 0 || project.config.clientTeamCreationEnabled) ? [{
|
||||
title: 'Teams',
|
||||
type: 'divider' as const,
|
||||
}] as const : [] ),
|
||||
...teams.map(team => ({
|
||||
title: (
|
||||
<div className="flex gap-2 items-center w-full min-w-0">
|
||||
<TeamIcon team={team}/>
|
||||
<span className="truncate max-w-[140px] md:max-w-[200px] text-sm font-medium">{team.displayName}</span>
|
||||
</div>
|
||||
),
|
||||
type: 'item' as const,
|
||||
id: `team-${team.id}`,
|
||||
content: <Suspense fallback={<TeamPageSkeleton/>}>
|
||||
<TeamPage team={team}/>
|
||||
</Suspense>,
|
||||
} as const)),
|
||||
...( project.config.clientTeamCreationEnabled ? [{
|
||||
title: 'Create a team',
|
||||
icon: <Icon name="Plus"/>,
|
||||
type: 'item' as const,
|
||||
id: 'team-creation',
|
||||
content: <Suspense fallback={<TeamCreationSkeleton/>}>
|
||||
<TeamCreationPage mockMode={!!props.mockUser} />
|
||||
</Suspense>,
|
||||
}] as const : [] ),
|
||||
].filter(p => p.type === 'divider' || (p as any).content);
|
||||
|
||||
return (
|
||||
<div className="flex-grow w-full max-w-7xl mx-auto px-4 md:px-8 py-8 flex flex-col">
|
||||
<SidebarLayout
|
||||
items={sidebarItems as any}
|
||||
title="Account Settings"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex flex-col gap-6'>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailsAndAuthPageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[120px] w-full rounded-2xl" />
|
||||
<Skeleton className="h-[120px] w-full rounded-2xl" />
|
||||
<Skeleton className="h-[120px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveSessionsPageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[250px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysPageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[250px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsPageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[200px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentsPageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[350px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[150px] w-full rounded-2xl" />
|
||||
<Skeleton className="h-[250px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCreationSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<Skeleton className="h-[150px] w-full rounded-2xl" />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -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<Team | null>(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 (
|
||||
<PageLayout>
|
||||
<PaymentsPanel
|
||||
mockMode
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{hasTeams ? (
|
||||
<div className="flex flex-col gap-1.5 max-w-[240px]">
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Account Billing context</span>
|
||||
<DashboardTeamSwitcher
|
||||
team={effectiveSelectedTeam ?? undefined}
|
||||
teams={teams}
|
||||
allowNull={allowPersonal}
|
||||
nullLabel="Personal Account"
|
||||
onChange={async (team) => {
|
||||
setSelectedTeam(team);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<PaymentsPanel
|
||||
customer={customer as any}
|
||||
customerType={customerType}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -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<PaymentMethodSummary>) {
|
||||
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<string, { interval?: [number, "day" | "week" | "month" | "year"] }>
|
||||
}>,
|
||||
subscription: null | {
|
||||
subscriptionId: string | null,
|
||||
currentPeriodEnd: Date | null,
|
||||
cancelAtPeriodEnd: boolean,
|
||||
isCancelable: boolean,
|
||||
},
|
||||
}>,
|
||||
useInvoices: (options?: CustomerInvoicesListOptions) => CustomerInvoicesList,
|
||||
createPaymentMethodSetupIntent: () => Promise<CustomerPaymentMethodSetupIntent>,
|
||||
setDefaultPaymentMethodFromSetupIntent: (setupIntentId: string) => Promise<PaymentMethodSummary>,
|
||||
switchSubscription: (options: { fromProductId: string, toProductId: string, priceId?: string, quantity?: number }) => Promise<void>,
|
||||
};
|
||||
|
||||
function SetDefaultPaymentMethodForm(props: {
|
||||
clientSecret: string,
|
||||
onSetupIntentSucceeded: (setupIntentId: string) => Promise<void>,
|
||||
}) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const darkMode = "color-scheme" in document.documentElement.style && document.documentElement.style["color-scheme"] === "dark";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-semibold text-foreground">Card Details</label>
|
||||
<div className="rounded-xl border border-black/[0.08] dark:border-white/[0.08] bg-white dark:bg-zinc-900 p-3.5 shadow-sm">
|
||||
<CardElement options={{ hidePostalCode: true, style: { base: { color: darkMode ? "white" : "black", fontSize: "14px" } } }} />
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<span className="text-red-500 text-xs font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!stripe || !elements) {
|
||||
setErrorMessage("Stripe is still loading. Please try again.");
|
||||
return;
|
||||
}
|
||||
const card = elements.getElement(CardElement);
|
||||
if (!card) {
|
||||
setErrorMessage("Card element not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await stripe.confirmCardSetup(props.clientSecret, {
|
||||
payment_method: { card },
|
||||
});
|
||||
if (result.error) {
|
||||
setErrorMessage(result.error.message ?? "Failed to save payment method.");
|
||||
return;
|
||||
}
|
||||
if (!result.setupIntent.id) {
|
||||
setErrorMessage("No setup intent returned from Stripe.");
|
||||
return;
|
||||
}
|
||||
await props.onSetupIntentSucceeded(result.setupIntent.id);
|
||||
}}
|
||||
className="bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 rounded-xl"
|
||||
>
|
||||
Save payment method
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentsPanel(props: {
|
||||
title?: string,
|
||||
customer?: CustomerLike,
|
||||
customerType?: "user" | "team",
|
||||
mockMode?: boolean,
|
||||
}) {
|
||||
if (props.mockMode) {
|
||||
return <MockPaymentsPanel title={props.title} />;
|
||||
}
|
||||
if (!props.customer) {
|
||||
return null;
|
||||
}
|
||||
return <RealPaymentsPanel title={props.title} customer={props.customer} customerType={props.customerType ?? "user"} />;
|
||||
}
|
||||
|
||||
function MockPaymentsPanel(props: { title?: string }) {
|
||||
const defaultPaymentMethod: PaymentMethodSummary = {
|
||||
id: "pm_mock",
|
||||
brand: "visa",
|
||||
last4: "4242",
|
||||
exp_month: 12,
|
||||
exp_year: 2030,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{props.title && <h3 className="text-lg font-semibold text-foreground">{props.title}</h3>}
|
||||
<Section
|
||||
title="Payment method"
|
||||
description="Manage the default payment method used for subscriptions and invoices."
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full md:w-[350px]">
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 p-2 rounded-xl border border-black/[0.04] dark:border-white/[0.04] text-foreground shrink-0">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground flex-1">{formatPaymentMethod(defaultPaymentMethod)}</span>
|
||||
<Button disabled variant="outline" className="border-black/[0.08] dark:border-white/[0.08] rounded-xl text-xs font-semibold px-4 py-2">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Active plans"
|
||||
description="View your active plans and purchases."
|
||||
>
|
||||
<div className="flex flex-col gap-4 w-full md:w-[350px]">
|
||||
<div className="flex items-center justify-between gap-4 p-3 bg-zinc-50/50 dark:bg-zinc-900/50 border border-black/[0.04] dark:border-white/[0.04] rounded-xl">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">Pro</span>
|
||||
<span className="text-xs text-muted-foreground/80 mt-0.5">Renews on Jan 1, 2030</span>
|
||||
</div>
|
||||
<Button disabled variant="outline" className="border-black/[0.08] dark:border-white/[0.08] text-xs rounded-xl">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3 bg-zinc-50/50 dark:bg-zinc-900/50 border border-black/[0.04] dark:border-white/[0.04] rounded-xl">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">Credits pack</span>
|
||||
<span className="text-xs text-muted-foreground/80 mt-0.5">One-time purchase</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [setupIntentStripeAccountId, setSetupIntentStripeAccountId] = useState<string | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<{ productId: string, subscriptionId?: string } | null>(null);
|
||||
const [switchFromProductId, setSwitchFromProductId] = useState<string | null>(null);
|
||||
const [switchToProductId, setSwitchToProductId] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{props.title && <h3 className="text-lg font-semibold text-foreground">{props.title}</h3>}
|
||||
|
||||
<Section
|
||||
title="Payment method"
|
||||
description="Manage the default payment method used for subscriptions and invoices."
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full md:w-[350px]">
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 p-2 rounded-xl border border-black/[0.04] dark:border-white/[0.04] text-foreground shrink-0">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground flex-1">
|
||||
{defaultPaymentMethod ? formatPaymentMethod(defaultPaymentMethod) : "No payment method on file"}
|
||||
</span>
|
||||
<Button
|
||||
onClick={openPaymentDialog}
|
||||
variant="outline"
|
||||
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl text-xs font-semibold px-4 py-2 shrink-0"
|
||||
>
|
||||
{defaultPaymentMethod ? "Update" : "Add card"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ActionDialog
|
||||
open={paymentDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closePaymentDialog();
|
||||
}}
|
||||
title="Update payment method"
|
||||
>
|
||||
{!setupIntentClientSecret || !setupIntentStripeAccountId || !stripePromise ? (
|
||||
<div className="space-y-2 p-1">
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
<Skeleton className="h-[120px] w-full rounded-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: setupIntentClientSecret,
|
||||
}}
|
||||
>
|
||||
<SetDefaultPaymentMethodForm
|
||||
clientSecret={setupIntentClientSecret}
|
||||
onSetupIntentSucceeded={async (setupIntentId) => {
|
||||
await props.customer.setDefaultPaymentMethodFromSetupIntent(setupIntentId);
|
||||
closePaymentDialog();
|
||||
}}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
</ActionDialog>
|
||||
</Section>
|
||||
|
||||
{productsForCustomerType.length > 0 && (
|
||||
<Section
|
||||
title="Active plans"
|
||||
description="View your active plans and purchases."
|
||||
>
|
||||
<div className="flex flex-col gap-3 w-full md:w-[350px]">
|
||||
{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 (
|
||||
<div key={product.id ?? `${product.displayName}-${index}`} className="flex items-center justify-between gap-4 p-3 bg-zinc-50/50 dark:bg-zinc-900/50 border border-black/[0.04] dark:border-white/[0.04] rounded-xl">
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground truncate">{product.displayName}{quantitySuffix}</span>
|
||||
<span className="text-xs text-muted-foreground/80 mt-0.5">{subtitle}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{canSwitchPlans && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openSwitchDialog(product.id!, product.switchOptions?.[0]?.productId ?? null)}
|
||||
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl text-xs px-3 py-1.5"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
{isCancelable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelTarget({ productId: product.id ?? "_inline", subscriptionId: product.subscription?.subscriptionId ?? undefined })}
|
||||
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl text-xs text-red-500 hover:text-red-600 px-3 py-1.5"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ActionDialog
|
||||
open={cancelTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
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);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span className="text-sm text-foreground/90 leading-relaxed">
|
||||
Canceling will stop future renewals for this subscription. This action is safe and will take effect at the end of the billing period.
|
||||
</span>
|
||||
</ActionDialog>
|
||||
|
||||
<ActionDialog
|
||||
open={switchFromProductId !== null}
|
||||
onOpenChange={(open) => {
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{switchOptions.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No other plans available for this subscription.
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Choose a plan</label>
|
||||
<Select
|
||||
value={switchToProductId ?? undefined}
|
||||
onValueChange={(value) => setSwitchToProductId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white dark:bg-zinc-900 border-black/[0.08] dark:border-white/[0.08] rounded-xl px-3 py-2 shadow-sm focus-visible:ring-black/[0.06] dark:focus-visible:ring-white/[0.06]">
|
||||
<SelectValue placeholder="Choose a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-black/[0.08] dark:border-white/[0.08] shadow-md">
|
||||
{switchOptions.map((option: any) => (
|
||||
<SelectItem key={option.productId} value={option.productId} className="rounded-lg">
|
||||
{option.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ActionDialog>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{invoices.length > 0 && (
|
||||
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base text-foreground leading-snug">
|
||||
Past Invoices
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1 leading-relaxed">
|
||||
Review your receipts and past billing logs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-black/[0.06] dark:border-white/[0.06] rounded-xl overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<TableRow className="border-b border-black/[0.06] dark:border-white/[0.06]">
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Date</TableHead>
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Status</TableHead>
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Amount</TableHead>
|
||||
<TableHead className="py-3 px-4 text-right w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(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 (
|
||||
<TableRow key={invoiceKey} className="border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 hover:bg-zinc-50/30 dark:hover:bg-zinc-900/30 transition-colors duration-150">
|
||||
<TableCell className="py-3.5 px-4 text-sm font-semibold text-foreground/90">
|
||||
{formatInvoiceDate(invoice.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 px-4 text-xs font-semibold">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold ${
|
||||
invoice.status === "paid"
|
||||
? "bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-400 border border-green-200 dark:border-green-900/30"
|
||||
: "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400 border border-amber-200 dark:border-amber-900/30"
|
||||
}`}>
|
||||
{formatInvoiceStatus(invoice.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 px-4 text-sm font-medium text-foreground/90">
|
||||
{formatInvoiceAmount(invoice.amountTotal)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 px-4 text-right">
|
||||
{invoice.hostedInvoiceUrl ? (
|
||||
<Button asChild variant="outline" className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl text-xs font-semibold px-3 py-1.5">
|
||||
<a href={invoice.hostedInvoiceUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1">
|
||||
View <CaretRight className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unavailable</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<AllowNull extends boolean = false> = {
|
||||
team?: Team;
|
||||
teamId?: string;
|
||||
teams?: Team[];
|
||||
allowNull?: AllowNull;
|
||||
nullLabel?: string;
|
||||
triggerClassName?: string;
|
||||
onChange?: (team: AllowNull extends true ? Team | null : Team) => Promise<void>;
|
||||
// Mock data props
|
||||
mockUser?: {
|
||||
team?: MockTeam;
|
||||
};
|
||||
mockTeams?: MockTeam[];
|
||||
mockProject?: {
|
||||
config: {
|
||||
clientTeamCreationEnabled: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function DashboardTeamSwitcher<AllowNull extends boolean = false>(props: DashboardTeamSwitcherProps<AllowNull>) {
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<Inner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function Fallback() {
|
||||
return <Skeleton className="h-9 w-full max-w-64 rounded-xl" />;
|
||||
}
|
||||
|
||||
function Inner<AllowNull extends boolean>(props: DashboardTeamSwitcherProps<AllowNull>) {
|
||||
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 (
|
||||
<Select
|
||||
value={selectedTeam?.id || (props.allowNull ? 'null-sentinel' : undefined)}
|
||||
onValueChange={(value) => {
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
let team: Team | null = null;
|
||||
if (value !== 'null-sentinel') {
|
||||
team = teams.find(team => team.id === value) || null;
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
await props.onChange(team as any);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={props.triggerClassName}>
|
||||
<SelectValue placeholder="Select team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-black/[0.08] dark:border-white/[0.08] shadow-md">
|
||||
{selectedTeam && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-2 py-1.5 flex items-center justify-between">
|
||||
<span>Current Team</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`#team-${selectedTeam.id}`);
|
||||
}}
|
||||
>
|
||||
<Gear className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</SelectLabel>
|
||||
<SelectItem value={selectedTeam.id} className="rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<TeamIcon team={selectedTeam} />
|
||||
<span className="max-w-[140px] truncate text-sm font-semibold text-foreground/90">{selectedTeam.displayName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{props.allowNull && (
|
||||
<SelectGroup>
|
||||
<SelectItem value="null-sentinel" className="rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<TeamIcon team="personal" />
|
||||
<span className="max-w-[140px] truncate text-sm font-semibold text-foreground/90">{props.nullLabel || 'Personal Account'}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{teams.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-2 py-1.5 mt-1 border-t border-black/[0.04] dark:border-white/[0.04]">Other Teams</SelectLabel>
|
||||
{teams
|
||||
.filter(team => team.id !== selectedTeam?.id)
|
||||
.map(team => (
|
||||
<SelectItem value={team.id} key={team.id} className="rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<TeamIcon team={team} />
|
||||
<span className="max-w-[140px] truncate text-sm font-semibold text-foreground/90">{team.displayName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{project.config.clientTeamCreationEnabled && (
|
||||
<>
|
||||
<SelectSeparator className="bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<div className="p-1">
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate(`#team-creation`);
|
||||
}}
|
||||
className="w-full text-xs font-semibold hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-lg justify-start py-1.5 px-2 h-auto text-left gap-1.5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Create a team
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user