/* @flow */ import React from 'react'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import Flux from '../lib/flux'; import PaymentModalStore from '../stores/PaymentModalStore'; import BillingActionCreators from '../actions/BillingActionCreators'; import PaymentModalActionCreators from '../actions/PaymentModalActionCreators'; import FancyCreditCardInput from './common/FancyCreditCardInput'; import Spinner from './common/Spinner'; import Shakeable from './common/Shakeable'; import i18n from '../i18n'; import classNames from 'classnames'; import Animated from '../lib/animated'; import TimerMixin from 'react-timer-mixin'; import '../styles/payment_modal.styl'; import { FormStates, PaymentModelModes, FancyInputRefs, PremiumPlans, PremiumPlanPrices, CurrencySymbols, CurrencyCodes, } from '../Constants'; import AlertActionCreators from '../actions/AlertActionCreators'; import {ensureStripeIsLoaded} from '../utils/StripeUtils'; import bodymovin from 'bodymovin'; import AnimationManager from '../utils/AnimationManager'; const Animation = { NAME: 'premium-payment-modal-animation-v1', URL: 'https://cdn.discordapp.com/animations/premium/v1/data.js', }; const FANCY_INPUT_REF = 'FANCY_INPUT_REF'; const BODYMOVIN_REF = 'BODYMOVIN_REF'; const SHAKE_REF = 'SHAKE_REF'; const ANIMATION_REF = 'ANIMATION_REF'; const SHAKE_INTENSITY = 1; const ErrorMessages = { [FancyInputRefs.CARD_NUMBER]: i18n.Messages.CREDIT_CARD_ERROR_NUMBER, [FancyInputRefs.EXPIRATION]: i18n.Messages.CREDIT_CARD_ERROR_EXPIRATION, [FancyInputRefs.SECURITY_CODE]: i18n.Messages.CREDIT_CARD_ERROR_SECURITY_CODE, [FancyInputRefs.ZIP_CODE]: i18n.Messages.CREDIT_CARD_ERROR_ZIP_CODE, }; const SceneLoops = { Idle: { BEG: 0, END: 600, }, Speed: { BEG: 601, END: 668, }, SpeedLoop: { BEG: 637, END: 668, }, Activation: { BEG: 669, END: 790, }, }; function processErrors(errors) { if (!errors || !errors.length) { return undefined; } const errorDict = {}; errors.forEach(input => { errorDict[input] = ErrorMessages[input]; }); return errorDict; } const SuccessAnimation = React.createClass({ mixins: [PureRenderMixin], _animating: false, getInitialState() { return { borderColor: new Animated.Value(0), borderWidth: new Animated.Value(1), backgroundColor: new Animated.Value(0), containerPosition: new Animated.Value(0), activatedPosition: new Animated.Value(0), shinePosition: new Animated.Value(0), }; }, isAnimating() { return this._animating; }, start() { if (this._animating) { return; } // We can't use State since setting state is async this._animating = true; this.flyIn(); }, flyIn() { const {activatedPosition} = this.state; Animated.sequence([ Animated.spring(activatedPosition, { toValue: 1, friction: 10, tension: 100, }), ]).start(this.activate); }, activate() { const {backgroundColor, shinePosition} = this.state; const shine = Animated.timing(shinePosition, { toValue: 1, duration: 450, }); const flash = Animated.sequence([ Animated.timing(backgroundColor, { delay: 300, toValue: 1, duration: 80, }), Animated.timing(backgroundColor, { toValue: 0, duration: 200, }), ]); Animated.sequence([Animated.delay(1400), Animated.parallel([shine, flash])]).start(); }, render() { return ( ); }, getActivatedStyle() { const {activatedPosition} = this.state; return { transform: [ { translateX: activatedPosition.interpolate({ inputRange: [0, 1], outputRange: ['-200%', '0%'], }), }, ], }; }, getShineStyle() { const {shinePosition} = this.state; return { transform: [ { translateX: shinePosition.interpolate({ inputRange: [0, 1], outputRange: ['0px', '413px'], }), }, ], }; }, getContainerStyle() { const {borderWidth, backgroundColor, containerPosition} = this.state; return { borderWidth: borderWidth.interpolate({ inputRange: [0, 1], outputRange: ['0px', '2px'], }), backgroundColor: backgroundColor.interpolate({ inputRange: [0, 1], outputRange: ['rgba(255,255,255,0.0)', 'rgba(255,255,255,1.0)'], }), transform: [ { translateX: containerPosition.interpolate({ inputRange: [0, 1], outputRange: ['0px', '10px'], }), }, ], }; }, }); const PaymentModal = React.createClass({ mixins: [PureRenderMixin, Flux.LazyStoreListenerMixin(PaymentModalStore), TimerMixin], statics: { modalConfig: { closable: true, store: PaymentModalStore, }, }, getInitialState() { return { containerPosition: new Animated.Value(0), animationLoaded: AnimationManager.has(Animation.NAME), readyToSubmit: false, animationError: false, stripeLoaded: false, stripError: false, ...this.getStateFromStores(), }; }, getStateFromStores() { return PaymentModalStore.getState(); }, componentDidMount() { // Mostly a utility for better user experience - if Stripe isn't // loading, the user won't have time to enter information ensureStripeIsLoaded().then(() => this.setState({stripeLoaded: true}), this.onScriptError); const animationData = AnimationManager.get(Animation.NAME); if (animationData) { this.setupAnimation(animationData); } else { // Forcing a delay to not jitter the modal in animation this.setTimeout(() => { AnimationManager.load(Animation.NAME, Animation.URL).then( this.handleAnimationDataLoad, this.handleAnimationDataError ); }, 400); } }, componentDidUpdate(prevProps: Object, prevState: Object) { if (!prevState.animationLoaded && this.state.animationLoaded) { this.setupAnimation(AnimationManager.get(Animation.NAME)); } }, setupAnimation(animationData: Object) { if (!animationData) { return; } // $FlowFixMe: Not sure of an elegant way to annotate this._animItem const animItem = (this._animItem = bodymovin.loadAnimation({ container: this.refs[BODYMOVIN_REF], renderer: 'svg', loop: false, autoplay: false, animationData: animationData, })); if (animItem) { animItem.addEventListener('enterFrame', this.onEnterFrame); animItem.addEventListener('complete', this.close); animItem.play(); } }, getFancyInputRef() { return this.refs[FANCY_INPUT_REF]; }, getSuccessAnimationRef() { return this.refs[ANIMATION_REF]; }, handleFormChange(readyToSubmit: boolean) { this.setState({readyToSubmit}); }, handleFormSubmit(event: Event) { event.preventDefault(); const input = this.getFancyInputRef(); if (input.isFilledOut() && this.state.stripeLoaded && this.state.formState === FormStates.OPEN) { this.submitForm(); } }, handleAnimationDataLoad(data: Object) { if (data) { this.setState({animationLoaded: true}); return; } this.handleAnimationDataError(); }, handleAnimationDataError() { this.setState({animationError: true}); }, componentWillUnmount() { if (this._animItem) { // $FlowFixMe: Not sure of an elegant way to annotate this._animItem this._animItem.destroy(); // $FlowFixMe: Not sure of an elegant way to annotate this._animItem this._animItem = undefined; } }, onEnterFrame(frameEvent: {currentTime: number}) { // $FlowFixMe: Not sure of an elegant way to annotate this._animItem const {_animItem} = this; if (!_animItem) { return; } const {formState} = this.state; const {currentTime} = frameEvent; const animationRef = this.getSuccessAnimationRef(); if (formState === FormStates.OPEN && currentTime >= SceneLoops.Idle.END) { _animItem.goToAndPlay(0, true); this.refs[SHAKE_REF].stop(); } else if (formState === FormStates.SUBMITTING && currentTime < SceneLoops.Speed.BEG) { this.refs[SHAKE_REF].shake(3000, SHAKE_INTENSITY); _animItem.goToAndPlay(SceneLoops.Speed.BEG, true); } else if (formState === FormStates.SUBMITTING && currentTime > SceneLoops.SpeedLoop.END) { this.refs[SHAKE_REF].shake(3000, SHAKE_INTENSITY); _animItem.goToAndPlay(SceneLoops.SpeedLoop.BEG, true); } else if ( formState === FormStates.CLOSED && currentTime > SceneLoops.SpeedLoop.END && animationRef && !animationRef.isAnimating() ) { animationRef.start(); this.containerBounce(); this.refs[SHAKE_REF].shake(1600, SHAKE_INTENSITY); _animItem.goToAndPlay(SceneLoops.SpeedLoop.BEG, true); } else if (currentTime > SceneLoops.Activation.END) { return this.close(); } }, containerBounce() { const {containerPosition} = this.state; Animated.parallel([ Animated.sequence([ Animated.delay(160), Animated.spring(containerPosition, { toValue: 1, friction: 5, tension: 80, }), ]), Animated.sequence([ Animated.delay(220), Animated.spring(containerPosition, { toValue: 0, friction: 5, tension: 80, }), ]), ]).start(); }, showAlert(message: ?string) { if (!message) { return; } AlertActionCreators.show({ title: i18n.Messages.PREMIUM_ALERT_ERROR_TITLE, body: message, }); }, // If stripe is unable to load, throw up an error message and quit out onScriptError() { this.setState({stripeError: true}); AlertActionCreators.show({ title: i18n.Messages.INVITE_MODAL_ERROR_TITLE, body: i18n.Messages.STRIPE_UNABLE_TO_LOAD, onConfirm: this.close, }); }, submitForm() { const input = this.getFancyInputRef(); const {number, exp, cvc, addressZip} = input.getInfo(); const {mode, plan} = this.state; const toPost = {number, cvc, exp, addressZip}; PaymentModalActionCreators.submit(toPost); if (mode === PaymentModelModes.CHANGE) { BillingActionCreators.changeCard(toPost).catch(error => this.showAlert(error && error.message)); } else { BillingActionCreators.subscribe(toPost, mode, plan).catch(error => this.showAlert(error && error.message)); } }, renderAnimation() { const {animationLoaded, animationError} = this.state; let content; if (!animationError && !animationLoaded) { content = ; } if (animationError) { content =
{i18n.Messages.PREMIUM}
; } return (
{content}
); }, renderSuccessAnimation() { const {formState} = this.state; if (formState !== FormStates.CLOSED) { return null; } return ; }, render() { const {readyToSubmit, formState, mode, cardErrors, cardInfo, plan} = this.state; const disabled = formState === FormStates.SUBMITTING || formState === FormStates.CLOSED; let title; if (mode === PaymentModelModes.CHANGE) { title = i18n.Messages.PAYMENT_MODAL_TITLE_CHANGE; } else { title = plan === PremiumPlans.YEARLY ? i18n.Messages.PAYMENT_MODAL_TITLE_NEW_YEARLY.format({ currencySymbol: CurrencySymbols.USD, currencyCode: CurrencyCodes.USD, amount: PremiumPlanPrices.YEARLY, }) : i18n.Messages.PAYMENT_MODAL_TITLE_NEW_MONTHLY.format({ currencySymbol: CurrencySymbols.USD, currencyCode: CurrencyCodes.USD, amount: PremiumPlanPrices.MONTHLY, }); } const inputErrors = processErrors(cardErrors); let buttonSpinner; if (formState === FormStates.SUBMITTING || formState === FormStates.CLOSED) { buttonSpinner = ; } let buttonContent; if (mode === PaymentModelModes.CHANGE) { buttonContent = i18n.Messages.PAYMENT_MODAL_BUTTON_CHANGE; } else { buttonContent = i18n.Messages.PAYMENT_MODAL_BUTTON_NEW; } const successClass = formState === FormStates.CLOSED ? 'success' : null; return ( {this.renderAnimation()}

{title}

{i18n.Messages.PAYMENT_MODAL_SUBTITLE}
); }, getContainerStyle() { const {containerPosition} = this.state; return { transform: [ { translateX: containerPosition.interpolate({ inputRange: [0, 1], outputRange: ['0px', '50px'], }), }, ], }; }, close() { // $FlowFixMe: Not sure of an elegant way to annotate this._animItem const {_animItem} = this; if (_animItem && _animItem.pause) { _animItem.pause(); } PaymentModalActionCreators.close(); }, }); export default PaymentModal; // WEBPACK FOOTER // // ./discord_app/components/PaymentModal.js