Wire dashboard account settings shell, payments, and handler route.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Developing-Gamer 2026-05-27 12:47:54 -07:00
parent 10688411be
commit 2674857fa6
5 changed files with 1120 additions and 2 deletions

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}