Skip to content

Commit 23597a0

Browse files
committed
wip 3
1 parent cf559c1 commit 23597a0

File tree

5 files changed

+195
-71
lines changed

5 files changed

+195
-71
lines changed

components/dashboard/src/SwitchToPAYG.tsx

Lines changed: 185 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import { TeamSubscription, TeamSubscription2 } from "@gitpod/gitpod-protocol/lib
1919
import { useConfetti } from "./contexts/ConfettiContext";
2020
import { resetAllNotifications } from "./AppNotifications";
2121
import { Plans } from "@gitpod/gitpod-protocol/lib/plans";
22+
import ContextMenu, { ContextMenuEntry } from "./components/ContextMenu";
23+
import CaretDown from "./icons/CaretDown.svg";
24+
import { TeamsContext, useCurrentTeam } from "./teams/teams-context";
25+
import { Team } from "@gitpod/gitpod-protocol";
26+
import { OrgEntry } from "./menu/OrganizationSelector";
2227

2328
/**
2429
* Keys of known page params
@@ -40,6 +45,7 @@ type PageParams = {
4045
type PageState = {
4146
phase: "call-to-action" | "trigger-signup" | "wait-for-signup" | "cleanup" | "done";
4247
attributionId?: string;
48+
setupIntentId?: string;
4349
old?: {
4450
planName: string;
4551
planDetails: string;
@@ -56,15 +62,88 @@ function SwitchToPAYG() {
5662
phase: "call-to-action",
5763
});
5864

65+
const currentOrg = useCurrentTeam();
66+
const { teams } = useContext(TeamsContext);
5967
const [errorMessage, setErrorMessage] = useState<string | undefined>();
68+
const [selectedOrganization, setSelectedOrganization] = useState<Team | undefined>(undefined);
6069
const [showBillingSetupModal, setShowBillingSetupModal] = useState<boolean>(false);
6170
const [pendingStripeSubscription, setPendingStripeSubscription] = useState<boolean>(false);
6271
const [droppedConfetti, setDroppedConfetti] = useState<boolean>(false);
6372
const { dropConfetti } = useConfetti();
6473

74+
useEffect(() => {
75+
setSelectedOrganization(currentOrg);
76+
}, [currentOrg, setSelectedOrganization]);
77+
78+
useEffect(() => {
79+
const { phase, attributionId, setupIntentId } = pageState;
80+
if (phase !== "trigger-signup") {
81+
return;
82+
}
83+
console.log("phase: " + phase);
84+
85+
// We're back from the Stripe modal: (safely) trigger the signup
86+
if (!attributionId) {
87+
console.error("Signup, but attributionId not set!");
88+
return;
89+
}
90+
if (!setupIntentId) {
91+
console.error("Signup, but setupIntentId not set!");
92+
setPageState((s) => ({ ...s, phase: "call-to-action" }));
93+
return;
94+
}
95+
96+
let cancelled = false;
97+
(async () => {
98+
// At this point we're coming back from the Stripe modal, and have the intent to setup a new subscription.
99+
// Technically, we have to guard against:
100+
// - reloads
101+
// - unmounts (for whatever reason)
102+
103+
// Do we already have a subscription (co-owner, me in another tab, reload, etc.)?
104+
let subscriptionId = await getGitpodService().server.findStripeSubscriptionId(attributionId);
105+
if (subscriptionId) {
106+
console.log(`${attributionId} already has a subscription! Moving to cleanup`);
107+
// We're happy!
108+
if (!cancelled) {
109+
setPageState((s) => ({ ...s, phase: "cleanup" }));
110+
}
111+
return;
112+
}
113+
114+
// Now we want to signup for sure
115+
setPendingStripeSubscription(true);
116+
try {
117+
if (cancelled) return;
118+
119+
const limit = 1000;
120+
console.log("SUBSCRIBE TO STRIPE");
121+
await getGitpodService().server.subscribeToStripe(attributionId, setupIntentId, limit);
122+
// Here we go off the effect handler due to the await
123+
if (!cancelled) {
124+
setPageState((s) => ({ ...s, phase: "wait-for-signup" }));
125+
}
126+
} catch (error) {
127+
if (cancelled) return;
128+
129+
setErrorMessage(`Could not subscribe to Stripe. ${error?.message || String(error)}`);
130+
setPendingStripeSubscription(false);
131+
return;
132+
}
133+
})().catch(console.error);
134+
135+
return () => {
136+
cancelled = true;
137+
};
138+
}, [pageState, setPageState]);
139+
65140
useEffect(() => {
66141
const { phase, attributionId, old } = pageState;
67142
const { setupIntentId, type, oldSubscriptionOrTeamId } = pageParams || {};
143+
if (phase === "trigger-signup") {
144+
// Handled in separate effect
145+
return;
146+
}
68147

69148
if (!type) {
70149
setErrorMessage("Error during params parsing: type not set!");
@@ -77,7 +156,13 @@ function SwitchToPAYG() {
77156

78157
console.log("phase: " + phase);
79158
switch (phase) {
80-
case "call-to-action":
159+
case "call-to-action": {
160+
// Check: Can we progress?
161+
if (setupIntentId) {
162+
setPageState((s) => ({ ...s, setupIntentId, phase: "trigger-signup" }));
163+
return;
164+
}
165+
81166
// Just verify and display information
82167
let cancelled = false;
83168
(async () => {
@@ -135,7 +220,13 @@ function SwitchToPAYG() {
135220
planName: Plans.getById(ts.planId!)!.name,
136221
planDetails: `${ts.quantity} Members`,
137222
};
138-
// no derivedAttributionId: user has to select/create new org
223+
// User has to select/create new org
224+
if (selectedOrganization) {
225+
derivedAttributionId = AttributionId.render({
226+
kind: "team",
227+
teamId: selectedOrganization.id,
228+
});
229+
}
139230
break;
140231
}
141232

@@ -171,56 +262,6 @@ function SwitchToPAYG() {
171262
}
172263
})().catch(console.error);
173264

174-
return () => {
175-
cancelled = true;
176-
};
177-
178-
case "trigger-signup": {
179-
// We're back from the Stripe modal: (safely) trigger the signup
180-
if (!attributionId) {
181-
console.error("Signup, but attributionId not set!");
182-
return;
183-
}
184-
if (!setupIntentId) {
185-
console.error("Signup, but setupIntentId not set!");
186-
return;
187-
}
188-
189-
let cancelled = false;
190-
(async () => {
191-
// At this point we're coming back from the Stripe modal, and have the intent to setup a new subscription.
192-
// Technically, we have to guard against:
193-
// - reloads
194-
// - unmounts (for whatever reason)
195-
196-
// Do we already have a subscription (co-owner, me in another tab, reload, etc.)?
197-
let subscriptionId = await getGitpodService().server.findStripeSubscriptionId(attributionId);
198-
if (subscriptionId) {
199-
// We're happy!
200-
if (!cancelled) {
201-
setPageState((s) => ({ ...s, phase: "cleanup" }));
202-
}
203-
return;
204-
}
205-
206-
// Now we want to signup for sure
207-
setPendingStripeSubscription(true);
208-
try {
209-
const limit = 1000;
210-
await getGitpodService().server.subscribeToStripe(attributionId, setupIntentId, limit);
211-
212-
if (!cancelled) {
213-
setPageState((s) => ({ ...s, phase: "wait-for-signup" }));
214-
}
215-
} catch (error) {
216-
if (cancelled) return;
217-
218-
setErrorMessage(`Could not subscribe to Stripe. ${error?.message || String(error)}`);
219-
setPendingStripeSubscription(false);
220-
return;
221-
}
222-
})().catch(console.error);
223-
224265
return () => {
225266
cancelled = true;
226267
};
@@ -276,6 +317,7 @@ function SwitchToPAYG() {
276317
setErrorMessage("Error during cleanup: old.oldSubscriptionId not set!");
277318
return;
278319
}
320+
279321
switch (type) {
280322
case "personalSubscription":
281323
getGitpodService()
@@ -289,6 +331,16 @@ function SwitchToPAYG() {
289331
break;
290332

291333
case "teamSubscription":
334+
const attrId = AttributionId.parse(attributionId || "");
335+
if (attrId?.kind === "team") {
336+
// This should always be the case
337+
getGitpodService()
338+
.server.tsAddMembersToOrg(oldSubscriptionId, attrId.teamId)
339+
.catch((error) => {
340+
console.error("Failed to move members to new org.", error);
341+
});
342+
}
343+
292344
getGitpodService()
293345
.server.tsCancel(oldSubscriptionId)
294346
.catch((error) => {
@@ -323,16 +375,25 @@ function SwitchToPAYG() {
323375
}
324376
return;
325377
}
326-
}, [location.search, pageParams, pageState, setPageState, dropConfetti, droppedConfetti]);
378+
}, [
379+
location.search,
380+
pageParams,
381+
pageState,
382+
setPageState,
383+
pendingStripeSubscription,
384+
setPendingStripeSubscription,
385+
selectedOrganization,
386+
dropConfetti,
387+
droppedConfetti,
388+
]);
327389

328390
const onUpgradePlan = useCallback(async () => {
329391
if (pageState.phase !== "call-to-action" || !pageState.attributionId) {
330392
return;
331393
}
332394

333-
setPageState((s) => ({ ...s, phase: "trigger-signup" }));
334395
setShowBillingSetupModal(true);
335-
}, [pageState.phase, pageState.attributionId, setPageState]);
396+
}, [pageState.phase, pageState.attributionId]);
336397

337398
if (!switchToPAYG || !user || !pageParams) {
338399
return (
@@ -366,8 +427,9 @@ function SwitchToPAYG() {
366427

367428
const planName = pageState.old?.planName || "Legacy Plan";
368429
const planDescription = pageState.old?.planDetails || "";
430+
const selectorEntries = getOrganizationSelectorEntries(teams || [], setSelectedOrganization);
369431
return (
370-
<div className="flex flex-col max-h-screen max-w-3xl mx-auto items-center w-full mt-32">
432+
<div className="flex flex-col max-h-screen max-w-3xl mx-auto items-center w-full mt-24">
371433
<h1>{`Update your ${titleModifier}`}</h1>
372434
<div className="w-full text-gray-500 text-center">
373435
Switch to the new pricing model to keep uninterrupted access and get <strong>large workspaces</strong>{" "}
@@ -405,13 +467,61 @@ function SwitchToPAYG() {
405467
additionalStyles: "",
406468
})}
407469
</div>
408-
<div className="w-full mt-6 grid justify-items-center">
409-
{pendingStripeSubscription && (
410-
<div className="w-full mt-6 text-center">
411-
<SpinnerLoader small={false} content="Creating subscription with Stripe" />
412-
</div>
413-
)}
414-
<div className="w-96 mt-10 text-center">
470+
<div className="w-full grid justify-items-center">
471+
<div className="w-96 mt-8 text-center">
472+
{pageParams?.type === "teamSubscription" && (
473+
<div className="w-full">
474+
<p className="text-gray-500 text-center text-base">
475+
Select organization or{" "}
476+
<a className="gp-link" target="_blank" href="/orgs/new">
477+
create a new one
478+
</a>
479+
</p>
480+
<div className="mt-2 flex-col w-full">
481+
<div className="px-8 flex flex-col space-y-2">
482+
<ContextMenu
483+
customClasses="w-full left-0 cursor-pointer"
484+
menuEntries={selectorEntries}
485+
>
486+
<div>
487+
{selectedOrganization ? (
488+
<OrgEntry
489+
id={selectedOrganization.id}
490+
title={selectedOrganization.name}
491+
subtitle=""
492+
iconSize="small"
493+
/>
494+
) : (
495+
<input
496+
className="w-full px-12 cursor-pointer font-semibold"
497+
readOnly
498+
type="text"
499+
value={selectedOrganization}
500+
></input>
501+
)}
502+
<img
503+
src={CaretDown}
504+
title="Select Account"
505+
className="filter-grayscale absolute top-1/2 right-3"
506+
alt="down caret icon"
507+
/>
508+
</div>
509+
</ContextMenu>
510+
</div>
511+
</div>
512+
<div className="mt-2 text-sm text-gray-500 w-full text-center">
513+
Legacy Team Subscription <strong>members</strong> will be moved to the selected
514+
organization, and the new plan will cover all organization usage.
515+
</div>
516+
</div>
517+
)}
518+
</div>
519+
<div className="w-96 mt-8 text-center">
520+
{pendingStripeSubscription && (
521+
<div className="w-full text-center mb-2">
522+
<SpinnerLoader small={true} content="Creating subscription with Stripe" />
523+
</div>
524+
)}
415525
<button
416526
className="w-full"
417527
onClick={onUpgradePlan}
@@ -442,6 +552,18 @@ function SwitchToPAYG() {
442552
);
443553
}
444554

555+
function getOrganizationSelectorEntries(organizations: Team[], setSelectedOrganization: (org: Team) => void) {
556+
const result: ContextMenuEntry[] = [];
557+
for (const org of organizations) {
558+
result.push({
559+
title: org.name,
560+
customContent: <OrgEntry id={org.id} title={org.name} subtitle="" iconSize="small" />,
561+
onClick: () => setSelectedOrganization(org),
562+
});
563+
}
564+
return result;
565+
}
566+
445567
function renderCard(props: {
446568
headline: string;
447569
title: string;

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export const AppRoutes: FunctionComponent<AppRoutesProps> = ({ user, teams }) =>
183183
<div className="container">
184184
<Menu />
185185
{isLocalPreview() && <LocalPreviewAlert />}
186-
<AppNotifications />
186+
{!location.pathname.startsWith("/switch-to-payg") && <AppNotifications />}
187187
<Switch>
188188
<Route path="/new" exact component={CreateWorkspacePage} />
189189
<Route path={projectsPathNew} exact component={NewProject} />

components/dashboard/src/components/org-icon/OrgIcon.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ const TEXT_SIZE_CLASSES = {
1919
medium: "text-xl",
2020
};
2121

22-
type Props = {
22+
export type OrgIconProps = {
2323
id: string;
2424
name: string;
2525
size?: keyof typeof SIZE_CLASSES;
2626
className?: string;
2727
};
28-
export const OrgIcon: FunctionComponent<Props> = ({ id, name, size = "medium", className }) => {
28+
export const OrgIcon: FunctionComponent<OrgIconProps> = ({ id, name, size = "medium", className }) => {
2929
const logoBGClass = consistentClassname(id);
3030
const initials = getOrgInitials(name);
3131
const sizeClasses = SIZE_CLASSES[size];

components/dashboard/src/menu/OrganizationSelector.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { FunctionComponent, useMemo } from "react";
88
import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu";
9-
import { OrgIcon } from "../components/org-icon/OrgIcon";
9+
import { OrgIcon, OrgIconProps } from "../components/org-icon/OrgIcon";
1010
import { useCurrentTeam, useTeamMemberInfos, useTeams } from "../teams/teams-context";
1111
import { useCurrentOrgMember } from "../data/organizations/org-members-query";
1212
import { useCurrentUser } from "../user-context";
@@ -227,11 +227,12 @@ type OrgEntryProps = {
227227
id: string;
228228
title: string;
229229
subtitle: string;
230+
iconSize?: OrgIconProps["size"];
230231
};
231-
const OrgEntry: FunctionComponent<OrgEntryProps> = ({ id, title, subtitle }) => {
232+
export const OrgEntry: FunctionComponent<OrgEntryProps> = ({ id, title, subtitle, iconSize }) => {
232233
return (
233234
<div className="w-full text-gray-400 flex items-center">
234-
<OrgIcon id={id} name={title} className="mr-4" />
235+
<OrgIcon id={id} name={title} className="mr-4" size={iconSize} />
235236
<div className="flex flex-col">
236237
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold">{title}</span>
237238
<span>{subtitle}</span>

0 commit comments

Comments
 (0)