mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
Merge pull request #518 from sharetribe/basic-analytics
Basic analytics
This commit is contained in:
commit
d0b728bfd6
20 changed files with 285 additions and 33 deletions
|
|
@ -14,3 +14,4 @@ Documentation for specific topics can be found in the following files:
|
|||
- [Error logging with Sentry](sentry.md)
|
||||
- [CI](ci.md)
|
||||
- [Static pages](static-pages.md)
|
||||
- [Analytics](analytics.md)
|
||||
|
|
|
|||
43
docs/analytics.md
Normal file
43
docs/analytics.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Analytics
|
||||
|
||||
The application supports tracking page views with pluggable analytics
|
||||
handlers.
|
||||
|
||||
## Google Analytics
|
||||
|
||||
Google Analytics (GA) is supported by default. The enable GA, set the
|
||||
GA tracker ID to the environment variable
|
||||
`REACT_APP_GOOGLE_ANALYTICS_ID`;
|
||||
|
||||
## Custom analytics handler
|
||||
|
||||
If you want to add a new analytics library, you can do as follows:
|
||||
|
||||
1. Add the analytics library script
|
||||
|
||||
If the analytics library has an external script, add the library
|
||||
script tag to the [src/public/index.html](../public/index.html)
|
||||
file. If you need more control, see how the GA script is added in
|
||||
[server/renderer.js](../server/renderer.js).
|
||||
|
||||
1. Create a handler
|
||||
|
||||
To track page views, create a custom handler e.g. in
|
||||
[src/analytics/handlers.js](../src/analytics/handlers.js). The
|
||||
handler should be a class that implements a `trackPageView(url)`
|
||||
method.
|
||||
|
||||
Note that the `url` parameter might not be the same as in the URL
|
||||
bar of the browser. It is the canonical form of the URL. For
|
||||
example, in the listing page it doesn't have the dynamic title slug
|
||||
in the middle. This allows unified analytics and correct tracking
|
||||
of pages that can be accessed from multiple different URLs.
|
||||
|
||||
If you analytics library takes the page URL from the browser, you
|
||||
might need to override that behavior to use the canonical URL that
|
||||
is given to the method.
|
||||
|
||||
1. Initialise the handler
|
||||
|
||||
Initialise the handler in the `setupAnalyticsHandlers()` function
|
||||
in [src/index.js](../src/index.js).
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--!meta-->
|
||||
|
||||
<!--!googleAnalyticsScript-->
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json">
|
||||
<link rel="icon" sizes="32x32" type="image/x-icon" href="%PUBLIC_URL%/static/favicon.ico">
|
||||
<link rel="icon" sizes="192x192" type="image/png" href="%PUBLIC_URL%/static/webapp-icon-192x192.png" />
|
||||
|
|
|
|||
|
|
@ -108,6 +108,23 @@ exports.render = function(requestUrl, context, preloadedState) {
|
|||
<script>window.__PRELOADED_STATE__ = ${JSON.stringify(serializedState)};</script>
|
||||
`;
|
||||
|
||||
// We want to precicely control where the analytics script is
|
||||
// injected in the HTML file so we can catch all events as early as
|
||||
// possible. This is why we inject the GA script separately from
|
||||
// react-helmet. This script also ensures that all the GA scripts
|
||||
// are added only when the proper env var is present.
|
||||
//
|
||||
// See: https://developers.google.com/analytics/devguides/collection/analyticsjs/#alternative_async_tracking_snippet
|
||||
const googleAnalyticsScript = process.env.REACT_APP_GOOGLE_ANALYTICS_ID
|
||||
? `
|
||||
<script>
|
||||
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
|
||||
ga('create', '${process.env.REACT_APP_GOOGLE_ANALYTICS_ID}', 'auto');
|
||||
</script>
|
||||
<script async src="https://www.google-analytics.com/analytics.js"></script>
|
||||
`
|
||||
: '';
|
||||
|
||||
return template({
|
||||
htmlAttributes: head.htmlAttributes.toString(),
|
||||
title: head.title.toString(),
|
||||
|
|
@ -115,6 +132,7 @@ exports.render = function(requestUrl, context, preloadedState) {
|
|||
meta: head.meta.toString(),
|
||||
script: head.script.toString(),
|
||||
preloadedStateScript,
|
||||
googleAnalyticsScript,
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { connect } from 'react-redux';
|
|||
import { Switch, Route, withRouter } from 'react-router-dom';
|
||||
import { NotFoundPage } from './containers';
|
||||
import { NamedRedirect } from './components';
|
||||
import { locationChanged } from './ducks/Routing.duck';
|
||||
import * as propTypes from './util/propTypes';
|
||||
import * as log from './util/log';
|
||||
import { canonicalRoutePath } from './util/routes';
|
||||
import routeConfiguration from './routeConfiguration';
|
||||
|
||||
const { arrayOf, bool, object, func, shape, string } = PropTypes;
|
||||
|
||||
|
|
@ -34,10 +37,16 @@ const callLoadData = props => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLocationChanged = (dispatch, location) => {
|
||||
const url = canonicalRoutePath(routeConfiguration(), location);
|
||||
dispatch(locationChanged(location, url));
|
||||
};
|
||||
|
||||
class RouteComponentRenderer extends Component {
|
||||
componentDidMount() {
|
||||
// Calling loadData on initial rendering (on client side).
|
||||
callLoadData(this.props);
|
||||
handleLocationChanged(this.props.dispatch, this.props.location);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
|
|
@ -45,6 +54,7 @@ class RouteComponentRenderer extends Component {
|
|||
// This makes it possible to use loadData as default client side data loading technique.
|
||||
// However it is better to fetch data before location change to avoid "Loading data" state.
|
||||
callLoadData(nextProps);
|
||||
handleLocationChanged(nextProps.dispatch, nextProps.location);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
18
src/analytics/analytics.js
Normal file
18
src/analytics/analytics.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { LOCATION_CHANGED } from '../ducks/Routing.duck';
|
||||
|
||||
// Create a Redux middleware from the given analytics handlers. Each
|
||||
// handler should have the following methods:
|
||||
//
|
||||
// - trackPageView(url): called when the URL is changed
|
||||
export const createMiddleware = handlers => () => next => action => {
|
||||
const { type, payload } = action;
|
||||
|
||||
if (type === LOCATION_CHANGED) {
|
||||
const { canonicalUrl } = payload;
|
||||
handlers.forEach(handler => {
|
||||
handler.trackPageView(canonicalUrl);
|
||||
});
|
||||
}
|
||||
|
||||
next(action);
|
||||
};
|
||||
19
src/analytics/handlers.js
Normal file
19
src/analytics/handlers.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export class LoggingAnalyticsHandler {
|
||||
trackPageView(url) {
|
||||
console.log('Analytics page view:', url);
|
||||
}
|
||||
}
|
||||
|
||||
export class GoogleAnalyticsHandler {
|
||||
constructor(ga) {
|
||||
if (typeof ga !== 'function') {
|
||||
throw new Error('Variable `ga` missing for Google Analytics');
|
||||
}
|
||||
this.ga = ga;
|
||||
}
|
||||
trackPageView(url) {
|
||||
// https://developers.google.com/analytics/devguides/collection/analyticsjs/single-page-applications#tracking_virtual_pageviews
|
||||
this.ga('set', 'page', url);
|
||||
this.ga('send', 'pageview');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import Helmet from 'react-helmet';
|
|||
import { withRouter } from 'react-router-dom';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
import config from '../../config';
|
||||
import { canonicalURL, metaTagProps } from '../../util/seo';
|
||||
import { metaTagProps } from '../../util/seo';
|
||||
import { canonicalRoutePath } from '../../util/routes';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
|
||||
import facebookImage from '../../assets/saunatimeFacebook-1200x630.jpg';
|
||||
|
|
@ -55,7 +57,7 @@ class PageComponent extends Component {
|
|||
className,
|
||||
rootClassName,
|
||||
children,
|
||||
history,
|
||||
location,
|
||||
intl,
|
||||
logoutError,
|
||||
scrollingDisabled,
|
||||
|
|
@ -69,7 +71,6 @@ class PageComponent extends Component {
|
|||
title,
|
||||
twitterHandle,
|
||||
twitterImages,
|
||||
canonicalPath,
|
||||
updated,
|
||||
} = this.props;
|
||||
|
||||
|
|
@ -85,10 +86,10 @@ class PageComponent extends Component {
|
|||
[css.scrollingDisabled]: scrollingDisabled,
|
||||
});
|
||||
|
||||
const { pathname, search = '' } = history.location;
|
||||
const pathWithSearch = `${pathname}${search}`;
|
||||
|
||||
const canonicalRootURL = config.canonicalRootURL;
|
||||
const canonicalPath = canonicalRoutePath(routeConfiguration(), location);
|
||||
const canonicalUrl = `${canonicalRootURL}${canonicalPath}`;
|
||||
|
||||
const siteTitle = config.siteTitle;
|
||||
const schemaTitle = intl.formatMessage({ id: 'Page.schemaTitle' }, { siteTitle });
|
||||
const schemaDescription = intl.formatMessage({ id: 'Page.schemaDescription' });
|
||||
|
|
@ -122,7 +123,7 @@ class PageComponent extends Component {
|
|||
title: metaTitle,
|
||||
twitterHandle,
|
||||
updated,
|
||||
url: canonicalURL(pathWithSearch),
|
||||
url: canonicalUrl,
|
||||
locale: intl.locale,
|
||||
});
|
||||
|
||||
|
|
@ -174,7 +175,7 @@ class PageComponent extends Component {
|
|||
}}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<link rel="canonical" href={canonicalURL(canonicalPath || pathWithSearch)} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta httpEquiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta httpEquiv="Content-Language" content={intl.locale} />
|
||||
{metaTags}
|
||||
|
|
@ -204,7 +205,6 @@ PageComponent.defaultProps = {
|
|||
description: null,
|
||||
facebookImages: null,
|
||||
twitterImages: null,
|
||||
canonicalPath: null,
|
||||
published: null,
|
||||
schema: null,
|
||||
tags: null,
|
||||
|
|
@ -243,12 +243,12 @@ PageComponent.propTypes = {
|
|||
title: string.isRequired, // page title
|
||||
twitterHandle: string, // twitter handle
|
||||
updated: string, // article:modified_time
|
||||
canonicalPath: string, // link rel=canonical
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
listen: func.isRequired,
|
||||
}).isRequired,
|
||||
location: object.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const estimatedNightlyTransaction = (bookingStart, bookingEnd, unitPrice) => {
|
|||
unitPrice: unitPrice,
|
||||
quantity: new Decimal(nightCount),
|
||||
lineTotal: totalPrice,
|
||||
reversal: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe('BookingDatesForm', () => {
|
|||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
it('matches snapshot with selected dates', () => {
|
||||
it('matches selected dates', () => {
|
||||
const price = new Money(1099, 'USD');
|
||||
const startDate = new Date(Date.UTC(2017, 3, 14));
|
||||
const endDate = new Date(Date.UTC(2017, 3, 16));
|
||||
|
|
@ -58,6 +58,7 @@ describe('BookingDatesForm', () => {
|
|||
unitPrice: price,
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new Money(2198, 'USD'),
|
||||
reversal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -330,14 +330,6 @@ export class ListingPageComponent extends Component {
|
|||
{ title, price: formattedPrice, siteTitle }
|
||||
);
|
||||
|
||||
const canonicalPath = createResourceLocatorString(
|
||||
'ListingPageCanonical',
|
||||
routeConfiguration(),
|
||||
{
|
||||
id: listingId.uuid,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
logoutError={logoutError}
|
||||
|
|
@ -348,7 +340,6 @@ export class ListingPageComponent extends Component {
|
|||
description={description}
|
||||
facebookImages={facebookImages}
|
||||
twitterImages={twitterImages}
|
||||
canonicalPath={canonicalPath}
|
||||
schema={{
|
||||
'@context': 'http://schema.org',
|
||||
'@type': 'ItemPage',
|
||||
|
|
|
|||
|
|
@ -20,15 +20,21 @@ const noop = () => null;
|
|||
describe('ListingPage', () => {
|
||||
it('matches snapshot', () => {
|
||||
const currentUser = createCurrentUser('user-2');
|
||||
const listing1 = createListing('listing1', {}, { author: createUser('user-1') });
|
||||
const id = 'listing1';
|
||||
const slug = 'listing1-title';
|
||||
const listing1 = createListing(id, {}, { author: createUser('user-1') });
|
||||
const getListing = () => listing1;
|
||||
|
||||
const props = {
|
||||
location: { search: '' },
|
||||
location: {
|
||||
pathname: `/l/${slug}/${id}`,
|
||||
search: '',
|
||||
hash: '',
|
||||
},
|
||||
history: {
|
||||
push: () => console.log('HistoryPush called'),
|
||||
},
|
||||
params: { slug: 'listing1-title', id: 'listing1' },
|
||||
params: { id, slug },
|
||||
currentUser,
|
||||
getListing: getListing,
|
||||
intl: fakeIntl,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
exports[`ListingPage matches snapshot 1`] = `
|
||||
<Page
|
||||
author="user-1 display name"
|
||||
canonicalPath="/l/listing1"
|
||||
contentType="website"
|
||||
description="listing1 description"
|
||||
facebookImages={Array []}
|
||||
|
|
|
|||
32
src/ducks/Routing.duck.js
Normal file
32
src/ducks/Routing.duck.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// ================ Action types ================ //
|
||||
|
||||
export const LOCATION_CHANGED = 'app/Routing/LOCATION_CHANGED';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
currentLocation: null,
|
||||
currentCanonicalUrl: null,
|
||||
};
|
||||
|
||||
export default function routingReducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case LOCATION_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
currentLocation: payload.location,
|
||||
currentCanonicalUrl: payload.canonicalUrl,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const locationChanged = (location, canonicalUrl) => ({
|
||||
type: LOCATION_CHANGED,
|
||||
payload: { location, canonicalUrl },
|
||||
});
|
||||
|
|
@ -6,20 +6,22 @@
|
|||
|
||||
import { reducer as form } from 'redux-form';
|
||||
import Auth from './Auth.duck';
|
||||
import EmailVerification from './EmailVerification.duck';
|
||||
import FlashNotification from './FlashNotification.duck';
|
||||
import LocationFilter from './LocationFilter.duck';
|
||||
import Routing from './Routing.duck';
|
||||
import UI from './UI.duck';
|
||||
import marketplaceData from './marketplaceData.duck';
|
||||
import user from './user.duck';
|
||||
import EmailVerification from './EmailVerification.duck';
|
||||
|
||||
export {
|
||||
form,
|
||||
Auth,
|
||||
EmailVerification,
|
||||
FlashNotification,
|
||||
LocationFilter,
|
||||
Routing,
|
||||
UI,
|
||||
form,
|
||||
marketplaceData,
|
||||
user,
|
||||
EmailVerification,
|
||||
};
|
||||
|
|
|
|||
20
src/index.js
20
src/index.js
|
|
@ -24,6 +24,7 @@ import { authInfo } from './ducks/Auth.duck';
|
|||
import { fetchCurrentUser } from './ducks/user.duck';
|
||||
import routeConfiguration from './routeConfiguration';
|
||||
import * as log from './util/log';
|
||||
import { LoggingAnalyticsHandler, GoogleAnalyticsHandler } from './analytics/handlers';
|
||||
|
||||
import './marketplaceIndex.css';
|
||||
|
||||
|
|
@ -53,6 +54,22 @@ const setupStripe = () => {
|
|||
window.Stripe.setPublishableKey(config.stripe.publishableKey);
|
||||
};
|
||||
|
||||
const setupAnalyticsHandlers = () => {
|
||||
let handlers = [];
|
||||
|
||||
// Log analytics page views and events in dev mode
|
||||
if (config.dev) {
|
||||
handlers.push(new LoggingAnalyticsHandler());
|
||||
}
|
||||
|
||||
// Add Google Analytics handler if tracker ID is found
|
||||
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) {
|
||||
handlers.push(new GoogleAnalyticsHandler(window.ga));
|
||||
}
|
||||
|
||||
return handlers;
|
||||
};
|
||||
|
||||
// If we're in a browser already, render the client application.
|
||||
if (typeof window !== 'undefined') {
|
||||
// set up logger with Sentry DSN client key and environment
|
||||
|
|
@ -74,7 +91,8 @@ if (typeof window !== 'undefined') {
|
|||
},
|
||||
],
|
||||
});
|
||||
const store = configureStore(sdk, initialState);
|
||||
const analyticsHandlers = setupAnalyticsHandlers();
|
||||
const store = configureStore(initialState, sdk, analyticsHandlers);
|
||||
|
||||
setupStripe();
|
||||
render(store);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import createReducer from './reducers';
|
||||
import * as analytics from './analytics/analytics';
|
||||
|
||||
/**
|
||||
* Create a new store with the given initial state. Adds Redux
|
||||
* middleware and enhancers.
|
||||
*/
|
||||
export default function configureStore(sdk, initialState = {}) {
|
||||
const middlewares = [thunk.withExtraArgument(sdk)];
|
||||
export default function configureStore(initialState = {}, sdk = null, analyticsHandlers = []) {
|
||||
const middlewares = [thunk.withExtraArgument(sdk), analytics.createMiddleware(analyticsHandlers)];
|
||||
|
||||
// Enable Redux Devtools in client side dev mode.
|
||||
const composeEnhancers =
|
||||
|
|
|
|||
|
|
@ -88,3 +88,32 @@ export const findRouteByRouteName = (nameToFind, routes) => {
|
|||
}
|
||||
return route;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the canonical URL from the given location
|
||||
*
|
||||
* @param {Array<{ route }>} routes - Route configuration as flat array
|
||||
* @param {Object} location - location object from React Router
|
||||
*
|
||||
* @return {String} Canonical URL of the given location
|
||||
*
|
||||
*/
|
||||
export const canonicalRoutePath = (routes, location) => {
|
||||
const { pathname, search, hash } = location;
|
||||
|
||||
const matches = matchPathname(pathname, routes);
|
||||
const isListingRoute = matches.length === 1 && matches[0].route.name === 'ListingPage';
|
||||
|
||||
if (isListingRoute) {
|
||||
// Remove the dynamic slug from the listing page canonical URL
|
||||
|
||||
const parts = pathname.split('/');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Expecting ListingPage pathname to have 4 parts');
|
||||
}
|
||||
const canonicalListingPathname = `/${parts[1]}/${parts[3]}`;
|
||||
return `${canonicalListingPathname}${search}${hash}`;
|
||||
}
|
||||
|
||||
return `${pathname}${search}${hash}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { RoutesProvider } from '../components';
|
|||
import routeConfiguration from '../routeConfiguration';
|
||||
import { renderDeep, renderShallow } from './test-helpers';
|
||||
import * as propTypes from './propTypes';
|
||||
import { createResourceLocatorString, findRouteByRouteName } from './routes';
|
||||
import { createResourceLocatorString, findRouteByRouteName, canonicalRoutePath } from './routes';
|
||||
|
||||
const { arrayOf } = PropTypes;
|
||||
|
||||
|
|
@ -68,4 +68,67 @@ describe('util/routes.js', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalRoutePath', () => {
|
||||
it('handles non-listing route', () => {
|
||||
const routes = routeConfiguration();
|
||||
const location = {
|
||||
pathname: '/',
|
||||
search: '?some=value',
|
||||
hash: '#and-some-hash',
|
||||
};
|
||||
expect(canonicalRoutePath(routes, location)).toEqual('/?some=value#and-some-hash');
|
||||
});
|
||||
it('handles ListingPage', () => {
|
||||
const routes = routeConfiguration();
|
||||
const location = {
|
||||
pathname: '/l/some-slug-here/00000000-0000-0000-0000-000000000000',
|
||||
search: '',
|
||||
hash: '',
|
||||
};
|
||||
expect(canonicalRoutePath(routes, location)).toEqual(
|
||||
'/l/00000000-0000-0000-0000-000000000000'
|
||||
);
|
||||
});
|
||||
it('handles ListingBasePage', () => {
|
||||
const routes = routeConfiguration();
|
||||
const location = {
|
||||
pathname: '/l',
|
||||
search: '',
|
||||
hash: '',
|
||||
};
|
||||
expect(canonicalRoutePath(routes, location)).toEqual('/l');
|
||||
});
|
||||
it('handles CheckoutPage', () => {
|
||||
const routes = routeConfiguration();
|
||||
const location = {
|
||||
pathname: '/l/some-slug-here/00000000-0000-0000-0000-000000000000/checkout',
|
||||
search: '',
|
||||
hash: '',
|
||||
};
|
||||
expect(canonicalRoutePath(routes, location)).toEqual(
|
||||
'/l/some-slug-here/00000000-0000-0000-0000-000000000000/checkout'
|
||||
);
|
||||
});
|
||||
it('handles NewListingPage', () => {
|
||||
const routes = routeConfiguration();
|
||||
const location = {
|
||||
pathname: '/l/new',
|
||||
search: '',
|
||||
hash: '',
|
||||
};
|
||||
expect(canonicalRoutePath(routes, location)).toEqual('/l/new');
|
||||
});
|
||||
it('handles ListingPageCanonical', () => {
|
||||
const routes = routeConfiguration();
|
||||
const location = {
|
||||
pathname: '/l/00000000-0000-0000-0000-000000000000',
|
||||
search: '',
|
||||
hash: '',
|
||||
};
|
||||
expect(canonicalRoutePath(routes, location)).toEqual(
|
||||
'/l/00000000-0000-0000-0000-000000000000'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import config from '../config';
|
||||
|
||||
export const canonicalURL = path => `${config.canonicalRootURL}${path}`;
|
||||
|
||||
const ensureOpenGraphLocale = locale => {
|
||||
switch (locale) {
|
||||
case 'en':
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue