Skip to content

Commit 0f6ae49

Browse files
authored
account payments tab fix (#1147)
1 parent 0f8b23d commit 0f6ae49

3 files changed

Lines changed: 97 additions & 12 deletions

File tree

packages/template/src/components-page/account-settings.tsx

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3+
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
34
import { Skeleton, Typography } from '@stackframe/stack-ui';
45
import { Contact, ShieldCheck, Bell, Monitor, Key, Settings, CirclePlus, CreditCard } from 'lucide-react';
5-
import React, { Suspense } from "react";
6+
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
67
import { useStackApp, useUser } from '..';
78
import { MaybeFullPage } from "../components/elements/maybe-full-page";
89
import { SidebarLayout } from '../components/elements/sidebar-layout';
@@ -88,6 +89,69 @@ export function AccountSettings(props: {
8889
const project = props.mockProject || projectFromHook;
8990
const teams = user?.useTeams() || [];
9091
const billing = user?.useBilling() || null;
92+
const teamsKey = useMemo(() => teams.map(team => team.id).join("|"), [teams]);
93+
const teamsById = useMemo(() => teams, [teamsKey]);
94+
const userRef = useRef(userFromHook ?? null);
95+
const userId = userFromHook?.id ?? null;
96+
const [paymentsAvailability, setPaymentsAvailability] = useState<{
97+
userHasProducts: boolean,
98+
teamIdsWithProducts: Set<string>,
99+
isReady: boolean,
100+
}>(() => ({
101+
userHasProducts: false,
102+
teamIdsWithProducts: new Set<string>(),
103+
isReady: !!props.mockUser,
104+
}));
105+
106+
useEffect(() => {
107+
userRef.current = userFromHook ?? null;
108+
}, [userFromHook]);
109+
110+
useEffect(() => {
111+
if (props.mockUser || !userId) {
112+
return;
113+
}
114+
let cancelled = false;
115+
runAsynchronouslyWithAlert(async () => {
116+
const currentUser = userRef.current;
117+
if (!currentUser || currentUser.id !== userId) {
118+
return;
119+
}
120+
const [userProducts, teamsWithProducts] = await Promise.all([
121+
currentUser.listProducts({ limit: 1 }),
122+
Promise.all(teamsById.map(async (team) => {
123+
const isTeamAdmin = await currentUser.hasPermission(team, "team_admin");
124+
if (!isTeamAdmin) {
125+
return null;
126+
}
127+
const teamProducts = await team.listProducts({ limit: 1 });
128+
const hasTeamProducts = teamProducts.some((product) => product.customerType === "team");
129+
return hasTeamProducts ? team.id : null;
130+
})),
131+
]);
132+
if (cancelled) {
133+
return;
134+
}
135+
const userHasProducts = userProducts.some((product) => product.customerType === "user");
136+
const teamIdsWithProducts = new Set<string>(teamsWithProducts.filter((id): id is string => id !== null));
137+
setPaymentsAvailability({
138+
userHasProducts,
139+
teamIdsWithProducts,
140+
isReady: true,
141+
});
142+
});
143+
return () => {
144+
cancelled = true;
145+
};
146+
}, [props.mockUser, teamsById, userId]);
147+
148+
const teamsWithProducts = useMemo(
149+
() => teamsById.filter(team => paymentsAvailability.teamIdsWithProducts.has(team.id)),
150+
[paymentsAvailability.teamIdsWithProducts, teamsById],
151+
);
152+
const shouldShowPaymentsTab = props.mockUser
153+
|| (paymentsAvailability.isReady
154+
&& (paymentsAvailability.userHasProducts || teamsWithProducts.length > 0));
91155

92156
// If we're not in mock mode and don't have a user, the useUser hook will handle redirect
93157
if (!props.mockUser && !userFromHook) {
@@ -142,15 +206,19 @@ export function AccountSettings(props: {
142206
<ApiKeysPage mockApiKeys={props.mockApiKeys} mockMode={!!props.mockUser} />
143207
</Suspense>,
144208
}] as const : []),
145-
{
209+
...(shouldShowPaymentsTab ? [{
146210
title: t('Payments'),
147211
type: 'item',
148212
id: 'payments',
149213
icon: <Icon name="CreditCard" />,
150214
content: <Suspense fallback={<PaymentsPageSkeleton/>}>
151-
<PaymentsPage mockMode={!!props.mockUser} />
215+
<PaymentsPage
216+
mockMode={!!props.mockUser}
217+
allowPersonal={paymentsAvailability.userHasProducts}
218+
availableTeams={teamsWithProducts}
219+
/>
152220
</Suspense>,
153-
},
221+
}] as const : []),
154222
{
155223
title: t('Settings'),
156224
type: 'item',

packages/template/src/components-page/account-settings/payments/payments-page.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
'use client';
22

3-
import { useState } from "react";
3+
import { useEffect, useState } from "react";
44
import { Team, TeamSwitcher } from "../../..";
55
import { useUser } from "../../../lib/hooks";
66
import { useTranslation } from "../../../lib/translations";
77
import { PageLayout } from "../page-layout";
88
import { PaymentsPanel } from "./payments-panel";
99

10-
export function PaymentsPage(props: { mockMode?: boolean }) {
10+
export function PaymentsPage(props: { mockMode?: boolean, availableTeams?: Team[], allowPersonal?: boolean }) {
1111
const { t } = useTranslation();
1212
const user = useUser({ or: props.mockMode ? "return-null" : "redirect" });
13-
const teams = user?.useTeams() ?? [];
13+
const teams = props.availableTeams ?? user?.useTeams() ?? [];
14+
const allowPersonal = props.allowPersonal ?? true;
1415
const hasTeams = teams.length > 0;
1516
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
16-
const customer = selectedTeam ?? user;
17-
const customerType = selectedTeam ? "team" : "user";
17+
const effectiveSelectedTeam = selectedTeam ?? (!allowPersonal ? (teams[0] ?? null) : null);
18+
const customer = effectiveSelectedTeam ?? (allowPersonal ? user : null);
19+
const customerType = effectiveSelectedTeam ? "team" : "user";
20+
21+
useEffect(() => {
22+
if (props.mockMode) {
23+
return;
24+
}
25+
if (!allowPersonal && !selectedTeam && teams.length > 0) {
26+
setSelectedTeam(teams[0]);
27+
return;
28+
}
29+
if (selectedTeam && !teams.some(team => team.id === selectedTeam.id)) {
30+
setSelectedTeam(allowPersonal ? null : (teams[0] ?? null));
31+
}
32+
}, [allowPersonal, props.mockMode, selectedTeam, teams]);
1833

1934
if (props.mockMode) {
2035
return (
@@ -35,8 +50,9 @@ export function PaymentsPage(props: { mockMode?: boolean }) {
3550
<PageLayout>
3651
{hasTeams ? (
3752
<TeamSwitcher
38-
team={selectedTeam ?? undefined}
39-
allowNull
53+
team={effectiveSelectedTeam ?? undefined}
54+
teams={teams}
55+
allowNull={allowPersonal}
4056
nullLabel={t("Personal")}
4157
onChange={async (team) => {
4258
setSelectedTeam(team);

packages/template/src/components/team-switcher.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type MockTeam = {
3030
type TeamSwitcherProps<AllowNull extends boolean = false> = {
3131
team?: Team,
3232
teamId?: string,
33+
teams?: Team[],
3334
allowNull?: AllowNull,
3435
nullLabel?: string,
3536
triggerClassName?: string,
@@ -76,7 +77,7 @@ function Inner<AllowNull extends boolean>(props: TeamSwitcherProps<AllowNull>) {
7677

7778
const navigate = app.useNavigate();
7879
const project = app.useProject();
79-
const rawTeams = user?.useTeams();
80+
const rawTeams = props.teams ?? user?.useTeams();
8081
const selectedTeam = props.team || rawTeams?.find(team => team.id === props.teamId);
8182
const teams = useMemo(() => rawTeams?.sort((a, b) => b.id === selectedTeam?.id ? 1 : -1), [rawTeams, selectedTeam]);
8283

0 commit comments

Comments
 (0)