Repository: kiwicom/mobile
Pull request #1069 (2018-09-05 09:13:34)

Payment screen showing different UI if there is a saved credit card

RobinCsl: @tbergq 🚀 

Patch:
diff --git a/app/MMB/src/scenes/tripServices/insurance/paymentScene/CardOption.js b/app/MMB/src/scenes/tripServices/insurance/paymentScene/CardOption.js
new file mode 100644
index 00000000..5c6880b3
--- /dev/null
+++ b/app/MMB/src/scenes/tripServices/insurance/paymentScene/CardOption.js
@@ -0,0 +1,89 @@
+// @flow
+
+import * as React from 'react';
+import { View, TouchableWithoutFeedback, Image } from 'react-native';
+import { type TranslationType } from '@kiwicom/mobile-localization';
+import { TextIcon, StyleSheet, Text } from '@kiwicom/mobile-shared';
+import { defaultTokens } from '@kiwicom/mobile-orbit';
+
+type Props = {|
+  +isActive: boolean,
+  +onPress: () => void,
+  +cardOption: TranslationType,
+  +leftIcon: React.Element<typeof TextIcon | typeof Image>,
+|};
+
+export default function CardOption(props: Props) {
+  const leftIconStyle =
+    props.leftIcon.type.displayName === 'Image'
+      ? styles.leftImageStyle
+      : styles.leftIconStyle;
+
+  const leftIcon = React.cloneElement(props.leftIcon, { style: leftIconStyle });
+
+  return (
+    <TouchableWithoutFeedback onPress={props.onPress}>
+      <View
+        style={[
+          styles.cardWrapper,
+          styles.row,
+          props.isActive && styles.cardActive,
+        ]}
+      >
+        <View style={styles.row}>
+          <View style={styles.leftIconContainer}>{leftIcon}</View>
+          <Text style={styles.cardOption}>{props.cardOption}</Text>
+        </View>
+
+        {props.isActive && (
+          <TextIcon code="&#xe01e;" style={styles.checkedIcon} />
+        )}
+      </View>
+    </TouchableWithoutFeedback>
+  );
+}
+
+const styles = StyleSheet.create({
+  cardWrapper: {
+    borderRadius: 6,
+    borderColor: defaultTokens.paletteCloudNormal,
+    borderWidth: 1,
+    padding: 10,
+    marginVertical: 5,
+    android: {
+      elevation: 1,
+      height: 38,
+    },
+    ios: {
+      height: 37,
+    },
+    justifyContent: 'space-between',
+  },
+  cardActive: {
+    borderColor: defaultTokens.paletteProductNormal,
+  },
+  row: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  cardOption: {
+    fontWeight: '600',
+  },
+  checkedIcon: {
+    color: defaultTokens.paletteProductNormal,
+  },
+  leftIconContainer: {
+    width: 30,
+    justifyContent: 'center',
+    alignItems: 'center',
+    marginEnd: 5,
+  },
+  leftIconStyle: {
+    fontSize: 10,
+    color: defaultTokens.paletteInkNormal,
+  },
+  leftImageStyle: {
+    width: 30,
+    flex: 1,
+  },
+});
diff --git a/app/MMB/src/scenes/tripServices/insurance/paymentScene/CardOptions.js b/app/MMB/src/scenes/tripServices/insurance/paymentScene/CardOptions.js
new file mode 100644
index 00000000..cf649b4a
--- /dev/null
+++ b/app/MMB/src/scenes/tripServices/insurance/paymentScene/CardOptions.js
@@ -0,0 +1,103 @@
+// @flow
+
+import * as React from 'react';
+import { View, Image } from 'react-native';
+import { Translation } from '@kiwicom/mobile-localization';
+import { StyleSheet, TextIcon } from '@kiwicom/mobile-shared';
+
+import SecurityCodeInput from './SecurityCodeInput';
+import CardOption from './CardOption';
+import VisaLogo from './images/VisaLogo.imageset/visa_logo.png';
+
+type Props = {|
+  +cardDigits: string,
+  +active: 'SAVED_CARD' | 'ANOTHER_CARD',
+  +setActiveCard: (active: 'SAVED_CARD' | 'ANOTHER_CARD') => void,
+  +onSavedCardSecurityCodeChange: () => void,
+|};
+
+export default class PaymentScene extends React.Component<Props> {
+  onPressAnotherCard = () => {
+    this.props.setActiveCard('ANOTHER_CARD');
+  };
+
+  onPressSavedCard = () => {
+    this.props.setActiveCard('SAVED_CARD');
+  };
+
+  render() {
+    const isSavedCardActive = this.props.active === 'SAVED_CARD';
+    const isAnotherCardActive = this.props.active === 'ANOTHER_CARD';
+
+    return (
+      <React.Fragment>
+        <View style={styles.savedCardContainer}>
+          <View style={styles.savedCard}>
+            <CardOption
+              leftIcon={
+                // TODO Choose image depending on the type of the card
+                <Image source={VisaLogo} resizeMode="contain" />
+              }
+              isActive={isSavedCardActive}
+              onPress={this.onPressSavedCard}
+              cardOption={
+                <Translation
+                  id="mmb.trip_services.insurance.payment.card"
+                  values={{ digits: this.props.cardDigits }}
+                />
+              }
+            />
+          </View>
+          {isSavedCardActive && (
+            <View style={styles.securityCode}>
+              <SecurityCodeInput
+                onSecurityCodeChange={this.props.onSavedCardSecurityCodeChange}
+                inputWrapperStyle={styles.inputWrapperStyle}
+                displayLabel={false}
+                placeholder={
+                  <Translation id="mmb.trip_services.insurance.payment.CVV" />
+                }
+                placeholderStyle={styles.placeholder}
+              />
+            </View>
+          )}
+        </View>
+
+        <CardOption
+          leftIcon={<TextIcon code="&#xe034;" />}
+          isActive={isAnotherCardActive}
+          onPress={this.onPressAnotherCard}
+          cardOption={
+            <Translation id="mmb.trip_services.insurance.payment.use_another_card" />
+          }
+        />
+      </React.Fragment>
+    );
+  }
+}
+
+const styles = StyleSheet.create({
+  inputWrapperStyle: {
+    android: {
+      elevation: 1,
+      height: 38,
+    },
+    ios: {
+      height: 37,
+    },
+  },
+  placeholder: {
+    paddingVertical: 10,
+  },
+  savedCardContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  savedCard: {
+    flex: 1,
+  },
+  securityCode: {
+    flex: 0.5,
+    marginStart: 10,
+  },
+});
diff --git a/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentForm.js b/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentForm.js
new file mode 100644
index 00000000..02dd9a7a
--- /dev/null
+++ b/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentForm.js
@@ -0,0 +1,54 @@
+// @flow
+
+import * as React from 'react';
+import { View } from 'react-native';
+import { StyleSheet } from '@kiwicom/mobile-shared';
+
+import CreditCardNumberInput from './CreditCardNumberInput';
+import ExpiryDateInput from './ExpiryDateInput';
+import SecurityCodeInput from './SecurityCodeInput';
+import CardholdersNameInput from './CardholdersNameInput';
+import RememberCardSwitch from './RememberCardSwitch';
+
+const noop = () => {};
+
+export default function PaymentForm() {
+  return (
+    <React.Fragment>
+      <View style={styles.formRow}>
+        <CreditCardNumberInput onCreditCardChange={noop} />
+      </View>
+      <View style={styles.row}>
+        <View style={styles.halfRow}>
+          <ExpiryDateInput onExpiryDateChange={noop} />
+        </View>
+        <View style={styles.halfRow}>
+          <SecurityCodeInput onSecurityCodeChange={noop} />
+        </View>
+      </View>
+      <View style={styles.formRow}>
+        <CardholdersNameInput onCardholdersNameChange={noop} />
+      </View>
+      <View style={[styles.formRow, styles.rememberCard]}>
+        <RememberCardSwitch onRememberCardChange={noop} />
+      </View>
+    </React.Fragment>
+  );
+}
+
+const styles = StyleSheet.create({
+  formRow: {
+    paddingTop: 15,
+    paddingBottom: 5,
+  },
+  halfRow: {
+    flexGrow: 1,
+  },
+  row: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  rememberCard: {
+    justifyContent: 'space-between',
+  },
+});
diff --git a/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentScene.js b/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentScene.js
index 3d42f770..c02025f9 100644
--- a/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentScene.js
+++ b/app/MMB/src/scenes/tripServices/insurance/paymentScene/PaymentScene.js
@@ -1,60 +1,89 @@
 // @flow
 
 import * as React from 'react';
-import { View } from 'react-native';
+import { View, ScrollView } from 'react-native';
 import { Translation } from '@kiwicom/mobile-localization';
 import { TextButton, StyleSheet } from '@kiwicom/mobile-shared';
 import { defaultTokens } from '@kiwicom/mobile-orbit';
+import { getCard } from '@kiwicom/rnmodules';
 
 import OrderSummary from '../insuranceOverviewScene/orderSummary/OrderSummary';
 import PaymentFormTitle from './PaymentFormTitle';
-import CreditCardNumberInput from './CreditCardNumberInput';
-import ExpiryDateInput from './ExpiryDateInput';
-import SecurityCodeInput from './SecurityCodeInput';
-import CardholdersNameInput from './CardholdersNameInput';
-import RememberCardSwitch from './RememberCardSwitch';
+import CardOptions from './CardOptions';
+import PaymentForm from './PaymentForm';
 
 const noop = () => {};
 
-export default class PaymentScene extends React.Component<{||}> {
-  onPress = () => {
+type CardType = {|
+  +cardholder: string,
+  +expiryMonth: string,
+  +expiryYear: string,
+  +number: string,
+|};
+
+type State = {|
+  card: ?CardType,
+  active: 'SAVED_CARD' | 'ANOTHER_CARD',
+|};
+
+export default class PaymentScene extends React.Component<{||}, State> {
+  state = {
+    card: null,
+    active: 'ANOTHER_CARD',
+  };
+
+  async componentDidMount() {
+    const card = await getCard();
+    if (card != null) {
+      this.setState({ card, active: 'SAVED_CARD' });
+    }
+  }
+  onPressPaymentButton = () => {
     console.warn('TODO');
   };
+  setActiveCard = (pressed: 'SAVED_CARD' | 'ANOTHER_CARD') => {
+    if (this.state.active !== pressed) {
+      this.setState({
+        active: pressed,
+      });
+    }
+  };
+
   render() {
+    const isAnotherCardActive = this.state.active === 'ANOTHER_CARD';
+
+    const cardDigits =
+      this.state.card != null ? this.state.card.number.slice(-4) : '';
+
     return (
       <React.Fragment>
-        <View style={styles.container}>
-          <View style={styles.formRow}>
-            <PaymentFormTitle />
-          </View>
-          <View style={styles.formRow}>
-            <CreditCardNumberInput onCreditCardChange={noop} />
-          </View>
-          <View style={styles.row}>
-            <View style={styles.halfRow}>
-              <ExpiryDateInput onExpiryDateChange={noop} />
+        <ScrollView>
+          <View style={styles.container}>
+            <View style={styles.formRow}>
+              <PaymentFormTitle />
             </View>
-            <View style={styles.halfRow}>
-              <SecurityCodeInput onSecurityCodeChange={noop} />
-            </View>
-          </View>
-          <View style={styles.formRow}>
-            <CardholdersNameInput onCardholdersNameChange={noop} />
+            {this.state.card != null && (
+              <CardOptions
+                active={this.state.active}
+                setActiveCard={this.setActiveCard}
+                onSavedCardSecurityCodeChange={noop}
+                cardDigits={cardDigits}
+              />
+            )}
+
+            {isAnotherCardActive && <PaymentForm />}
           </View>
-          <View style={[styles.formRow, styles.rememberCard]}>
-            <RememberCardSwitch onRememberCardChange={noop} />
+          <View style={[styles.container, styles.buttonContainer]}>
+            <TextButton
+              title={
+                <Translation id="mmb.trip_services.insurance.payment.pay_now" />
+              }
+              onPress={this.onPressPaymentButton}
+              // TODO
+              disabled={true}
+            />
           </View>
-        </View>
-        <View style={[styles.container, styles.buttonContainer]}>
-          <TextButton
-            title={
-              <Translation id="mmb.trip_services.insurance.payment.pay_now" />
-            }
-            onPress={this.onPress}
-            // TODO
-            disabled={true}
-          />
-        </View>
+        </ScrollView>
 
         <OrderSummary />
       </React.Fragment>
@@ -71,16 +100,6 @@ const styles = StyleSheet.create({
     paddingTop: 15,
     paddingBottom: 5,
   },
-  halfRow: {
-    flexGrow: 1,
-  },
-  row: {
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  rememberCard: {
-    justifyContent: 'space-between',
-  },
   buttonContainer: {
     marginTop: 10,
   },
diff --git a/app/MMB/src/scenes/tripServices/insurance/paymentScene/SecurityCodeInput.js b/app/MMB/src/scenes/tripServices/insurance/paymentScene/SecurityCodeInput.js
index acf9c3f6..b3bf254a 100644
--- a/app/MMB/src/scenes/tripServices/insurance/paymentScene/SecurityCodeInput.js
+++ b/app/MMB/src/scenes/tripServices/insurance/paymentScene/SecurityCodeInput.js
@@ -3,14 +3,26 @@
 import * as React from 'react';
 import { View, TouchableWithoutFeedback } from 'react-native';
 import { Translation, Alert } from '@kiwicom/mobile-localization';
-import { TextInput, StyleSheet, TextIcon } from '@kiwicom/mobile-shared';
+import {
+  TextInput,
+  StyleSheet,
+  TextIcon,
+  type StylePropType,
+} from '@kiwicom/mobile-shared';
 import { defaultTokens } from '@kiwicom/mobile-orbit';
 
 type Props = {|
   +onSecurityCodeChange: () => void,
+  +inputWrapperStyle?: StylePropType,
+  +placeholder?: React.Element<typeof Translation>,
+  +placeholderStyle?: StylePropType,
+  +displayLabel: boolean,
 |};
 
 export default class SecurityCodeInput extends React.Component<Props> {
+  static defaultProps = {
+    displayLabel: true,
+  };
   onPress = () => {
     Alert.translatedAlert(
       { id: 'mmb.trip_services.insurance.payment.security_code' },
@@ -19,18 +31,35 @@ export default class SecurityCodeInput extends React.Component<Props> {
       },
     );
   };
+
   render() {
+    let label;
+    let helpIconOffset;
+    if (this.props.displayLabel) {
+      label = (
+        <Translation id="mmb.trip_services.insurance.payment.security_code" />
+      );
+      helpIconOffset = styles.helpIconOffset;
+    }
+    let placeholderProps;
+    if (this.props.placeholder != null) {
+      placeholderProps = {
+        placeholder: this.props.placeholder,
+        placeholderStyle: this.props.placeholderStyle,
+      };
+    }
     return (
       <React.Fragment>
         <TextInput
-          label={
-            <Translation id="mmb.trip_services.insurance.payment.security_code" />
-          }
+          label={label}
           onChangeText={this.props.onSecurityCodeChange}
           keyboardType="numeric"
+          maxLength={4}
+          inputWrapperStyle={this.props.inputWrapperStyle}
+          {...placeholderProps}
         />
         <TouchableWithoutFeedback onPress={this.onPress}>
-          <View style={styles.helpIcon}>
+          <View style={[styles.helpIcon, helpIconOffset]}>
             <TextIcon code="F" style={styles.icon} />
           </View>
         </TouchableWithoutFeedback>
@@ -42,9 +71,12 @@ export default class SecurityCodeInput extends React.Component<Props> {
 const styles = StyleSheet.create({
   helpIcon: {
     position: 'absolute',
+    end: 0,
+    padding: 10,
+  },
+  helpIconOffset: {
     end: 15,
     bottom: 2.5,
-    padding: 10,
   },
   icon: {
     color: defaultTokens.paletteProductNormal,
diff --git a/packages/localization/src/vocabularies/ManageMyBooking.js b/packages/localization/src/vocabularies/ManageMyBooking.js
index eaaa8297..56a5d3bd 100644
--- a/packages/localization/src/vocabularies/ManageMyBooking.js
+++ b/packages/localization/src/vocabularies/ManageMyBooking.js
@@ -252,6 +252,9 @@ export default {
     'Remember this card for next time',
   'mmb.trip_services.insurance.payment.security_code.alert_message':
     'The security code (CVC/CVV/CID) is the 3 or 4 digit number on the back of your card or on the front for American Express.',
+  'mmb.trip_services.insurance.payment.card': 'Card •••• __1_digits__',
+  'mmb.trip_services.insurance.payment.CVV': 'CVV',
+  'mmb.trip_services.insurance.payment.use_another_card': 'Use another card',
 
   // MMB Transportation
   'mmb.trip_services.transportation.map.title': 'Get transportation',
diff --git a/packages/shared/src/forms/TextInput.js b/packages/shared/src/forms/TextInput.js
index 847c7927..8a6bb8d9 100644
--- a/packages/shared/src/forms/TextInput.js
+++ b/packages/shared/src/forms/TextInput.js
@@ -11,6 +11,7 @@ import Text from '../Text';
 
 type Props = {|
   +placeholder?: React.Element<typeof Translation>,
+  +placeholderStyle?: StylePropType,
   +defaultValue?: string,
   +autoFocus?: boolean,
   +onChangeText?: (text: string) => void,
@@ -19,6 +20,7 @@ type Props = {|
   +label?: React.Element<typeof Translation>,
   +labelStyle?: StylePropType,
   +inputWrapperStyle?: StylePropType,
+  +maxLength?: number,
 |};
 
 type State = {|
@@ -67,7 +69,11 @@ export default class TextInput extends React.Component<Props, State> {
         {this.props.placeholder &&
           this.state.displayPlaceholder && (
             <Text
-              style={[styleSheet.text, styleSheet.placeholder]}
+              style={[
+                styleSheet.text,
+                styleSheet.placeholder,
+                this.props.placeholderStyle,
+              ]}
               pointerEvents="none"
             >
               {this.props.placeholder}


Test patch:
diff --git a/packages/shared/src/forms/__tests__/__snapshots__/TextInput.test.js.snap b/packages/shared/src/forms/__tests__/__snapshots__/TextInput.test.js.snap
index d6265908..1acfd11b 100644
--- a/packages/shared/src/forms/__tests__/__snapshots__/TextInput.test.js.snap
+++ b/packages/shared/src/forms/__tests__/__snapshots__/TextInput.test.js.snap
@@ -182,6 +182,7 @@ exports[`renders with placeholder 1`] = `
             "paddingVertical": 15,
             "position": "absolute",
           },
+          undefined,
         ]
       }
     >