@@ -19,6 +19,11 @@ import { TeamSubscription, TeamSubscription2 } from "@gitpod/gitpod-protocol/lib
19
19
import { useConfetti } from "./contexts/ConfettiContext" ;
20
20
import { resetAllNotifications } from "./AppNotifications" ;
21
21
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" ;
22
27
23
28
/**
24
29
* Keys of known page params
@@ -40,6 +45,7 @@ type PageParams = {
40
45
type PageState = {
41
46
phase : "call-to-action" | "trigger-signup" | "wait-for-signup" | "cleanup" | "done" ;
42
47
attributionId ?: string ;
48
+ setupIntentId ?: string ;
43
49
old ?: {
44
50
planName : string ;
45
51
planDetails : string ;
@@ -56,15 +62,88 @@ function SwitchToPAYG() {
56
62
phase : "call-to-action" ,
57
63
} ) ;
58
64
65
+ const currentOrg = useCurrentTeam ( ) ;
66
+ const { teams } = useContext ( TeamsContext ) ;
59
67
const [ errorMessage , setErrorMessage ] = useState < string | undefined > ( ) ;
68
+ const [ selectedOrganization , setSelectedOrganization ] = useState < Team | undefined > ( undefined ) ;
60
69
const [ showBillingSetupModal , setShowBillingSetupModal ] = useState < boolean > ( false ) ;
61
70
const [ pendingStripeSubscription , setPendingStripeSubscription ] = useState < boolean > ( false ) ;
62
71
const [ droppedConfetti , setDroppedConfetti ] = useState < boolean > ( false ) ;
63
72
const { dropConfetti } = useConfetti ( ) ;
64
73
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
+
65
140
useEffect ( ( ) => {
66
141
const { phase, attributionId, old } = pageState ;
67
142
const { setupIntentId, type, oldSubscriptionOrTeamId } = pageParams || { } ;
143
+ if ( phase === "trigger-signup" ) {
144
+ // Handled in separate effect
145
+ return ;
146
+ }
68
147
69
148
if ( ! type ) {
70
149
setErrorMessage ( "Error during params parsing: type not set!" ) ;
@@ -77,7 +156,13 @@ function SwitchToPAYG() {
77
156
78
157
console . log ( "phase: " + phase ) ;
79
158
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
+
81
166
// Just verify and display information
82
167
let cancelled = false ;
83
168
( async ( ) => {
@@ -135,7 +220,13 @@ function SwitchToPAYG() {
135
220
planName : Plans . getById ( ts . planId ! ) ! . name ,
136
221
planDetails : `${ ts . quantity } Members` ,
137
222
} ;
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
+ }
139
230
break ;
140
231
}
141
232
@@ -171,56 +262,6 @@ function SwitchToPAYG() {
171
262
}
172
263
} ) ( ) . catch ( console . error ) ;
173
264
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
-
224
265
return ( ) => {
225
266
cancelled = true ;
226
267
} ;
@@ -276,6 +317,7 @@ function SwitchToPAYG() {
276
317
setErrorMessage ( "Error during cleanup: old.oldSubscriptionId not set!" ) ;
277
318
return ;
278
319
}
320
+
279
321
switch ( type ) {
280
322
case "personalSubscription" :
281
323
getGitpodService ( )
@@ -289,6 +331,16 @@ function SwitchToPAYG() {
289
331
break ;
290
332
291
333
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
+
292
344
getGitpodService ( )
293
345
. server . tsCancel ( oldSubscriptionId )
294
346
. catch ( ( error ) => {
@@ -323,16 +375,25 @@ function SwitchToPAYG() {
323
375
}
324
376
return ;
325
377
}
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
+ ] ) ;
327
389
328
390
const onUpgradePlan = useCallback ( async ( ) => {
329
391
if ( pageState . phase !== "call-to-action" || ! pageState . attributionId ) {
330
392
return ;
331
393
}
332
394
333
- setPageState ( ( s ) => ( { ...s , phase : "trigger-signup" } ) ) ;
334
395
setShowBillingSetupModal ( true ) ;
335
- } , [ pageState . phase , pageState . attributionId , setPageState ] ) ;
396
+ } , [ pageState . phase , pageState . attributionId ] ) ;
336
397
337
398
if ( ! switchToPAYG || ! user || ! pageParams ) {
338
399
return (
@@ -366,8 +427,9 @@ function SwitchToPAYG() {
366
427
367
428
const planName = pageState . old ?. planName || "Legacy Plan" ;
368
429
const planDescription = pageState . old ?. planDetails || "" ;
430
+ const selectorEntries = getOrganizationSelectorEntries ( teams || [ ] , setSelectedOrganization ) ;
369
431
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 " >
371
433
< h1 > { `Update your ${ titleModifier } ` } </ h1 >
372
434
< div className = "w-full text-gray-500 text-center" >
373
435
Switch to the new pricing model to keep uninterrupted access and get < strong > large workspaces</ strong > { " " }
@@ -405,13 +467,61 @@ function SwitchToPAYG() {
405
467
additionalStyles : "" ,
406
468
} ) }
407
469
</ 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
+ ) }
415
525
< button
416
526
className = "w-full"
417
527
onClick = { onUpgradePlan }
@@ -442,6 +552,18 @@ function SwitchToPAYG() {
442
552
) ;
443
553
}
444
554
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
+
445
567
function renderCard ( props : {
446
568
headline : string ;
447
569
title : string ;
0 commit comments