mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-11 18:13:47 +00:00
Created VisitsTable
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||||
import './SimplePaginator.scss';
|
import './SimplePaginator.scss';
|
||||||
@@ -8,9 +9,10 @@ const propTypes = {
|
|||||||
pagesCount: PropTypes.number.isRequired,
|
pagesCount: PropTypes.number.isRequired,
|
||||||
currentPage: PropTypes.number.isRequired,
|
currentPage: PropTypes.number.isRequired,
|
||||||
setCurrentPage: PropTypes.func.isRequired,
|
setCurrentPage: PropTypes.func.isRequired,
|
||||||
|
centered: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||||
if (pagesCount < 2) {
|
if (pagesCount < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -18,7 +20,7 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
|||||||
const onClick = (page) => () => setCurrentPage(page);
|
const onClick = (page) => () => setCurrentPage(page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
|
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||||
<PaginationItem disabled={currentPage <= 1}>
|
<PaginationItem disabled={currentPage <= 1}>
|
||||||
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ const SearchBar = (colorGenerator, ForServerVersion) => {
|
|||||||
|
|
||||||
<ForServerVersion minVersion="1.21.0">
|
<ForServerVersion minVersion="1.21.0">
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<DateRangeRow
|
<div className="row">
|
||||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
<DateRangeRow
|
||||||
onStartDateChange={setDate('startDate')}
|
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||||
onEndDateChange={setDate('endDate')}
|
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||||
/>
|
onStartDateChange={setDate('startDate')}
|
||||||
|
onEndDateChange={setDate('endDate')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ForServerVersion>
|
</ForServerVersion>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const propTypes = {
|
|||||||
|
|
||||||
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
|
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-xl-3 col-lg-4 col-md-6 offset-xl-6 offset-lg-4">
|
<div className="col-md-6">
|
||||||
<DateInput
|
<DateInput
|
||||||
selected={startDate}
|
selected={startDate}
|
||||||
placeholderText="Since"
|
placeholderText="Since"
|
||||||
@@ -22,7 +22,7 @@ const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }
|
|||||||
onChange={onStartDateChange}
|
onChange={onStartDateChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
<div className="col-md-6">
|
||||||
<DateInput
|
<DateInput
|
||||||
className="date-range-row__date-input"
|
className="date-range-row__date-input"
|
||||||
selected={endDate}
|
selected={endDate}
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export default class SearchField extends React.Component {
|
|||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
|
large: PropTypes.bool,
|
||||||
|
noBorder: PropTypes.bool,
|
||||||
};
|
};
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
className: '',
|
className: '',
|
||||||
placeholder: 'Search...',
|
placeholder: 'Search...',
|
||||||
|
large: true,
|
||||||
|
noBorder: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = { showClearBtn: false, searchTerm: '' };
|
state = { showClearBtn: false, searchTerm: '' };
|
||||||
@@ -41,13 +45,16 @@ export default class SearchField extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { className, placeholder } = this.props;
|
const { className, placeholder, large, noBorder } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('search-field', className)}>
|
<div className={classNames('search-field', className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control form-control-lg search-field__input"
|
className={classNames('form-control search-field__input', {
|
||||||
|
'form-control-lg': large,
|
||||||
|
'search-field__input--no-border': noBorder,
|
||||||
|
})}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={this.state.searchTerm}
|
value={this.state.searchTerm}
|
||||||
onChange={(e) => this.searchTermChanged(e.target.value)}
|
onChange={(e) => this.searchTermChanged(e.target.value)}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
padding-right: 40px;
|
padding-right: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-field__input--no-border.search-field__input--no-border {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.search-field__icon {
|
.search-field__icon {
|
||||||
@include vertical-align();
|
@include vertical-align();
|
||||||
|
|
||||||
|
|||||||
59
src/utils/helpers/visits.js
Normal file
59
src/utils/helpers/visits.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { hasValue } from '../utils';
|
||||||
|
|
||||||
|
const DEFAULT = 'Others';
|
||||||
|
|
||||||
|
export const osFromUserAgent = (userAgent) => {
|
||||||
|
if (!hasValue(userAgent)) {
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerUserAgent = userAgent.toLowerCase();
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case lowerUserAgent.includes('linux'):
|
||||||
|
return 'Linux';
|
||||||
|
case lowerUserAgent.includes('windows'):
|
||||||
|
return 'Windows';
|
||||||
|
case lowerUserAgent.includes('mac'):
|
||||||
|
return 'MacOS';
|
||||||
|
case lowerUserAgent.includes('mobi'):
|
||||||
|
return 'Mobile';
|
||||||
|
default:
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const browserFromUserAgent = (userAgent) => {
|
||||||
|
if (!hasValue(userAgent)) {
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerUserAgent = userAgent.toLowerCase();
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case lowerUserAgent.includes('opera') || lowerUserAgent.includes('opr'):
|
||||||
|
return 'Opera';
|
||||||
|
case lowerUserAgent.includes('firefox'):
|
||||||
|
return 'Firefox';
|
||||||
|
case lowerUserAgent.includes('chrome'):
|
||||||
|
return 'Chrome';
|
||||||
|
case lowerUserAgent.includes('safari'):
|
||||||
|
return 'Safari';
|
||||||
|
case lowerUserAgent.includes('edg'):
|
||||||
|
return 'Microsoft Edge';
|
||||||
|
case lowerUserAgent.includes('msie'):
|
||||||
|
return 'Internet Explorer';
|
||||||
|
default:
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractDomain = (url) => {
|
||||||
|
if (!hasValue(url)) {
|
||||||
|
return 'Direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = url.includes('://') ? url.split('/')[2] : url.split('/')[0];
|
||||||
|
|
||||||
|
return domain.split(':')[0];
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { isEmpty, mapObjIndexed, values } from 'ramda';
|
import { isEmpty, mapObjIndexed, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card } from 'reactstrap';
|
import { Button, Card, Collapse } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||||
import DateRangeRow from '../utils/DateRangeRow';
|
import DateRangeRow from '../utils/DateRangeRow';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import { formatDate } from '../utils/helpers/date';
|
import { formatDate } from '../utils/helpers/date';
|
||||||
@@ -11,6 +13,7 @@ import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
|||||||
import VisitsHeader from './VisitsHeader';
|
import VisitsHeader from './VisitsHeader';
|
||||||
import GraphCard from './GraphCard';
|
import GraphCard from './GraphCard';
|
||||||
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
import { shortUrlDetailType } from './reducers/shortUrlDetail';
|
||||||
|
import VisitsTable from './VisitsTable';
|
||||||
|
|
||||||
const ShortUrlVisits = (
|
const ShortUrlVisits = (
|
||||||
{ processStatsFromVisits },
|
{ processStatsFromVisits },
|
||||||
@@ -30,7 +33,12 @@ const ShortUrlVisits = (
|
|||||||
cancelGetShortUrlVisits: PropTypes.func,
|
cancelGetShortUrlVisits: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = { startDate: undefined, endDate: undefined };
|
state = {
|
||||||
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
|
showTable: false,
|
||||||
|
};
|
||||||
|
|
||||||
loadVisits = (loadDetail = false) => {
|
loadVisits = (loadDetail = false) => {
|
||||||
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
|
const { match: { params }, location: { search }, getShortUrlVisits, getShortUrlDetail } = this.props;
|
||||||
const { shortCode } = params;
|
const { shortCode } = params;
|
||||||
@@ -57,10 +65,9 @@ const ShortUrlVisits = (
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { shortUrlVisits, shortUrlDetail } = this.props;
|
const { shortUrlVisits, shortUrlDetail } = this.props;
|
||||||
|
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
||||||
|
|
||||||
const renderVisitsContent = () => {
|
const renderVisitsContent = () => {
|
||||||
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
|
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
|
||||||
|
|
||||||
@@ -137,14 +144,31 @@ const ShortUrlVisits = (
|
|||||||
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
|
<VisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} />
|
||||||
|
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<DateRangeRow
|
<div className="row flex-md-row-reverse">
|
||||||
startDate={this.state.startDate}
|
<div className="col-lg-8 col-xl-6">
|
||||||
endDate={this.state.endDate}
|
<DateRangeRow
|
||||||
onStartDateChange={setDate('startDate')}
|
startDate={this.state.startDate}
|
||||||
onEndDateChange={setDate('endDate')}
|
endDate={this.state.endDate}
|
||||||
/>
|
onStartDateChange={setDate('startDate')}
|
||||||
|
onEndDateChange={setDate('endDate')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4 col-xl-6 mt-4 mt-lg-0">
|
||||||
|
{visits.length > 0 && (
|
||||||
|
<Button outline onClick={() => this.setState(({ showTable }) => ({ showTable: !showTable }))}>
|
||||||
|
Show table <FontAwesomeIcon icon={chevronDown} rotation={this.state.showTable ? 180 : undefined} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{!loading && visits.length > 0 && (
|
||||||
|
<Collapse isOpen={this.state.showTable}>
|
||||||
|
<VisitsTable visits={visits} />
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{renderVisitsContent()}
|
{renderVisitsContent()}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
176
src/visits/VisitsTable.js
Normal file
176
src/visits/VisitsTable.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Moment from 'react-moment';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { map } from 'ramda';
|
||||||
|
import {
|
||||||
|
faCaretDown as caretDownIcon,
|
||||||
|
faCaretUp as caretUpIcon,
|
||||||
|
faCheck as checkIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
|
import SearchField from '../utils/SearchField';
|
||||||
|
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../utils/helpers/visits';
|
||||||
|
import { determineOrderDir } from '../utils/utils';
|
||||||
|
import { visitType } from './reducers/shortUrlVisits';
|
||||||
|
import './VisitsTable.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
visits: PropTypes.arrayOf(visitType).isRequired,
|
||||||
|
onVisitSelected: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const visitMatchesSearch = ({ browser, os, referer, location }, searchTerm) =>
|
||||||
|
`${browser} ${os} ${referer} ${location}`.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const calculateVisits = (allVisits, page, searchTerm, { field, dir }) => {
|
||||||
|
const end = page * PAGE_SIZE;
|
||||||
|
const start = end - PAGE_SIZE;
|
||||||
|
const filteredVisits = searchTerm ? allVisits.filter((visit) => visitMatchesSearch(visit, searchTerm)) : allVisits;
|
||||||
|
const total = filteredVisits.length;
|
||||||
|
const visits = filteredVisits
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!dir) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const greaterThan = dir === 'ASC' ? 1 : -1;
|
||||||
|
const smallerThan = dir === 'ASC' ? -1 : 1;
|
||||||
|
|
||||||
|
return a[field] > b[field] ? greaterThan : smallerThan;
|
||||||
|
})
|
||||||
|
.slice(start, end);
|
||||||
|
|
||||||
|
return { visits, start, end, total };
|
||||||
|
};
|
||||||
|
const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({
|
||||||
|
date,
|
||||||
|
browser: browserFromUserAgent(userAgent),
|
||||||
|
os: osFromUserAgent(userAgent),
|
||||||
|
referer: extractDomain(referer),
|
||||||
|
location: visitLocation ? `${visitLocation.countryName} - ${visitLocation.cityName}` : '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const VisitsTable = ({ visits, onVisitSelected }) => {
|
||||||
|
const allVisits = normalizeVisits(visits);
|
||||||
|
|
||||||
|
const [ selectedVisit, setSelectedVisit ] = useState(undefined);
|
||||||
|
const [ page, setPage ] = useState(1);
|
||||||
|
const [ searchTerm, setSearchTerm ] = useState(undefined);
|
||||||
|
const [ order, setOrder ] = useState({ field: undefined, dir: undefined });
|
||||||
|
const [ currentPage, setCurrentPageVisits ] = useState(calculateVisits(allVisits, page, searchTerm, order));
|
||||||
|
|
||||||
|
const orderByColumn = (field) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||||
|
const renderOrderIcon = (field) => {
|
||||||
|
if (!order.dir || order.field !== field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||||
|
className="visits-table__header-icon"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onVisitSelected && onVisitSelected(selectedVisit);
|
||||||
|
}, [ selectedVisit ]);
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPageVisits(calculateVisits(allVisits, page, searchTerm, order));
|
||||||
|
}, [ page, searchTerm, order ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="table table-striped table-bordered table-hover table-sm table-responsive-sm mt-4 mb-0">
|
||||||
|
<thead className="short-urls-list__header">
|
||||||
|
<tr>
|
||||||
|
<th className="text-center">
|
||||||
|
<FontAwesomeIcon icon={checkIcon} />
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('date')}>
|
||||||
|
{renderOrderIcon('date')}
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('location')}>
|
||||||
|
{renderOrderIcon('location')}
|
||||||
|
Location
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('browser')}>
|
||||||
|
{renderOrderIcon('browser')}
|
||||||
|
Browser
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('os')}>
|
||||||
|
{renderOrderIcon('os')}
|
||||||
|
OS
|
||||||
|
</th>
|
||||||
|
<th className="short-urls-list__header-cell--with-action" onClick={orderByColumn('referer')}>
|
||||||
|
{renderOrderIcon('referer')}
|
||||||
|
Referrer
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="p-0">
|
||||||
|
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentPage.visits.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center">
|
||||||
|
No visits found with current filtering
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{currentPage.visits.map((visit, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
className={classNames({ 'table-primary': selectedVisit === visit })}
|
||||||
|
onClick={() => setSelectedVisit(selectedVisit === visit ? undefined : visit)}
|
||||||
|
>
|
||||||
|
<td className="text-center">
|
||||||
|
{selectedVisit === visit && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Moment format="YYYY-MM-DD HH:mm">{visit.date}</Moment>
|
||||||
|
</td>
|
||||||
|
<td>{visit.location}</td>
|
||||||
|
<td>{visit.browser}</td>
|
||||||
|
<td>{visit.os}</td>
|
||||||
|
<td>{visit.referer}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
{currentPage.total >= PAGE_SIZE && (
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="p-2">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-6">
|
||||||
|
<SimplePaginator
|
||||||
|
pagesCount={Math.ceil(currentPage.total / PAGE_SIZE)}
|
||||||
|
currentPage={page}
|
||||||
|
setCurrentPage={setPage}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 d-flex align-items-center flex-row-reverse">
|
||||||
|
<div>
|
||||||
|
Visits <b>{currentPage.start + 1}</b> to <b>{currentPage.end}</b> of <b>{currentPage.total}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
VisitsTable.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default VisitsTable;
|
||||||
4
src/visits/VisitsTable.scss
Normal file
4
src/visits/VisitsTable.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.visits-table__header-icon {
|
||||||
|
float: right;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
@@ -10,8 +10,24 @@ export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_V
|
|||||||
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export const visitType = PropTypes.shape({
|
||||||
|
referer: PropTypes.string,
|
||||||
|
date: PropTypes.string,
|
||||||
|
userAgent: PropTypes.string,
|
||||||
|
visitLocations: PropTypes.shape({
|
||||||
|
countryCode: PropTypes.string,
|
||||||
|
countryName: PropTypes.string,
|
||||||
|
regionName: PropTypes.string,
|
||||||
|
cityName: PropTypes.string,
|
||||||
|
latitude: PropTypes.number,
|
||||||
|
longitude: PropTypes.number,
|
||||||
|
timezone: PropTypes.string,
|
||||||
|
isEmpty: PropTypes.bool,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const shortUrlVisitsType = PropTypes.shape({
|
export const shortUrlVisitsType = PropTypes.shape({
|
||||||
visits: PropTypes.array,
|
visits: PropTypes.arrayOf(visitType),
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
error: PropTypes.bool,
|
error: PropTypes.bool,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,46 +1,5 @@
|
|||||||
import { isNil, isEmpty, memoizeWith, prop } from 'ramda';
|
import { isEmpty, isNil, memoizeWith, prop } from 'ramda';
|
||||||
|
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../../utils/helpers/visits';
|
||||||
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('opera') >= 0 || lowerUserAgent.indexOf('opr') >= 0:
|
|
||||||
return 'Opera';
|
|
||||||
case lowerUserAgent.indexOf('firefox') >= 0:
|
|
||||||
return 'Firefox';
|
|
||||||
case lowerUserAgent.indexOf('chrome') >= 0:
|
|
||||||
return 'Chrome';
|
|
||||||
case lowerUserAgent.indexOf('safari') >= 0:
|
|
||||||
return 'Safari';
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
const visitLocationHasProperty = (visitLocation, propertyName) =>
|
const visitLocationHasProperty = (visitLocation, propertyName) =>
|
||||||
!isNil(visitLocation)
|
!isNil(visitLocation)
|
||||||
@@ -48,20 +7,19 @@ const visitLocationHasProperty = (visitLocation, propertyName) =>
|
|||||||
&& !isEmpty(visitLocation[propertyName]);
|
&& !isEmpty(visitLocation[propertyName]);
|
||||||
|
|
||||||
const updateOsStatsForVisit = (osStats, { userAgent }) => {
|
const updateOsStatsForVisit = (osStats, { userAgent }) => {
|
||||||
const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent);
|
const os = osFromUserAgent(userAgent);
|
||||||
|
|
||||||
osStats[os] = (osStats[os] || 0) + 1;
|
osStats[os] = (osStats[os] || 0) + 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
|
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
|
||||||
const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent);
|
const browser = browserFromUserAgent(userAgent);
|
||||||
|
|
||||||
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
|
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
|
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
|
||||||
const notHasDomain = isNil(referer) || isEmpty(referer);
|
const domain = extractDomain(referer);
|
||||||
const domain = notHasDomain ? 'Direct' : extractDomain(referer);
|
|
||||||
|
|
||||||
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
|
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user