/* @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()}
);
},
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