Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First iteration of plans store #41856

Merged
merged 4 commits into from
May 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions client/landing/gutenboarding/components/plans-button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* External dependencies
*/
import * as React from 'react';
import { Button } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { useViewportMatch } from '@wordpress/compose';
import { sprintf } from '@wordpress/i18n';
import { useI18n } from '@automattic/react-i18n';

/**
* Internal dependencies
*/
import JetpackLogo from 'components/jetpack-logo'; // @TODO: extract to @automattic package
import { STORE_KEY as PLANS_STORE } from '../../stores/plans';
import { STORE_KEY as ONBOARD_STORE } from '../../stores/onboard';
import { usePlanRouteParam } from '../../path';

/**
* Style dependencies
*/
import './style.scss';

const PlansButton: React.FunctionComponent< Button.ButtonProps > = ( { ...buttonProps } ) => {
const { __ } = useI18n();

// mobile first to match SCSS media query https://github.com/Automattic/wp-calypso/pull/41471#discussion_r415678275
const isDesktop = useViewportMatch( 'mobile', '>=' );
const hasPaidDomain = useSelect( ( select ) => select( ONBOARD_STORE ).hasPaidDomain() );
const defaultPlan = useSelect( ( select ) =>
select( PLANS_STORE ).getDefaultPlan( hasPaidDomain )
);
const selectedPlan = useSelect( ( select ) => select( PLANS_STORE ).getSelectedPlan() );

const planPath = usePlanRouteParam();
const planFromPath = useSelect( ( select ) => select( PLANS_STORE ).getPlanByPath( planPath ) );

/**
* Plan is decided in this order
* 1. selected from PlansGrid (by dispatching setPlan)
* 2. having the plan slug in the URL
* 3. selecting a paid domain
*/
const plan = selectedPlan || planFromPath || defaultPlan;

/* translators: Button label where %s is the WordPress.com plan name (eg: Free, Personal, Premium, Business) */
const planLabel = sprintf( __( '%s Plan' ), plan.getTitle() );

return (
<Button disabled label={ __( planLabel ) } className="plans-button" { ...buttonProps }>
{ isDesktop && planLabel }
<JetpackLogo className="plans-button__jetpack-logo" size={ 16 } monochrome />
</Button>
);
};

export default PlansButton;
84 changes: 5 additions & 79 deletions client/landing/gutenboarding/stores/onboard/persist.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,5 @@
/*
Defines the options used for the @wp/data persistence plugin,
which include a persistent storage implementation to add data expiration handling.
*/

const PERSISTENCE_INTERVAL = 7 * 24 * 3600000; // days * hours in days * ms in hour
const STORAGE_KEY = 'WP_ONBOARD';
const STORAGE_TS_KEY = 'WP_ONBOARD_TS';

// A plain object fallback if localStorage is not available
const objStore: { [ key: string ]: string } = {};

const objStorage: Pick< Storage, 'getItem' | 'setItem' | 'removeItem' > = {
getItem( key ) {
if ( objStore.hasOwnProperty( key ) ) {
return objStore[ key ];
}

return null;
},
setItem( key, value ) {
objStore[ key ] = String( value );
},
removeItem( key ) {
delete objStore[ key ];
},
};

// Make sure localStorage support exists
const localStorageSupport = (): boolean => {
try {
window.localStorage.setItem( 'WP_ONBOARD_TEST', '1' );
window.localStorage.removeItem( 'WP_ONBOARD_TEST' );
return true;
} catch ( e ) {
return false;
}
};

// Choose the right storage implementation
const storageHandler = localStorageSupport() ? window.localStorage : objStorage;

// Persisted data expires after seven days
const isNotExpired = ( timestampStr: string ): boolean => {
const timestamp = Number( timestampStr );
return Boolean( timestamp ) && timestamp + PERSISTENCE_INTERVAL > Date.now();
};

// Check for "fresh" query param
const hasFreshParam = (): boolean => {
return new URLSearchParams( window.location.search ).has( 'fresh' );
};

// Handle data expiration by providing a storage object override to the @wp/data persistence plugin.
const storage: Pick< Storage, 'getItem' | 'setItem' > = {
getItem( key ) {
const timestamp = storageHandler.getItem( STORAGE_TS_KEY );

if ( timestamp && isNotExpired( timestamp ) && ! hasFreshParam() ) {
return storageHandler.getItem( key );
}

storageHandler.removeItem( STORAGE_KEY );
storageHandler.removeItem( STORAGE_TS_KEY );

return null;
},
setItem( key, value ) {
storageHandler.setItem( STORAGE_TS_KEY, JSON.stringify( Date.now() ) );
storageHandler.setItem( key, value );
},
};

const persistOptions = {
storageKey: STORAGE_KEY,
storage,
};

export default persistOptions;
/**
* Internal dependencies
*/
import createPersistenceConfig from '../persistence-config';
export default createPersistenceConfig( 'WP_ONBOARD' );
86 changes: 86 additions & 0 deletions client/landing/gutenboarding/stores/persistence-config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
Defines the options used for the @wp/data persistence plugin,
which include a persistent storage implementation to add data expiration handling.
*/

/**
* Creates a storage config for state persistence
*
* @param storageKey Unique key to the storage
*/
export default function createPersistenceConfig( storageKey: string ) {
const PERSISTENCE_INTERVAL = 7 * 24 * 3600000; // days * hours in days * ms in hour
const STORAGE_KEY = storageKey;
const STORAGE_TS_KEY = storageKey + '_TS';

// A plain object fallback if localStorage is not available
const objStore: { [ key: string ]: string } = {};

const objStorage: Pick< Storage, 'getItem' | 'setItem' | 'removeItem' > = {
getItem( key ) {
if ( objStore.hasOwnProperty( key ) ) {
return objStore[ key ];
}

return null;
},
setItem( key, value ) {
objStore[ key ] = String( value );
},
removeItem( key ) {
delete objStore[ key ];
},
};

// Make sure localStorage support exists
const localStorageSupport = (): boolean => {
try {
window.localStorage.setItem( 'WP_ONBOARD_TEST', '1' );
window.localStorage.removeItem( 'WP_ONBOARD_TEST' );
return true;
} catch ( e ) {
return false;
}
};

// Choose the right storage implementation
const storageHandler = localStorageSupport() ? window.localStorage : objStorage;

// Persisted data expires after seven days
const isNotExpired = ( timestampStr: string ): boolean => {
const timestamp = Number( timestampStr );
return Boolean( timestamp ) && timestamp + PERSISTENCE_INTERVAL > Date.now();
};

// Check for "fresh" query param
const hasFreshParam = (): boolean => {
return new URLSearchParams( window.location.search ).has( 'fresh' );
};

// Handle data expiration by providing a storage object override to the @wp/data persistence plugin.
const storage: Pick< Storage, 'getItem' | 'setItem' > = {
getItem( key ) {
const timestamp = storageHandler.getItem( STORAGE_TS_KEY );

if ( timestamp && isNotExpired( timestamp ) && ! hasFreshParam() ) {
return storageHandler.getItem( key );
}

storageHandler.removeItem( STORAGE_KEY );
storageHandler.removeItem( STORAGE_TS_KEY );

return null;
},
setItem( key, value ) {
storageHandler.setItem( STORAGE_TS_KEY, JSON.stringify( Date.now() ) );
storageHandler.setItem( key, value );
},
};

const persistOptions = {
storageKey: STORAGE_KEY,
storage,
};

return persistOptions;
}
6 changes: 6 additions & 0 deletions client/landing/gutenboarding/stores/plans/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const setPlan = ( slug: string | undefined ) => {
return {
type: 'SET_PLAN' as const,
slug,
};
};
1 change: 1 addition & 0 deletions client/landing/gutenboarding/stores/plans/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const STORE_KEY = 'automattic/onboard/plans';
30 changes: 30 additions & 0 deletions client/landing/gutenboarding/stores/plans/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { controls } from '@wordpress/data-controls';
import { plugins, registerStore, use } from '@wordpress/data';

import { STORE_KEY } from './constants';
import reducer from './reducer';
import * as actions from './actions';
import * as selectors from './selectors';
import persistOptions from './persist';
import { SelectFromMap, DispatchFromMap } from '@automattic/data-stores';

use( plugins.persistence, persistOptions );

registerStore< State >( STORE_KEY, {
actions,
controls,
reducer: reducer as any,
selectors,
persist: [ 'selectedPlanSlug' ],
} );

declare module '@wordpress/data' {
function dispatch( key: typeof STORE_KEY ): DispatchFromMap< typeof actions >;
function select( key: typeof STORE_KEY ): SelectFromMap< typeof selectors >;
}

export type State = import('./reducer').State;
export { STORE_KEY };
5 changes: 5 additions & 0 deletions client/landing/gutenboarding/stores/plans/persist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Internal dependencies
*/
import createPersistenceConfig from '../persistence-config';
export default createPersistenceConfig( 'WP_ONBOARD_PLANS' );
35 changes: 35 additions & 0 deletions client/landing/gutenboarding/stores/plans/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Internal dependencies
*/
import * as plans from '../../../../lib/plans/constants';

export const supportedPlanSlugs = [
plans.PLAN_FREE,
plans.PLAN_PERSONAL,
plans.PLAN_PREMIUM,
plans.PLAN_BUSINESS,
plans.PLAN_ECOMMERCE,
];

import { PlanAction } from './types';

const DEFAUlT_STATE: {
selectedPlanSlug: string | undefined;
supportedPlanSlugs: Array< string >;
} = {
supportedPlanSlugs,
selectedPlanSlug: undefined,
};

const reducer = function ( state = DEFAUlT_STATE, action: PlanAction ) {
switch ( action.type ) {
case 'SET_PLAN':
return { ...state, selectedPlanSlug: action.slug };
default:
return state;
}
};

export type State = ReturnType< typeof reducer >;

export default reducer;
16 changes: 16 additions & 0 deletions client/landing/gutenboarding/stores/plans/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { State, supportedPlanSlugs } from './reducer';
import * as plans from 'lib/plans/constants';
import { getPlan, getPlanPath } from 'lib/plans';

const freePlan = plans.PLAN_FREE;
const defaultPaidPlan = plans.PLAN_PREMIUM;

export const getSelectedPlan = ( state: State ) => getPlan( state.selectedPlanSlug );
export const getDefaultPlan = ( state: State, hasPaidDomain: boolean ) =>
hasPaidDomain ? getPlan( defaultPaidPlan ) : getPlan( freePlan );
export const getSupportedPlans = ( state: State ) => state.supportedPlanSlugs.map( getPlan );
export const getPlanByPath = ( state: State, path: string | undefined ) =>
getPlan( supportedPlanSlugs.find( ( slug ) => getPlanPath( slug ) === path ) );
4 changes: 4 additions & 0 deletions client/landing/gutenboarding/stores/plans/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type PlanAction = {
type: string;
slug?: string;
};