diff --git a/package.json b/package.json
index 5b690609..7b8cf327 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"bootstrap": "^4.1.1",
"case-sensitive-paths-webpack-plugin": "2.1.1",
"chalk": "1.1.3",
+ "chart.js": "^2.7.2",
"css-loader": "0.28.7",
"dotenv": "4.0.0",
"dotenv-expand": "4.2.0",
@@ -44,6 +45,7 @@
"raf": "3.4.0",
"ramda": "^0.25.0",
"react": "^16.3.2",
+ "react-chartjs-2": "^2.7.4",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "^1.5.0",
"react-dev-utils": "^5.0.1",
diff --git a/src/api/ShlinkApiClient.js b/src/api/ShlinkApiClient.js
index 2539050b..e958b620 100644
--- a/src/api/ShlinkApiClient.js
+++ b/src/api/ShlinkApiClient.js
@@ -31,13 +31,13 @@ export class ShlinkApiClient {
const filteredOptions = reject(value => isEmpty(value) || isNil(value), options);
return this._performRequest('/short-codes', 'POST', {}, filteredOptions)
.then(resp => resp.data)
- .catch(e => this._handleAuthError(e, this.listShortUrls, [filteredOptions]));
+ .catch(e => this._handleAuthError(e, this.createShortUrl, [filteredOptions]));
};
- getShortUrlVisits = shortCode =>
- this._performRequest(`/short-codes/${shortCode}/visits`, 'GET')
+ getShortUrlVisits = (shortCode, dates) =>
+ this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates)
.then(resp => resp.data.visits.data)
- .catch(e => this._handleAuthError(e, this.listShortUrls, [shortCode]));
+ .catch(e => this._handleAuthError(e, this.getShortUrlVisits, [shortCode, dates]));
_performRequest = async (url, method = 'GET', params = {}, data = {}) => {
if (isEmpty(this._token)) {
diff --git a/src/reducers/index.js b/src/reducers/index.js
index afa1c33a..6aae91c8 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -5,6 +5,7 @@ import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult';
+import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits';
export default combineReducers({
servers: serversReducer,
@@ -12,4 +13,5 @@ export default combineReducers({
shortUrlsList: shortUrlsListReducer,
shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationResultReducer,
+ shortUrlVisits: shortUrlVisitsReducer
});
diff --git a/src/short-urls/ShortUrlVisits.js b/src/short-urls/ShortUrlVisits.js
index fbbaf082..c59e35ce 100644
--- a/src/short-urls/ShortUrlVisits.js
+++ b/src/short-urls/ShortUrlVisits.js
@@ -1,25 +1,68 @@
import React from 'react';
+import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { connect } from 'react-redux';
import { pick } from 'ramda';
+import { Card, CardBody, CardHeader } from 'reactstrap';
import { getShortUrlVisits } from './reducers/shortUrlVisits';
+import VisitsParser from '../visits/services/VisitsParser';
export class ShortUrlsVisits extends React.Component {
+ state = { startDate: '', endDate: '' };
+
componentDidMount() {
const { match: { params } } = this.props;
- this.props.getShortUrlVisits(params.shortCode);
+ this.props.getShortUrlVisits(params.shortCode, this.state);
}
render() {
- const { match: { params }, selectedServer } = this.props;
+ const { match: { params }, selectedServer, visitsParser, shortUrlVisits } = this.props;
const serverUrl = selectedServer ? selectedServer.url : '';
const shortUrl = `${serverUrl}/${params.shortCode}`;
+ const generateGraphData = stats => ({
+ labels: Object.keys(stats),
+ datasets: Object.values(stats)
+ });
return (
-
-
+
+
- {/* TODO Once Shlink's API allows it, add total visits counter, long URL and creation time */}
+
+
+
+
+
+
+ Operating systems
+
+
+
+
+
+
+
+ Browsers
+
+
+
+
+
+
+
+ Countries
+
+
+
+
+
+
+
+ Referrers
+
+
+
+
@@ -27,6 +70,10 @@ export class ShortUrlsVisits extends React.Component {
}
}
-export default connect(pick(['selectedServer']), {
+ShortUrlsVisits.defaultProps = {
+ visitsParser: VisitsParser
+};
+
+export default connect(pick(['selectedServer', 'shortUrlVisits']), {
getShortUrlVisits
})(ShortUrlsVisits);
diff --git a/src/short-urls/reducers/shortUrlVisits.js b/src/short-urls/reducers/shortUrlVisits.js
index a484a4db..df35e71a 100644
--- a/src/short-urls/reducers/shortUrlVisits.js
+++ b/src/short-urls/reducers/shortUrlVisits.js
@@ -34,11 +34,11 @@ export default function dispatch (state = initialState, action) {
}
}
-export const getShortUrlVisits = shortCode => async dispatch => {
+export const getShortUrlVisits = (shortCode, dates) => async dispatch => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
try {
- const visits = await ShlinkApiClient.getShortUrlVisits(shortCode);
+ const visits = await ShlinkApiClient.getShortUrlVisits(shortCode, dates);
dispatch({ visits, type: GET_SHORT_URL_VISITS });
} catch (e) {
dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
diff --git a/src/visits/services/VisitsParser.js b/src/visits/services/VisitsParser.js
new file mode 100644
index 00000000..2d35a760
--- /dev/null
+++ b/src/visits/services/VisitsParser.js
@@ -0,0 +1,101 @@
+import { forEach, isNil, isEmpty } from 'ramda';
+
+const osFromUserAgent = userAgent => {
+ const lowerUserAgent = userAgent.toLowerCase();
+
+ switch (true) {
+ case (lowerUserAgent.indexOf('linux') >= 0):
+ return 'Linux';
+ case (lowerUserAgent.indexOf('windows') >= 0):
+ return 'Windows';
+ case (lowerUserAgent.indexOf('mac') >= 0):
+ return 'MacOS';
+ case (lowerUserAgent.indexOf('mobi') >= 0):
+ return 'Mobile';
+ default:
+ return 'Others';
+ }
+};
+
+const browserFromUserAgent = userAgent => {
+ const lowerUserAgent = userAgent.toLowerCase();
+
+ switch (true) {
+ case (lowerUserAgent.indexOf('firefox') >= 0):
+ return 'Firefox';
+ case (lowerUserAgent.indexOf('chrome') >= 0):
+ return 'Chrome';
+ case (lowerUserAgent.indexOf('safari') >= 0):
+ return 'Safari';
+ case (lowerUserAgent.indexOf('opera') >= 0):
+ return 'Opera';
+ case (lowerUserAgent.indexOf('msie') >= 0):
+ return 'Internet Explorer';
+ default:
+ return 'Others';
+ }
+};
+
+const extractDomain = url => {
+ const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0];
+ return domain.split(':')[0];
+};
+
+// FIXME Refactor these foreach statements which mutate a stats object
+export class VisitsParser {
+ processOsStats = visits => {
+ const stats = {};
+
+ forEach(visit => {
+ const userAgent = visit.userAgent;
+ const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
+
+ stats[os] = typeof stats[os] === 'undefined' ? 1 : stats[os] + 1;
+ }, visits);
+
+ return stats;
+ };
+
+ processBrowserStats = visits => {
+ const stats = {};
+
+ forEach(visit => {
+ const userAgent = visit.userAgent;
+ const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
+
+ stats[browser] = typeof stats[browser] === 'undefined' ? 1 : stats[browser] + 1;
+ }, visits);
+
+ return stats;
+ };
+
+ processReferrersStats = visits => {
+ const stats = {};
+
+ forEach(visit => {
+ const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer);
+ const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer);
+
+ stats[domain] = typeof stats[domain] === 'undefined' ? 1 : stats[domain] + 1;
+ }, visits);
+
+ return stats;
+ };
+
+ processCountriesStats = visits => {
+ const stats = {};
+
+ forEach(({ visitLocation }) => {
+ const notHasCountry = isNil(visitLocation)
+ || isNil(visitLocation.countryName)
+ || isEmpty(visitLocation.countryName);
+ const country = notHasCountry ? 'Unknown' : visitLocation.countryName;
+
+ stats[country] = typeof stats[country] === 'undefined' ? 1 : stats[country] + 1;
+ }, visits);
+
+ return stats;
+ };
+}
+
+export default new VisitsParser();
diff --git a/yarn.lock b/yarn.lock
index 1d32185d..27e6da93 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1437,6 +1437,26 @@ chardet@^0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
+chart.js@^2.7.2:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.7.2.tgz#3c9fde4dc5b95608211bdefeda7e5d33dffa5714"
+ dependencies:
+ chartjs-color "^2.1.0"
+ moment "^2.10.2"
+
+chartjs-color-string@^0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz#8d3752d8581d86687c35bfe2cb80ac5213ceb8c1"
+ dependencies:
+ color-name "^1.0.0"
+
+chartjs-color@^2.1.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.2.0.tgz#84a2fb755787ed85c39dd6dd8c7b1d88429baeae"
+ dependencies:
+ chartjs-color-string "^0.5.0"
+ color-convert "^0.5.3"
+
cheerio@^1.0.0-rc.2:
version "1.0.0-rc.2"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
@@ -1589,6 +1609,10 @@ collection-visit@^1.0.0:
map-visit "^1.0.0"
object-visit "^1.0.0"
+color-convert@^0.5.3:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
+
color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.2"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.2.tgz#49881b8fba67df12a96bdf3f56c0aab9e7913147"
@@ -4905,7 +4929,7 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi
dependencies:
minimist "0.0.8"
-moment@^2.22.2:
+moment@^2.10.2, moment@^2.22.2:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
@@ -6070,6 +6094,13 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
+react-chartjs-2@^2.7.4:
+ version "2.7.4"
+ resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.7.4.tgz#e41ea4e81491dc78347111126a48e96ee57db1a6"
+ dependencies:
+ lodash "^4.17.4"
+ prop-types "^15.5.8"
+
react-copy-to-clipboard@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e"