530 lines
15 KiB
JavaScript
Executable file
530 lines
15 KiB
JavaScript
Executable file
/* @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 (
|
|
<Animated.div className="success-animation" style={this.getContainerStyle()}>
|
|
<Animated.img
|
|
className="premium-activated"
|
|
src={require('../images/premium/img_premium_activated.svg')}
|
|
width={224}
|
|
height={20}
|
|
style={this.getActivatedStyle()}
|
|
/>
|
|
<Animated.img
|
|
className="shine"
|
|
src={require('../images/premium/img_premium_flash.svg')}
|
|
width={73}
|
|
height={55}
|
|
style={this.getShineStyle()}
|
|
/>
|
|
</Animated.div>
|
|
);
|
|
},
|
|
|
|
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 = <Spinner />;
|
|
}
|
|
if (animationError) {
|
|
content = <div className="premium-title">{i18n.Messages.PREMIUM}</div>;
|
|
}
|
|
return (
|
|
<div className={classNames('premium-animation', {error: animationError})} ref={BODYMOVIN_REF}>
|
|
{content}
|
|
</div>
|
|
);
|
|
},
|
|
|
|
renderSuccessAnimation() {
|
|
const {formState} = this.state;
|
|
if (formState !== FormStates.CLOSED) {
|
|
return null;
|
|
}
|
|
return <SuccessAnimation ref={ANIMATION_REF} />;
|
|
},
|
|
|
|
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 = <Spinner type="pulsing-ellipsis" />;
|
|
}
|
|
|
|
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 (
|
|
<Shakeable className={classNames('premium-payment-modal', successClass)} ref={SHAKE_REF}>
|
|
{this.renderAnimation()}
|
|
<form className="premium-payment-form" onSubmit={this.handleFormSubmit}>
|
|
<h1 className="premium-payment-title">{title}</h1>
|
|
<div className="premium-payment-subtitle">{i18n.Messages.PAYMENT_MODAL_SUBTITLE}</div>
|
|
<Animated.div className="fancy-input-container" style={this.getContainerStyle()}>
|
|
<FancyCreditCardInput
|
|
formState={formState}
|
|
disabled={disabled}
|
|
ref={FANCY_INPUT_REF}
|
|
errors={inputErrors}
|
|
renderSuccess={this.renderSuccessAnimation()}
|
|
initialCardInfo={cardInfo}
|
|
onChange={this.handleFormChange}
|
|
/>
|
|
</Animated.div>
|
|
<button
|
|
type="submit"
|
|
className={classNames('premium-payment-button', {
|
|
open: formState === FormStates.OPEN,
|
|
submitting: formState === FormStates.SUBMITTING,
|
|
success: formState === FormStates.CLOSED,
|
|
})}
|
|
disabled={disabled || !readyToSubmit}>
|
|
{buttonContent}
|
|
{buttonSpinner}
|
|
</button>
|
|
</form>
|
|
</Shakeable>
|
|
);
|
|
},
|
|
|
|
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
|