diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-02-26 16:07:30 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-04-06 17:47:10 +0200 |
| commit | f8efaf18de195f33e6fdf390a3b94e0fa0c4e6e4 (patch) | |
| tree | 6abd977ec78c64358e3776c103746678484d7154 /gui/src/renderer | |
| parent | dab4202bea4b5cfe9121cee513b61a3fb45bbb19 (diff) | |
| download | mullvadvpn-f8efaf18de195f33e6fdf390a3b94e0fa0c4e6e4.tar.xz mullvadvpn-f8efaf18de195f33e6fdf390a3b94e0fa0c4e6e4.zip | |
Add component for submitting voucher
Diffstat (limited to 'gui/src/renderer')
| -rw-r--r-- | gui/src/renderer/app.tsx | 3 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorView.tsx | 69 | ||||
| -rw-r--r-- | gui/src/renderer/components/RedeemVoucher.tsx | 203 | ||||
| -rw-r--r-- | gui/src/renderer/components/RedeemVoucherStyles.tsx | 43 | ||||
| -rw-r--r-- | gui/src/renderer/containers/RedeemVoucherContainer.tsx | 19 |
5 files changed, 333 insertions, 4 deletions
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 6d6c2ecccb..82858ad3d9 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -43,6 +43,7 @@ import { RelaySettings, RelaySettingsUpdate, TunnelState, + VoucherResponse, } from '../shared/daemon-rpc-types'; interface IPreferredLocaleDescriptor { @@ -272,7 +273,7 @@ export default class AppRenderer { } } - public async submitVoucher(voucherCode: string) { + public submitVoucher(voucherCode: string): Promise<VoucherResponse> { return IpcRendererEventChannel.account.submitVoucher(voucherCode); } diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx index 7f50d7340f..68f0c9c30b 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -5,6 +5,7 @@ import { links } from '../../config.json'; import AccountExpiry from '../../shared/account-expiry'; import { AccountToken } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; +import RedeemVoucherContainer from '../containers/RedeemVoucherContainer'; import { LoginState } from '../redux/account/reducers'; import AccountTokenLabel from './AccountTokenLabel'; import * as AppButton from './AppButton'; @@ -12,6 +13,11 @@ import * as Cell from './Cell'; import styles from './ExpiredAccountErrorViewStyles'; import ImageView from './ImageView'; import { ModalAlert, ModalAlertType } from './Modal'; +import { + RedeemVoucherInput, + RedeemVoucherResponse, + RedeemVoucherSubmitButton, +} from './RedeemVoucher'; export enum RecoveryAction { openBrowser, @@ -33,6 +39,8 @@ interface IExpiredAccountErrorViewProps { interface IExpiredAccountErrorViewState { showBlockWhenDisconnectedAlert: boolean; + showRedeemVoucherAlert: boolean; + redeemingVoucher: boolean; } export default class ExpiredAccountErrorView extends Component< @@ -41,6 +49,8 @@ export default class ExpiredAccountErrorView extends Component< > { public state: IExpiredAccountErrorViewState = { showBlockWhenDisconnectedAlert: false, + showRedeemVoucherAlert: false, + redeemingVoucher: false, }; public componentDidUpdate() { @@ -64,8 +74,15 @@ export default class ExpiredAccountErrorView extends Component< )} {this.renderExternalPaymentButton()} + + <AppButton.GreenButton + disabled={this.getRecoveryAction() === RecoveryAction.disconnect} + onPress={this.onOpenRedeemVoucherAlert}> + {messages.pgettext('connect-view', 'Redeem voucher')} + </AppButton.GreenButton> </View> + {this.state.showRedeemVoucherAlert && this.renderRedeemVoucherAlert()} {this.state.showBlockWhenDisconnectedAlert && this.renderBlockWhenDisconnectedAlert()} </View> ); @@ -149,7 +166,7 @@ export default class ExpiredAccountErrorView extends Component< <AppButton.BlockingButton disabled={this.getRecoveryAction() === RecoveryAction.disconnect} onPress={this.onOpenExternalPayment}> - <AppButton.GreenButton> + <AppButton.GreenButton style={styles.button}> <AppButton.Label>{buttonText}</AppButton.Label> <AppButton.Icon source="icon-extLink" height={16} width={16} /> </AppButton.GreenButton> @@ -157,8 +174,30 @@ export default class ExpiredAccountErrorView extends Component< ); } - private isNewAccount() { - return this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account'; + private renderRedeemVoucherAlert() { + return ( + <RedeemVoucherContainer + onSubmit={this.onVoucherSubmit} + onSuccess={this.props.hideWelcomeView} + onFailure={this.onVoucherResponse}> + <ModalAlert + buttons={[ + <RedeemVoucherSubmitButton key="submit" />, + <AppButton.BlueButton + key="cancel" + disabled={this.state.redeemingVoucher} + onPress={this.onCloseRedeemVoucherAlert}> + {messages.pgettext('connect-view', 'Cancel')} + </AppButton.BlueButton>, + ]}> + <Text style={styles.fieldLabel}> + {messages.pgettext('connect-view', 'Enter voucher code')} + </Text> + <RedeemVoucherInput /> + <RedeemVoucherResponse /> + </ModalAlert> + </RedeemVoucherContainer> + ); } private renderBlockWhenDisconnectedAlert() { @@ -195,6 +234,10 @@ export default class ExpiredAccountErrorView extends Component< ); } + private isNewAccount() { + return this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account'; + } + private onOpenExternalPayment = async (): Promise<void> => { if (this.getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { this.setState({ showBlockWhenDisconnectedAlert: true }); @@ -215,6 +258,26 @@ export default class ExpiredAccountErrorView extends Component< } } + private onOpenRedeemVoucherAlert = () => { + if (this.getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { + this.setState({ showBlockWhenDisconnectedAlert: true }); + } else { + this.setState({ showRedeemVoucherAlert: true }); + } + }; + + private onCloseRedeemVoucherAlert = () => { + this.setState({ showRedeemVoucherAlert: false }); + }; + + private onVoucherSubmit = () => { + this.setState({ redeemingVoucher: true }); + }; + + private onVoucherResponse = () => { + this.setState({ redeemingVoucher: false }); + }; + private onCloseBlockWhenDisconnectedInstructions = () => { this.setState({ showBlockWhenDisconnectedAlert: false }); }; diff --git a/gui/src/renderer/components/RedeemVoucher.tsx b/gui/src/renderer/components/RedeemVoucher.tsx new file mode 100644 index 0000000000..453200ab7c --- /dev/null +++ b/gui/src/renderer/components/RedeemVoucher.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { Component, Text, TextInput, View } from 'reactxp'; +import { colors } from '../../config.json'; +import { VoucherResponse } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import * as AppButton from './AppButton'; +import ImageView from './ImageView'; +import styles from './RedeemVoucherStyles'; + +interface IRedeemVoucherContextValue { + onSubmit: () => void; + value: string; + setValue: (value: string) => void; + valueValid: boolean; + submitting: boolean; + response?: VoucherResponse; +} + +const contextProviderMissingError = new Error('<RedeemVoucherContext.Provider> is missing'); + +const RedeemVoucherContext = React.createContext<IRedeemVoucherContextValue>({ + onSubmit() { + throw contextProviderMissingError; + }, + get value(): string { + throw contextProviderMissingError; + }, + setValue(_) { + throw contextProviderMissingError; + }, + get valueValid(): boolean { + throw contextProviderMissingError; + }, + get submitting(): boolean { + throw contextProviderMissingError; + }, + get response(): VoucherResponse { + throw contextProviderMissingError; + }, +}); + +interface IRedeemVoucherProps { + submitVoucher: (voucherCode: string) => Promise<VoucherResponse>; + updateAccountExpiry: (expiry: string) => void; + onSubmit?: () => void; + onSuccess?: () => void; + onFailure?: () => void; + children?: React.ReactNode; +} + +interface IRedeemVoucherState { + value: string; + submitting: boolean; + response?: VoucherResponse; +} + +export class RedeemVoucher extends Component<IRedeemVoucherProps, IRedeemVoucherState> { + public state = { + value: '', + submitting: false, + response: undefined, + }; + + public render() { + return ( + <RedeemVoucherContext.Provider + value={{ + onSubmit: this.onSubmit, + value: this.state.value, + setValue: this.setValue, + valueValid: RedeemVoucher.isValueValid(this.state.value), + submitting: this.state.submitting, + response: this.state.response, + }}> + {this.props.children} + </RedeemVoucherContext.Provider> + ); + } + + private setValue = (value: string) => { + this.setState({ value }); + }; + + private static isValueValid(value: string): boolean { + return value.length >= 16; + } + + private onSubmit = async () => { + if (!RedeemVoucher.isValueValid(this.state.value)) { + return; + } + + this.setState({ submitting: true }); + + if (this.props.onSubmit) { + this.props.onSubmit(); + } + + const response = await this.props.submitVoucher(this.state.value); + + if (response.type === 'success') { + this.setState({ value: '', submitting: false, response }); + this.props.updateAccountExpiry(response.new_expiry); + if (this.props.onSuccess) { + this.props.onSuccess(); + } + } else { + this.setState({ submitting: false, response }); + if (this.props.onFailure) { + this.props.onFailure(); + } + } + }; +} + +export class RedeemVoucherInput extends Component { + public render() { + return ( + <RedeemVoucherContext.Consumer> + {(context) => ( + <View> + <TextInput + style={styles.textInput} + value={context.value} + placeholder={'XXXX-XXXX-XXXX-XXXX'} + placeholderTextColor={colors.blue40} + autoCorrect={false} + onChangeText={context.setValue} + onSubmitEditing={context.onSubmit} + /> + </View> + )} + </RedeemVoucherContext.Consumer> + ); + } +} + +export class RedeemVoucherResponse extends Component { + public render() { + return ( + <RedeemVoucherContext.Consumer> + {(context) => { + if (context.submitting) { + return ( + <ImageView source="icon-spinner" style={styles.spinner} height={20} width={20} /> + ); + } + + if (context.response) { + switch (context.response.type) { + case 'success': + return ( + <Text style={styles.redeemVoucherResponseSuccess}> + {messages.pgettext('redeem-voucher-view', 'Voucher was successfully redeemed.')} + </Text> + ); + case 'invalid': + return ( + <Text style={styles.redeemVoucherResponseError}> + {messages.pgettext('redeem-voucher-view', 'Voucher code is invalid.')} + </Text> + ); + case 'already_used': + return ( + <Text style={styles.redeemVoucherResponseError}> + {messages.pgettext( + 'redeem-voucher-view', + 'Voucher code has already been used.', + )} + </Text> + ); + case 'error': + return ( + <Text style={styles.redeemVoucherResponseError}> + {messages.pgettext('redeem-voucher-view', 'An error occured.')} + </Text> + ); + } + } + + return <View style={styles.redeemVoucherResponseEmpty} />; + }} + </RedeemVoucherContext.Consumer> + ); + } +} + +export class RedeemVoucherSubmitButton extends Component { + public render() { + return ( + <RedeemVoucherContext.Consumer> + {(context) => ( + <AppButton.GreenButton + key="cancel" + disabled={!context.valueValid || context.submitting} + onPress={context.onSubmit}> + {messages.pgettext('redeem-voucher-view', 'Redeem')} + </AppButton.GreenButton> + )} + </RedeemVoucherContext.Consumer> + ); + } +} diff --git a/gui/src/renderer/components/RedeemVoucherStyles.tsx b/gui/src/renderer/components/RedeemVoucherStyles.tsx new file mode 100644 index 0000000000..00eea8340d --- /dev/null +++ b/gui/src/renderer/components/RedeemVoucherStyles.tsx @@ -0,0 +1,43 @@ +import { Styles } from 'reactxp'; +import { colors } from '../../config.json'; + +export default { + textInput: Styles.createTextInputStyle({ + flex: 1, + overflow: 'hidden', + paddingTop: 14, + paddingLeft: 14, + paddingRight: 14, + paddingBottom: 14, + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '600', + lineHeight: 26, + color: colors.blue, + backgroundColor: colors.white, + borderRadius: 4, + }), + redeemVoucherResponseSuccess: Styles.createTextStyle({ + marginTop: 8, + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '600', + lineHeight: 20, + color: colors.green, + }), + redeemVoucherResponseError: Styles.createTextStyle({ + marginTop: 8, + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '800', + lineHeight: 20, + color: colors.red, + }), + redeemVoucherResponseEmpty: Styles.createViewStyle({ + height: 20, + marginTop: 8, + }), + spinner: Styles.createViewStyle({ + marginTop: 8, + }), +}; diff --git a/gui/src/renderer/containers/RedeemVoucherContainer.tsx b/gui/src/renderer/containers/RedeemVoucherContainer.tsx new file mode 100644 index 0000000000..1aeee8c719 --- /dev/null +++ b/gui/src/renderer/containers/RedeemVoucherContainer.tsx @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { RedeemVoucher } from '../components/RedeemVoucher'; +import withAppContext, { IAppContext } from '../context'; +import { ReduxDispatch } from '../redux/store'; +import accountActions from '../redux/account/actions'; + +const mapDispatchToProps = (dispatch: ReduxDispatch, props: IAppContext) => { + const account = bindActionCreators(accountActions, dispatch); + + return { + submitVoucher: (voucherCode: string) => { + return props.app.submitVoucher(voucherCode); + }, + updateAccountExpiry: account.updateAccountExpiry, + }; +}; + +export default withAppContext(connect(null, mapDispatchToProps)(RedeemVoucher)); |
