FE-494: Added init logic for checkout redesign. (#153)

This commit is contained in:
Ildar Galeev 2017-11-27 11:10:59 +03:00 committed by GitHub
parent 35458e3982
commit 9d1b69843a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 570 additions and 30 deletions

View File

@ -36,7 +36,9 @@
"@types/classnames": "~2.2.3",
"@types/react": "~16.0.1",
"@types/react-dom": "~16.0.1",
"@types/react-redux": "~5.0.13",
"@types/react-transition-group": "~1.1.0",
"@types/url-parse": "~1.1.0",
"autoprefixer": "~6.7.7",
"awesome-typescript-loader": "~3.2.3",
"babel-core": "~6.24.1",
@ -71,6 +73,7 @@
"node-sass": "~4.5.1",
"react-addons-test-utils": "~15.5.1",
"react-test-renderer": "~15.5.4",
"redux-devtools-extension": "~2.13.2",
"sass-loader": "~6.0.3",
"sinon": "~2.2.0",
"sinon-chai": "~2.10.0",

2
src/app/actions/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './result-action';
export * from './type-keys';

View File

@ -0,0 +1,13 @@
import { TypeKeys } from '../type-keys';
import { Dispatch } from 'redux';
import { Result } from '../../state';
import { ResultAction } from './result-action';
export function close(): Dispatch<ResultAction> {
return (dispatch: Dispatch<ResultAction>) => {
dispatch({
type: TypeKeys.SET_RESULT,
payload: Result.close
} as ResultAction);
};
}

View File

@ -0,0 +1,2 @@
export * from './result-action';
export * from './close';

View File

@ -0,0 +1,8 @@
import { Action } from 'redux';
import { TypeKeys } from '../type-keys';
import { Result } from '../../state';
export interface ResultAction extends Action {
type: TypeKeys.SET_RESULT,
payload: Result
}

View File

@ -0,0 +1,3 @@
export enum TypeKeys {
SET_RESULT = 'SET_RESULT'
}

View File

@ -1,14 +1,24 @@
import * as React from 'react';
import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';
import * as styles from './close.scss';
import {Icon} from '../../index';
import { Icon } from '../../index';
import { close } from '../../../actions/result-action';
import { ResultAction } from '../../../actions/result-action/result-action';
export class Close extends React.Component {
render() {
return (
<div className={styles.close}>
<Icon icon='cross' />
</div>
);
}
interface CloseProps {
close: () => Dispatch<ResultAction>;
}
const mapDispatchToProps = (dispatch: Dispatch<ResultAction>) => ({
close: bindActionCreators(close, dispatch)
});
const CloseDef: React.SFC<CloseProps> = (props) =>
(
<div className={styles.close} onClick={props.close}>
<Icon icon='cross'/>
</div>
);
export const Close = connect(null, mapDispatchToProps)(CloseDef);

View File

@ -28,16 +28,20 @@
}
}
.overlay {
.overlayContainer {
position: absolute;
height: 100%;
min-height: 100%;
width: 100%;
}
.overlay {
height: 100%;
width: 100%;
@include responsive(sm) {
background: rgba(0, 0, 0, .7);
transform: translateZ(-1000px);
animation: fadein .5s;
}
}
@ -74,10 +78,25 @@
}
}
.appearOverlay {
@include responsive(sm) {
animation: fadein .5s;
}
}
.enterOverlay {
background: green;
}
.leaveOverlay {
@include responsive(sm) {
animation: fadeout .5s;
}
}
.appearContainer {
@include responsive(sm) {
//animation: popup .75s;
animation: popout 1s;
animation: popup .75s;
}
}
@ -87,7 +106,7 @@
.leaveContainer {
@include responsive(sm) {
animation: rotatein 0s;
animation: popout 1s;
}
}

View File

@ -1,20 +1,24 @@
export const mainContainer: string;
export const overlayContainer: string;
export const overlay: string;
export const fadein: string;
export const container: string;
export const form_container: string;
export const appearOverlay: string;
export const fadein: string;
export const enterOverlay: string;
export const leaveOverlay: string;
export const fadeout: string;
export const appearContainer: string;
export const popout: string;
export const popup: string;
export const enterContainer: string;
export const leaveContainer: string;
export const rotatein: string;
export const popout: string;
export const appearFormContainer: string;
export const rotatein: string;
export const enterFormContainer: string;
export const leaveFormContainer: string;
export const rotateout: string;
export const fadeout: string;
export const slidedown: string;
export const slideup: string;
export const growth: string;
export const popup: string;
export const shake: string;

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as TransitionGroup from 'react-transition-group';
import * as styles from './container.scss';
import {Header, Info, Footer, Form, ThreeDSContainer, ContainerLoader, Close} from '../index';
import { Header, Info, Footer, Form, ThreeDSContainer, ContainerLoader, Close } from '../index';
export class Container extends React.Component {
static getView(): string {
@ -21,7 +21,23 @@ export class Container extends React.Component {
const CSSTransitionGroup = TransitionGroup.CSSTransitionGroup;
return (
<div className={styles.mainContainer}>
<div className={styles.overlay} />
<CSSTransitionGroup
component='div'
className={styles.overlayContainer}
transitionName={{
appear: styles.appearOverlay,
enter: styles.enterOverlay,
leave: styles.leaveOverlay
}}
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}
transitionAppearTimeout={1000}
transitionAppear={true}
transitionEnter={true}
transitionLeave={true}
>
<div className={styles.overlay}/>
</CSSTransitionGroup>
{Container.getView() === 'loading' ? <ContainerLoader/> : false}
<CSSTransitionGroup
component='div'
@ -39,7 +55,7 @@ export class Container extends React.Component {
>
{Container.getView() !== 'loading' ?
<div className={styles.container}>
<Close />
<Close/>
{Container.getView() === 'default' ?
<CSSTransitionGroup
component='div'

View File

@ -0,0 +1,5 @@
export class AppConfig {
capiEndpoint: string;
applePayMerchantID: string;
applePayMerchantValidationEndpoint: string;
}

View File

@ -0,0 +1,60 @@
import * as URL from 'url-parse';
import { Transport, PossibleEvents } from '../../communication-ts';
import { UriSerializer } from '../../utils/uri-serializer';
import { AppConfig, Config, InitConfig } from '.';
import { IntegrationType } from './integration-type';
export class ConfigResolver {
static resolve(transport: Transport): Promise<Config> {
return Promise.all([
this.resolveInitConfig(transport),
this.loadAppConfig()
]).then((configs) => {
return {
origin: this.getOrigin(),
initConfig: configs[0],
appConfig: configs[1]
};
});
}
private static loadAppConfig(): Promise<AppConfig> {
return fetch('../appConfig.json', {
headers: {
'Content-Type': 'application/json'
},
method: 'GET'
}).then((response) => response.json());
}
private static resolveInitConfig(transport: Transport): Promise<InitConfig> {
return new Promise((resolve) => {
this.isUriContext()
? resolve(UriSerializer.deserialize(location.search))
: transport.on(PossibleEvents.init, (config) => resolve(config));
}).then((config: InitConfig) => {
config.integrationType = this.calcIntegrationType();
return config;
});
}
private static calcIntegrationType(): IntegrationType {
return IntegrationType.invoice; // TODO implement here
}
private static isUriContext(): boolean {
return !!location.search;
}
private static getOrigin(): string {
const currentScript: any = document.currentScript || this.getCurrentScript();
const url = URL(currentScript.src);
return url.origin;
}
private static getCurrentScript(): HTMLElement {
const scripts = document.getElementsByTagName('script');
return scripts[scripts.length - 1];
}
}

8
src/app/config/config.ts Normal file
View File

@ -0,0 +1,8 @@
import { AppConfig } from './app-config';
import { InitConfig } from './init-config';
export class Config {
origin: string;
initConfig: InitConfig;
appConfig: AppConfig;
}

View File

@ -0,0 +1,12 @@
import { InitConfig } from './init-config';
import { IntegrationType } from './integration-type';
export class CustomerInitConfig extends InitConfig {
customerID: string;
customerAccessToken: string;
constructor() {
super();
this.integrationType = IntegrationType.customer;
}
}

View File

@ -0,0 +1,4 @@
export enum HoldExpiration {
cancel = 'cancel',
capture = 'capture'
}

9
src/app/config/index.ts Normal file
View File

@ -0,0 +1,9 @@
export * from './app-config';
export * from './config';
export * from './config-resolver';
export * from './customer-init-config';
export * from './hold-expiration';
export * from './init-config';
export * from './integration-type';
export * from './invoice-init-config';
export * from './invoice-template-init-config';

View File

@ -0,0 +1,13 @@
import { HoldExpiration } from './hold-expiration';
import { IntegrationType } from './integration-type';
export class InitConfig {
integrationType: IntegrationType;
terminals: boolean = false;
paymentFlowHold: boolean = false;
holdExpiration: HoldExpiration = HoldExpiration.cancel;
locale: string = 'auto';
name?: string;
description?: string;
email?: string;
}

View File

@ -0,0 +1,5 @@
export enum IntegrationType {
invoice = 'invoice',
invoiceTemplate = 'invoiceTemplate',
customer = 'customer'
}

View File

@ -0,0 +1,12 @@
import { InitConfig } from './init-config';
import { IntegrationType } from './integration-type';
export class InvoiceInitConfig extends InitConfig {
invoiceID: string;
invoiceAccessToken: string;
constructor() {
super();
this.integrationType = IntegrationType.invoice;
}
}

View File

@ -0,0 +1,12 @@
import { InitConfig } from './init-config';
import { IntegrationType } from './integration-type';
export class InvoiceTemplateInitConfig extends InitConfig {
invoiceTemplateID: string;
invoiceTemplateAccessToken: string;
constructor() {
super();
this.integrationType = IntegrationType.invoiceTemplate;
}
}

View File

@ -0,0 +1,10 @@
import { applyMiddleware, combineReducers, createStore, Store } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import { resultReducer } from './reducers/result-reducer';
export function configureStore(initialState: any): Store<any> {
return createStore(combineReducers({
result: resultReducer
}), initialState, composeWithDevTools(applyMiddleware(thunk)));
}

41
src/app/finalize-app.ts Normal file
View File

@ -0,0 +1,41 @@
import * as ReactDOM from 'react-dom';
import { Transport, PossibleEvents } from '../communication-ts';
import { Result } from './state';
class AppFinalizer {
private actionTimeout = 300;
constructor(private transport: Transport, private checkoutEl: HTMLElement) { }
close() {
ReactDOM.unmountComponentAtNode(this.checkoutEl);
setTimeout(() => {
this.transport.emit(PossibleEvents.close);
this.transport.destroy();
}, this.actionTimeout);
}
done(redirectUrl?: string, popupMode?: boolean) {
ReactDOM.unmountComponentAtNode(this.checkoutEl);
setTimeout(() => {
this.transport.emit(PossibleEvents.done);
this.transport.destroy();
if (popupMode) {
redirectUrl ? location.replace(redirectUrl) : window.close();
}
}, this.actionTimeout);
}
}
export function finalize(result: Result, transport: Transport, checkoutEl: HTMLElement, redirectUrl?: string, popupMode?: boolean) {
const finalizer = new AppFinalizer(transport, checkoutEl);
switch (result) {
case Result.close:
finalizer.close();
break;
case Result.done:
finalizer.done();
break;
}
}

View File

@ -1,10 +1,36 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Container } from './components';
import { Provider } from 'react-redux';
import './styles/main.scss';
import './styles/forms.scss';
import { ConfigResolver } from './config/config-resolver';
import { Child } from '../communication-ts/child';
import { Container } from './components';
import { configureStore } from './configure-store';
import { finalize } from './finalize-app';
ReactDOM.render(
<Container/>,
document.getElementById('app')
);
Child.resolve()
.then((transport) =>
Promise.all([
transport,
ConfigResolver.resolve(transport)
]))
.then((res) => {
const app = document.getElementById('app');
const store = configureStore({});
store.subscribe(() => {
const state = store.getState();
if (state.result) {
finalize(state.result, res[0], app);
}
});
ReactDOM.render(
<Provider store={store}>
<Container/>
</Provider>,
app
);
})
.catch((error) => {
throw new Error(error); // TODO need to implement
});

View File

@ -0,0 +1,13 @@
import { State } from '../state/state';
import { TypeKeys } from '../actions/type-keys';
import { ResultAction } from '../actions/result-action/result-action';
export function resultReducer(s: State = null, action: ResultAction) {
switch (action.type) {
case TypeKeys.SET_RESULT: {
return action.payload;
}
default:
return s;
}
}

2
src/app/state/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './result';
export * from './state';

4
src/app/state/result.ts Normal file
View File

@ -0,0 +1,4 @@
export enum Result {
close = 'close',
done = 'done'
}

5
src/app/state/state.ts Normal file
View File

@ -0,0 +1,5 @@
import { Result } from '.';
export type State = {
readonly result: Result;
}

View File

@ -0,0 +1,7 @@
export function applyMixins(derivedCtor: any, baseCtors: any[]) {
baseCtors.forEach(baseCtor => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
derivedCtor.prototype[name] = baseCtor.prototype[name];
});
});
}

View File

@ -0,0 +1,42 @@
import {
RealTransport,
Transport,
ContextResolver,
StubTransport,
TransportInfo
} from '.';
export class Child {
static resolve(): Promise<Transport> {
return new Promise((resolve) => {
if (ContextResolver.isAvailable() && window.opener) {
const target = window.opener;
const context = ContextResolver.get();
return resolve(new RealTransport(target, context.parentOrigin, window));
} else if (!this.inIframe() && !window.opener) {
return resolve(new StubTransport());
} else {
const shake = (e: MessageEvent) => {
if (e && e.data === TransportInfo.parentHandshakeMessageName) {
const target = e.source;
target.postMessage(TransportInfo.childHandshakeMessageName, e.origin);
ContextResolver.set({
parentOrigin: e.origin
});
return resolve(new RealTransport(target, e.origin, window));
}
};
window.addEventListener('message', shake, false);
}
});
}
private static inIframe(): boolean {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
}

View File

@ -0,0 +1,25 @@
export class ContextResolver {
static set(context: any) {
try {
sessionStorage.setItem(this.key, JSON.stringify(context));
/* tslint:disable:no-empty */
} catch (e) {}
}
static get(): any {
try {
return JSON.parse(sessionStorage.getItem(this.key));
/* tslint:disable:no-empty */
} catch (e) {}
}
static isAvailable(): boolean {
try {
return !!JSON.parse(sessionStorage.getItem(this.key));
/* tslint:disable:no-empty */
} catch (e) {}
}
private static key = 'checkout-context';
}

View File

@ -0,0 +1,8 @@
export * from './child';
export * from './context-resolver';
export * from './transport';
export * from './real-transport';
export * from './stub-transport';
export * from './transport-info';
export * from './possible-events';
export * from './transport-message';

View File

@ -0,0 +1,5 @@
export enum PossibleEvents {
init = 'init-payform',
done = 'payment-done',
close = 'close'
}

View File

@ -0,0 +1,44 @@
import { Transport, TransportInfo, PossibleEvents, TransportMessage } from '.';
export class RealTransport implements Transport {
private target: Window;
private origin: string;
private events: any = {};
constructor(target: Window, origin: string, source: Window) {
this.target = target;
this.origin = origin;
source.addEventListener('message', this.listener.bind(this), false);
}
emit(name: PossibleEvents, data?: any) {
const serialized = JSON.stringify({
data,
name,
transport: TransportInfo.transportName
} as TransportMessage);
this.target.postMessage(serialized, this.origin);
}
on(eventName: PossibleEvents, callback: (data: any) => any) {
this.events[eventName] = callback;
}
destroy() {
window.removeEventListener('message', this.listener.bind(this), false);
}
private listener(e: MessageEvent) {
let parsed: TransportMessage;
try {
parsed = JSON.parse(e.data);
/* tslint:disable:no-empty */
} catch (e) {}
if (parsed && (parsed.name in this.events)) {
if (parsed.transport === TransportInfo.transportName) {
this.events[parsed.name].call(this, parsed.data);
}
}
}
}

View File

@ -0,0 +1,17 @@
import { Transport, PossibleEvents } from '.';
export class StubTransport implements Transport {
emit(name: PossibleEvents, data: any) {
console.info('transport stub emit: ', name, data);
}
on(eventName: PossibleEvents, callback: (data: any) => any) {
callback({});
console.info('transport stub on: ', eventName, callback);
}
destroy() {
console.info('transport stub destroy');
}
}

View File

@ -0,0 +1,5 @@
export enum TransportInfo {
transportName = 'rbkmoney-checkout',
parentHandshakeMessageName = 'rbkmoney-checkout-handshake',
childHandshakeMessageName = 'rbkmoney-payframe-handshake'
}

View File

@ -0,0 +1,7 @@
import { TransportInfo } from '.';
export class TransportMessage {
data: any;
name: string;
transport: TransportInfo;
}

View File

@ -0,0 +1,9 @@
import { PossibleEvents } from '.';
export interface Transport {
emit(name: PossibleEvents, data?: any): void;
on(eventName: PossibleEvents, callback: (data: any) => any): void;
destroy(): void;
}

View File

@ -0,0 +1,46 @@
export class UriSerializer {
static serialize(params: any): string {
let urlParams = '';
for (const prop in params) {
if (params.hasOwnProperty(prop)) {
const value = params[prop];
if ((typeof value === 'function') || (value === undefined) || (value === null)) {
continue;
}
if (urlParams !== '') {
urlParams += '&';
}
urlParams += `${prop}=${encodeURIComponent(value)}`;
}
}
return urlParams;
}
static deserialize(url: string): any {
const split = (typeof url === 'string' && url !== '') && url.split('?');
if (!split) {
return {};
}
const params = split.length > 1 ? split[1] : split[0];
const result = JSON.parse(`{"${decodeURI(params).replace(/"/g, '\\"').replace(/&/g, '","').replace(/=/g, '":"')}"}`);
for (const prop in result) {
if (result.hasOwnProperty(prop)) {
const value = decodeURIComponent(result[prop]);
if (value === 'true') {
result[prop] = true;
} else if (value === 'false') {
result[prop] = false;
} else if (value === 'undefined') {
result[prop] = undefined;
} else if (value === 'null') {
result[prop] = null;
} else if (value !== '' && !isNaN(value as any)) {
result[prop] = parseFloat(value);
} else {
result[prop] = value;
}
}
}
return result;
}
}

View File

@ -8,7 +8,8 @@
"target": "es5",
"jsx": "react",
"lib": ["es2015", "es2017", "dom"],
"baseUrl": "."
"baseUrl": ".",
"experimentalDecorators": true
},
"include": [
"./src/**/*"

View File

@ -5,6 +5,9 @@
"member-access": [true, "no-public"],
"trailing-comma": false,
"ordered-imports": false,
"no-console": [true, "log"]
"no-console": [true, "log"],
"max-line-length": false,
"object-literal-sort-keys": false,
"interface-name": false
}
}