summaryrefslogtreecommitdiffhomepage
path: root/gui
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-02-26 16:07:30 +0100
committerOskar Nyberg <oskar@mullvad.net>2020-04-06 17:47:10 +0200
commitf8efaf18de195f33e6fdf390a3b94e0fa0c4e6e4 (patch)
tree6abd977ec78c64358e3776c103746678484d7154 /gui
parentdab4202bea4b5cfe9121cee513b61a3fb45bbb19 (diff)
downloadmullvadvpn-f8efaf18de195f33e6fdf390a3b94e0fa0c4e6e4.tar.xz
mullvadvpn-f8efaf18de195f33e6fdf390a3b94e0fa0c4e6e4.zip
Add component for submitting voucher
Diffstat (limited to 'gui')
-rw-r--r--gui/src/renderer/app.tsx3
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx69
-rw-r--r--gui/src/renderer/components/RedeemVoucher.tsx203
-rw-r--r--gui/src/renderer/components/RedeemVoucherStyles.tsx43
-rw-r--r--gui/src/renderer/containers/RedeemVoucherContainer.tsx19
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));