@@ -1,13 +1,21 @@
1+import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+import cn from 'classnames';
1−import { Just, Nothing } from 'folktale/maybe';
3+import Maybe from 'folktale/maybe';
2−import React from 'react';
3import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
4−import * as need from 'lib/need';
5+import {
6−import { hasReceived, sendMail } from 'lib/inviteMail';
7+ Flex,
7−import { Button, Input, H3, Warning } from 'components/old/Base';
8+ Input,
14+} from 'indigo-react';
15+import { uniq } from 'lodash';
9−// for wallet generation
17+import * as need from 'lib/need';
10−import * as wg from '_walletgen/lib/lib';
18+import * as wg from 'lib/walletgen';
12// for transaction generation and signing
20// for transaction generation and signing
@@ -19,483 +27,471 @@ import {
20} from 'lib/txn';
28} from 'lib/txn';
21import * as tank from 'lib/tank';
29import * as tank from 'lib/tank';
30+import { useLocalRouter } from 'lib/LocalRouter';
32+import MiniBackButton from 'components/MiniBackButton';
33+import { usePointCursor } from 'store/pointCursor';
34+import LoadableButton from 'components/LoadableButton';
35+import useArray from 'lib/useArray';
36+import useForm from 'indigo-react/lib/useForm';
37+import { buildEmailInputConfig } from 'components/Inputs';
38+import { MIN_PLANET } from 'lib/constants';
39+import Highlighted from 'components/Highlighted';
22−import { withNetwork } from 'store/network';
40+import { useNetwork } from 'store/network';
23−import { compose } from 'lib/lib';
41+import { usePointCache } from 'store/pointCache';
24−import { withWallet } from 'store/wallet';
42+import { useWallet } from 'store/wallet';
25−import { withPointCursor } from 'store/pointCursor';
43+import useSetState from 'lib/useSetState';
26−import View from 'components/View';
44+import pluralize from 'lib/pluralize';
45+import useMailer from 'lib/useMailer';
28const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
47const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
29−const GAS_LIMIT = 350000;
48+const GAS_LIMIT = 400000;
30const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
49const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
50+const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
32const STATUS = {
52const STATUS = {
33− INPUT: 'Generate invites',
53+ INPUT: 'INPUT',
34− GENERATING: 'Generating invites',
54+ GENERATING: 'GENERATING',
35− CAN_SEND: 'Send invites',
55+ CAN_SEND: 'CAN_SEND',
36− FUNDING: 'Funding invites',
56+ FUNDING: 'FUNDING',
37− SENDING: 'Sending invites',
57+ SENDING: 'SENDING',
38− DONE: 'Done',
58+ SUCCESS: 'SUCCESS',
39− FAIL: 'Some failed',
59+ FAILURE: 'FAILURE',
42−const EMAIL_STATUS = {
62+const buttonText = (status, count) => {
43− BUSY: '⋯',
63+ switch (status) {
48−class InviteEmail extends React.Component {
53− invitesAvailable: Nothing(),
55− targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
56− status: STATUS.INPUT,
64+ case STATUS.INPUT:
57− errors: Nothing(),
65+ return `Generate ${pluralize(count, 'Invite')}`;
58− //
66+ case STATUS.GENERATING:
59− fundingMessage: Nothing(),
67+ return `Generating ${pluralize(count, 'Invite')}...`;
60− wipInvites: [],
68+ case STATUS.CAN_SEND:
61− };
69+ return `Send ${pluralize(count, 'Invite')}`;
62−
70+ case STATUS.FUNDING:
63− this.findInvited = this.findInvited.bind(this);
71+ return `Waiting on Funds...`;
64− this.generateInvites = this.generateInvites.bind(this);
72+ case STATUS.SENDING:
65− this.sendInvites = this.sendInvites.bind(this);
73+ return `Sending ${pluralize(count, 'Invite')}...`;
66− this.canSend = this.canSend.bind(this);
74+ case STATUS.FAILURE:
67− this.addError = this.addError.bind(this);
75+ default:
68− this.handleEmailInput = this.handleEmailInput.bind(this);
76+ return 'Error';
69− this.addTarget = this.addTarget.bind(this);
70− this.removeTarget = this.removeTarget.bind(this);
71− this.setEmailStatus = this.setEmailStatus.bind(this);
72− this.askForFunding = this.askForFunding.bind(this);
75− componentDidMount() {
76− this.point = need.pointCursor(this.props.pointCursor);
77− this.address = need.addressFromWallet(this.props.wallet);
78− this.contracts = need.contracts(this.props.contracts);
79− this.web3 = need.web3(this.props.web3);
81− console.log(azimuth.delegatedSending);
82− azimuth.delegatedSending
83− .getTotalUsableInvites(this.contracts, this.point)
85− this.setState({ invitesAvailable: Just(count) });
90− componentDidUpdate(prevProps) {
94− async findInvited() {
95− let invited = await azimuth.delegatedSending.getInvited(
99− invited = invited.map(async point => {
100− const active = await azimuth.azimuth.isActive(this.contracts, point);
101− const res = { point: Number(point), active: active };
104− invited = await Promise.all(invited);
105− const total = invited.length;
106− const accepted = invited.filter(i => i.active).length;
107− this.setState({ invited: Just({ total, accepted }) });
110− async getTemporaryWallet() {
80+// world's simplest uid
111− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
81+let id = 0;
112− const owner = await wg.generateOwnershipWallet(0, ticket);
82+const buildInputConfig = (extra = {}) =>
113− return { ticket, owner: owner.keys.address };
83+ buildEmailInputConfig({
84+ name: `email-${id++}`,
85+ placeholder: 'Email Address',
89+const buildAccessoryFor = (dones, errors) => name => {
90+ if (dones[name]) return <AccessoryIcon.Success />;
91+ if (errors[name]) return <AccessoryIcon.Failure />;
92+ return <AccessoryIcon.Pending />;
116− async generateInvites() {
95+// TODO: test with tank, successful txs
117− this.setState({ status: STATUS.GENERATING });
96+export default function InviteEmail() {
97+ // TODO: resumption after error?
98+ const { pop } = useLocalRouter();
99+ const { contracts, web3, networkType } = useNetwork();
100+ const { wallet, walletType, walletHdPath } = useWallet();
101+ const { syncInvites, getInvites } = usePointCache();
102+ const { pointCursor } = usePointCursor();
103+ const point = need.pointCursor(pointCursor);
104+ const { getHasRecieved, syncHasReceivedForEmail, sendMail } = useMailer();
106+ const { availableInvites } = getInvites(point);
107+ const maxInvitesToSend = availableInvites.matchWith({
112+ // manage the array of input configs
115+ { append: appendInput, removeAt: removeInputAt },
117+ [buildInputConfig({ label: 'Email Address' })],
121+ // manage per-input state
122+ const [hovered, setHovered] = useSetState();
123+ const [invites, addInvite, clearInvites] = useSetState();
124+ const [receipts, addReceipt, clearReceipts] = useSetState();
125+ const [errors, addError, clearErrors] = useSetState();
127+ // manage general state that affects the whole form
128+ const [status, setStatus] = useState(STATUS.INPUT);
129+ const [needFunds, setNeedFunds] = useState(null);
130+ const [generalError, setGeneralError] = useState(null);
132+ // derive booleans from status
133+ const canInput = status === STATUS.INPUT;
134+ const canSend = status === STATUS.CAN_SEND;
135+ const isGenerating = status === STATUS.GENERATING;
136+ const isSending = status === STATUS.SENDING;
137+ const isFailed = status === STATUS.FAILURE;
138+ const isDone = status === STATUS.SUCCESS;
140+ // add disabled, error info to input configs
141+ const dynamicConfigs = useMemo(
143+ inputConfigs.map(config => {
144+ config.disabled = !canInput;
145+ const hasReceivedError = getHasRecieved(config.name).matchWith({
146+ Nothing: () => null, // loading
147+ Just: p => p.value && HAS_RECEIVED_TEXT,
149+ config.error = hasReceivedError || errors[config.name];
152+ [inputConfigs, errors, canInput, getHasRecieved]
155+ // construct the state of the set of inputs we're rendering below
156+ const { inputs, pass } = useForm(dynamicConfigs);
157+ // did all of the inputs pass inspection (and there are no general errors)?
158+ const allPass = pass && !generalError;
159+ // the form is submittable iff passing and input is allowed
160+ const canGenerate = allPass && canInput;
161+ // additional inputs can be added iff input is allowed and we have enough
163+ const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
165+ // progress is [0, .length] of invites or receipts, as we're generating them
166+ const progress = isGenerating
167+ ? Object.keys(invites).length
169+ ? Object.keys(receipts).length
171+ const visualProgress = progress === null ? '-' : `${progress + 1}`;
173+ // build a builder for the accessory to the input, depending on status
174+ const accessoryFor = (() => {
175+ if (isGenerating || canSend) return buildAccessoryFor(invites, errors);
176+ if (isSending) return buildAccessoryFor(receipts, errors);
177+ if (isFailed) return buildAccessoryFor({}, errors);
181+ const generateInvites = useCallback(async () => {
182+ const _contracts = contracts.getOrElse(null);
183+ const _web3 = web3.getOrElse(null);
184+ const _wallet = wallet.getOrElse(null);
185+ if (!_contracts || !_web3 || !_wallet) {
186+ // not using need because we want a custom error
187+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
119− const web3 = this.web3;
190+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
120− const targets = this.state.targets;
191+ const chainId = await _web3.eth.net.getId();
121 const planets = await azimuth.delegatedSending.getPlanetsToSend(
192 const planets = await azimuth.delegatedSending.getPlanetsToSend(
122− this.contracts,
193+ _contracts,
123− this.point,
194+ point,
124− targets.length
195+ inputs.length
127 // account for the race condition where invites got used up while we were
198 // account for the race condition where invites got used up while we were
128 // composing our target list
199 // composing our target list
129− if (planets.length < targets.length) {
200+ if (planets.length < inputs.length) {
201+ // resync invites to the cache, since they're out of date
130− this.addError('Can currently only send ' + planets.length + ' invites!');
205+ `Can currently only send ${planets.length} invites. ` +
131− return;
206+ `Please remove invites until you are within the limit.`
134− const nonce = await web3.eth.getTransactionCount(this.address);
210+ let errorCount = 0;
135− const chainId = await web3.eth.net.getId();
211+ clearInvites();
212+ // NB(shrugs) - must be processed in serial because main thread, etc
213+ for (let i = 0; i < inputs.length; i++) {
214+ const input = inputs[i];
216+ const { data: email, name } = inputs[i];
137− // create invite wallets and transactions
218+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
138− let invites = []; // { email, ticket, signedTx, rawTx }
219+ MIN_PLANET
139− for (let i = 0; i < targets.length; i++) {
220+ // we're always giving planets, so generate a ticket of the correct size
140− console.log('generating email', i, targets[i].email);
221+ );
141− this.setEmailStatus(i, EMAIL_STATUS.BUSY);
143− const email = targets[i].email;
223+ const inviteTx = azimuth.delegatedSending.sendPoint(
144− const wallet = await this.getTemporaryWallet();
224+ _contracts,
229+ const signedTx = await signTransaction({
234+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
235+ txn: Maybe.Just(inviteTx),
236+ gasPrice: GAS_PRICE_GWEI.toString(),
237+ gasLimit: GAS_LIMIT.toString(),
242+ const rawTx = hexify(signedTx.serialize());
146− let inviteTx = azimuth.delegatedSending.sendPoint(
244+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
147− this.contracts,
245+ } catch (error) {
148− this.point,
246+ console.error(error);
149− planets[i],
247+ errorCount++;
150− wallet.owner
248+ addError({ [input.name]: `Wallet Error: ${input.email}` });
152− const signedTx = await signTransaction({
154− txn: Just(inviteTx),
155− gasPrice: GAS_PRICE_GWEI.toString(),
156− gasLimit: GAS_LIMIT.toString(),
161− const rawTx = hexify(signedTx.serialize());
163− invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
164− this.setEmailStatus(i, EMAIL_STATUS.DONE);
167− this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
252+ if (errorCount > 0) {
254+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
257+ )} while generating wallets.`
170− async sendInvites() {
275+ const sendInvites = useCallback(async () => {
171− const invites = this.state.wipInvites;
276+ const _web3 = web3.getOrElse(null);
172− const web3 = this.web3;
277+ const _wallet = wallet.getOrElse(null);
173−
278+ if (!_web3 || !_wallet) {
174− //TODO invite status isn't changing/re-rendering... why?
279+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
176− status: STATUS.FUNDING,
177− invites: invites.map(i => {
178− return { ...i, status: Nothing() };
282+ setStatus(STATUS.FUNDING);
182 const tankWasUsed = await tank.ensureFundsFor(
283 const tankWasUsed = await tank.ensureFundsFor(
184− this.point,
285+ point,
185− this.address,
286+ _wallet.address,
186− INVITE_COST * invites.length,
287+ INVITE_COST * inputs.length,
187− invites.map(i => i.rawTx),
288+ inputs.map(input => invites[input.name].rawTx),
188− this.askForFunding,
289+ (address, minBalance, balance) =>
189− () => {
290+ setNeedFunds({ address, minBalance, balance }),
190− this.setState({ fundingMessage: Nothing() });
291+ () => setNeedFunds(false)
194− this.setState({ status: STATUS.SENDING });
294+ setStatus(STATUS.SENDING);
195−
295+ clearReceipts();
196− //TODO nasty loop, probably move into lib... but so many callbacks!
198− for (let i = 0; i < invites.length; i++) {
199− const invite = invites[i];
201− // send transaction. if it fails, inform the user and skip to next
297+ let errorCount = 0;
202− let txHash;
298+ const txAndMailings = inputs.map(async input => {
299+ const invite = invites[input.name];
204− txHash = await sendSignedTransaction(
301+ const txHash = await sendSignedTransaction(
206− Just(invite.signedTx),
303+ Maybe.Just(invite.signedTx),
207 tankWasUsed,
304 tankWasUsed,
308+ // TODO: waitForTransactionConfirm never rejects
309+ const didConfirm = await waitForTransactionConfirm(_web3, txHash);
310+ if (!didConfirm) throw new Error();
210− } catch (err) {
311+ } catch (error) {
211− console.error('invite tx sending failed', err);
312+ console.error(error);
212− this.addError('Invite transaction not sent for ' + invite.email);
313+ errorCount++;
213− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
314+ addError({
214− continue;
315+ [input.name]: `Transaction Failure for ${invite.email}`,
217− // ask the email sender to send this. we can do this prior to tx confirm,
218− // because the sender service waits for confirm & success for us.
219− // if this fails, inform the user and skip to next
221− const mailSuccess = await sendMail(
223− invite.ticket,
321+ await sendMail(invite.email, invite.ticket, invite.rawTx);
226− if (!mailSuccess) throw new Error();
227− this.setEmailStatus(i, EMAIL_STATUS.DONE);
228− } catch (e) {
322+ } catch (error) {
229− console.log('email send failed');
323+ console.error(error);
230− //NOTE this assumes that the transaction succeeds, but we don't know
324+ errorCount++;
231− // that for a fact yet...
232− this.addError(
325+ addError({
233− 'Invite email failed to send for ' +
326+ [input.name]: `Mailing Failure for ${invite.email}`,
235− '. Please give them this ticket: ' +
238− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
242− // update status on transaction confirm, but don't block on it
330+ addReceipt({ [input.name]: true });
243− promises.push(
331+ });
244− waitForTransactionConfirm(web3, txHash).then(success => {
332+
245− if (success) {
333+ await Promise.all(txAndMailings);
246− this.setEmailStatus(i, EMAIL_STATUS.DONE);
334+ // if there are any receipt errors, throw a general error
247− } else {
335+ if (errorCount > 0) {
248− console.log('invite tx rejected');
336+ throw new Error(
249− this.addError('Invite transaction failed for ' + invite.email);
337+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
250− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
338+ errorCount,
252− return success;
340+ )} while sending transactions.`
257− const txStatus = await Promise.all(promises);
344+ web3,
258− if (txStatus.every(e => e)) {
345+ inputs,
259− this.setState({ status: STATUS.DONE });
346+ addReceipt,
355+ const onClick = useCallback(async () => {
356+ setGeneralError(null);
359+ setStatus(STATUS.GENERATING);
360+ await generateInvites();
361+ setStatus(STATUS.CAN_SEND);
260− } else {
362+ } else if (canSend) {
261− this.setState({ status: STATUS.FAIL });
363+ setStatus(STATUS.SENDING);
364+ await sendInvites();
365+ setStatus(STATUS.SUCCESS);
368+ console.error(error);
369+ setGeneralError(error);
370+ setStatus(STATUS.FAILURE);
263− }
372+ }, [canGenerate, canSend, generateInvites, sendInvites]);
265− setEmailStatus(i, status) {
374+ // when inputs update, check to see if any of them are duplicates
266− const targets = this.state.targets;
375+ useEffect(() => {
267− targets[i].status = Just(status);
376+ // compute the list of valid emails
268− this.setState({ targets });
377+ const emails = inputs.map(i => i.data).filter(d => !!d);
269− }
378+ if (uniq(emails).length !== emails.length) {
270−
379+ setGeneralError(new Error(`Duplicate email.`));
272− const newError = this.state.errors.matchWith({
273− Nothing: () => [error],
275− errs.value.push(error);
279− this.setState({ errors: Just(newError) });
282− askForFunding(address, minBalance, balance) {
284− fundingMessage: Just(
285− `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
286− `we'll continue once that's true. Current balance: ${fromWei(
294− handleEmailInput(i, email) {
295− let targets = this.state.targets;
296− targets[i].email = email;
298− // if it's a valid address, check if it has received an invite before
299− if (email.match(/.*@.*\...+/)) {
300− hasReceived(email).then(res => {
301− targets[i].hasReceived = Just(res);
302− this.setState({ targets });
305− targets[i].hasReceived = Nothing();
381+ setGeneralError(null);
308− this.setState({ targets });
385+ // when we transition to done, sync invites because we just sent some
309− }
386+ // and therefore know that state has changed
310−
387+ useEffect(() => {
311− canSend() {
388+ if (isDone) {
312− const targets = this.state.targets;
389+ syncInvites(point);
314− // may not have duplicate targets
315− if (new Set(targets.map(t => t.email)).size !== targets.length)
318− // none may have received invites already
319− for (let i = 0; i < targets.length; i++) {
320− const target = targets[i];
321− const res = target.hasReceived.matchWith({
322− Nothing: () => true,
323− Just: has => has.value,
325− if (res) return false;
327−
391+ }, [isDone, syncInvites, point]);
328− return this.state.invitesAvailable.matchWith({
392+
329− Nothing: () => false,
393+ return (
330− Just: invites => invites.value > 0,
394+ <Grid gap={12}>
331− });
395+ <Grid.Item as={Grid} full>
332− }
396+ <Grid.Item as={Flex} cols={[1, 11]} align="center">
333−
397+ <MiniBackButton onClick={() => pop()} />
334− addTarget() {
398+ </Grid.Item>
335− let targets = this.state.targets;
399+ <Grid.Item cols={[11, 13]} justifySelf="end">
336− targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
400+ {/* use hidden class instead of removing component from dom */}
337− this.setState({ targets });
401+ {/* in order to avoid janky reflow */}
339−
403+ onClick={() => appendInput()}
340− removeTarget() {
404+ disabled={!canAddInvite}
341− let targets = this.state.targets;
405+ className={cn({ hidden: isDone })}
342− targets.length = targets.length - 1;
406+ solid>
343− this.setState({ targets });
407+ +
346− render() {
410+ </Grid.Item>
348− this.state.status === STATUS.DONE ? this.state.targets.length : null;
412+ {isDone && (
350− const invitesAvailable = this.state.invitesAvailable.matchWith({
414+ <Grid.Item as={Text} full>
351− Nothing: () => 'Loading...',
415+ <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
352− Just: invites => {
416+ {pluralize(inputs.length, 'has', 'have')} been successfully sent
353− const dia = dif ? `(-${dif})` : '';
417+ </Grid.Item>
354− return `You currently have ${invites.value}${dia} invitations left.`;
418+ {inputs.map(input => (
355− },
419+ <Grid.Item as={HelpText} key={input.name} full>
356− });
420+ {invites[input.name].email}
358− const invitesSent = this.state.invited.matchWith({
359− Nothing: () => <>{'Loading...'}</>,
361− const dit = dif ? `(+${dif})` : '';
365− {invited.value.total + dit}
366− {' invites you sent, '}
367− {invited.value.accepted}
368− {' have been accepted.'}
374− const error = this.state.errors.matchWith({
375− Nothing: () => null,
377− <Warning className={'mt-8'}>
378− <H3 style={{ marginTop: 0, paddingTop: 0 }}>
379− {'Something went wrong!'}
381− {errors.value.map(err => (
388− const fundingMessage = this.state.fundingMessage.matchWith({
389− Nothing: () => null,
390− Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
394− if (this.state.status === STATUS.DONE) {
395− //TODO proper clickable router thing
396− inputs = '<- Back to Home';
398− const inputDisabled = this.state.status !== STATUS.INPUT;
400− const lessButton = (
402− prop-size={'s narrow'}
404− disabled={inputDisabled || this.state.targets.length === 1}
405− onClick={this.removeTarget}>
410− const moreButton = (
412− prop-size={'s narrow'}
416− this.state.invitesAvailable.matchWith({
417− Nothing: () => true,
418− Just: inv => inv.value <= this.state.targets.length,
421− onClick={this.addTarget}>
426− let targetInputs = [];
427− for (let i = 0; i < this.state.targets.length; i++) {
428− const target = this.state.targets[i];
429− let progress = target.status.matchWith({
431− Just: status => status.value,
436− value={target.email}
437− onChange={value => {
438− this.handleEmailInput(i, value);
440− placeholder={'Email address'}
441− disabled={inputDisabled}
443− {target.hasReceived.matchWith({
444− Nothing: () => null,
446− has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
453− const submitButton = (
455− prop-size={'xl wide'}
458− (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
462− if (this.state.status === STATUS.INPUT) this.generateInvites();
463− if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
469− inputs = (
426+ {!isDone && (
471− {lessButton}
428+ {/* email inputs */}
472− {moreButton}
429+ {inputs.map((input, i) => {
473− {targetInputs}
430+ const isFirst = i === 0;
474− {submitButton}
431+ return (
436+ onMouseOver={() => setHovered({ [input.name]: true })}
437+ onMouseLeave={() => setHovered({ [input.name]: false })}
443+ onValue={email => syncHasReceivedForEmail(email)}
444+ // NB(shrugs): ^ this feels like a hack?
445+ accessory={accessoryFor(input.name)}
448+ (input.focused || hovered[input.name]) &&
455+ onClick={() => removeInputAt(i)}
469+ disabled={!canGenerate && !canSend}
470+ accessory={`${visualProgress}/${inputs.length}`}
474+ {buttonText(status, inputs.length)}
480+ Your ownership address {needFunds.address} needs at least{' '}
481+ {fromWei(needFunds.minBalance)} ETH and currently has{' '}
482+ {fromWei(needFunds.balance)} ETH. Waiting until the account has
490+ <ErrorText>{generalError.message.toString()}</ErrorText>
481− <p>{'send invites here, for planets'}</p>
483− <p>{invitesAvailable}</p>
485− <ul>{invitesSent}</ul>
497−export default compose(