Remove format pragma, run prettier on /assets (#5237)
This commit is contained in:
parent
9ca23f80ed
commit
ce1df315d3
|
|
@ -4,3 +4,4 @@ static/images/
|
||||||
.*rc
|
.*rc
|
||||||
*.json
|
*.json
|
||||||
*.config.js
|
*.config.js
|
||||||
|
js/types/query-api.d.ts
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"insertPragma": true,
|
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"semi": false
|
"semi": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/* @format */
|
|
||||||
|
|
||||||
@import 'tailwindcss/base';
|
@import 'tailwindcss/base';
|
||||||
@import 'flatpickr/dist/flatpickr.min.css';
|
@import 'flatpickr/dist/flatpickr.min.css';
|
||||||
@import './modal.css';
|
@import './modal.css';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
#chartjs-tooltip {
|
#chartjs-tooltip {
|
||||||
background-color: rgb(25 30 56);
|
background-color: rgb(25 30 56);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
/* stylelint-disable media-feature-range-notation */
|
/* stylelint-disable media-feature-range-notation */
|
||||||
/* stylelint-disable selector-class-pattern */
|
/* stylelint-disable selector-class-pattern */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
.loading {
|
.loading {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
/* stylelint-disable selector-class-pattern */
|
/* stylelint-disable selector-class-pattern */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@import "tailwindcss/base";
|
@import 'tailwindcss/base';
|
||||||
@import "tailwindcss/components";
|
@import 'tailwindcss/components';
|
||||||
@import "tailwindcss/utilities";
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Put your component styling within the Tailwind utilities layer.
|
* Put your component styling within the Tailwind utilities layer.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
[tooltip] {
|
[tooltip] {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import "./polyfills/closest"
|
import './polyfills/closest'
|
||||||
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
|
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
|
||||||
import Alpine from 'alpinejs'
|
import Alpine from 'alpinejs'
|
||||||
import "./liveview/live_socket"
|
import './liveview/live_socket'
|
||||||
import comboBox from "./liveview/combo-box"
|
import comboBox from './liveview/combo-box'
|
||||||
import dropdown from "./liveview/dropdown"
|
import dropdown from './liveview/dropdown'
|
||||||
import "./liveview/phx_events"
|
import './liveview/phx_events'
|
||||||
|
|
||||||
Alpine.data('dropdown', dropdown)
|
Alpine.data('dropdown', dropdown)
|
||||||
Alpine.data('comboBox', comboBox)
|
Alpine.data('comboBox', comboBox)
|
||||||
|
|
@ -30,14 +30,14 @@ if (document.querySelectorAll('[data-modal]').length > 0) {
|
||||||
const triggers = document.querySelectorAll('[data-dropdown-trigger]')
|
const triggers = document.querySelectorAll('[data-dropdown-trigger]')
|
||||||
|
|
||||||
for (const trigger of triggers) {
|
for (const trigger of triggers) {
|
||||||
trigger.addEventListener('click', function(e) {
|
trigger.addEventListener('click', function (e) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.currentTarget.nextElementSibling.classList.remove('hidden')
|
e.currentTarget.nextElementSibling.classList.remove('hidden')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (triggers.length > 0) {
|
if (triggers.length > 0) {
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function (e) {
|
||||||
const dropdown = e.target.closest('[data-dropdown]')
|
const dropdown = e.target.closest('[data-dropdown]')
|
||||||
|
|
||||||
if (dropdown && e.target.tagName === 'A') {
|
if (dropdown && e.target.tagName === 'A') {
|
||||||
|
|
@ -45,7 +45,7 @@ if (triggers.length > 0) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function (e) {
|
||||||
const clickedInDropdown = e.target.closest('[data-dropdown]')
|
const clickedInDropdown = e.target.closest('[data-dropdown]')
|
||||||
|
|
||||||
if (!clickedInDropdown) {
|
if (!clickedInDropdown) {
|
||||||
|
|
@ -61,7 +61,9 @@ const changelogNotification = document.getElementById('changelog-notification')
|
||||||
if (changelogNotification) {
|
if (changelogNotification) {
|
||||||
showChangelogNotification(changelogNotification)
|
showChangelogNotification(changelogNotification)
|
||||||
|
|
||||||
fetch('https://plausible.io/changes.txt', { headers: { 'Content-Type': 'text/plain' } })
|
fetch('https://plausible.io/changes.txt', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' }
|
||||||
|
})
|
||||||
.then((res) => res.text())
|
.then((res) => res.text())
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
localStorage.lastChangelogUpdate = new Date(res).getTime()
|
localStorage.lastChangelogUpdate = new Date(res).getTime()
|
||||||
|
|
@ -87,9 +89,11 @@ function showChangelogNotification(el) {
|
||||||
</a>
|
</a>
|
||||||
`
|
`
|
||||||
const link = el.getElementsByTagName('a')[0]
|
const link = el.getElementsByTagName('a')[0]
|
||||||
link.addEventListener('click', function() {
|
link.addEventListener('click', function () {
|
||||||
localStorage.lastChangelogClick = Date.now()
|
localStorage.lastChangelogClick = Date.now()
|
||||||
setTimeout(() => { link.remove() }, 100)
|
setTimeout(() => {
|
||||||
|
link.remove()
|
||||||
|
}, 100)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +101,7 @@ function showChangelogNotification(el) {
|
||||||
const embedButton = document.getElementById('generate-embed')
|
const embedButton = document.getElementById('generate-embed')
|
||||||
|
|
||||||
if (embedButton) {
|
if (embedButton) {
|
||||||
embedButton.addEventListener('click', function(_e) {
|
embedButton.addEventListener('click', function (_e) {
|
||||||
const baseUrl = document.getElementById('base-url').value
|
const baseUrl = document.getElementById('base-url').value
|
||||||
const embedCode = document.getElementById('embed-code')
|
const embedCode = document.getElementById('embed-code')
|
||||||
const theme = document.getElementById('theme').value.toLowerCase()
|
const theme = document.getElementById('theme').value.toLowerCase()
|
||||||
|
|
@ -116,7 +120,8 @@ if (embedButton) {
|
||||||
<script async src="${baseUrl}/js/embed.host.js"></script>`
|
<script async src="${baseUrl}/js/embed.host.js"></script>`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
embedCode.value = 'ERROR: Please enter a valid URL in the shared link field'
|
embedCode.value =
|
||||||
|
'ERROR: Please enter a valid URL in the shared link field'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import 'url-search-params-polyfill'
|
import 'url-search-params-polyfill'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/** @format */
|
|
||||||
import { DashboardQuery } from './query'
|
import { DashboardQuery } from './query'
|
||||||
import { formatISO } from './util/date'
|
import { formatISO } from './util/date'
|
||||||
import { serializeApiFilters } from './util/filters'
|
import { serializeApiFilters } from './util/filters'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
Fragment,
|
Fragment,
|
||||||
useState,
|
useState,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AppNavigationLink,
|
AppNavigationLink,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { Fragment, useRef } from 'react'
|
import React, { Fragment, useRef } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useInView } from 'react-intersection-observer'
|
||||||
export default function LazyLoader(props) {
|
export default function LazyLoader(props) {
|
||||||
const [hasBecomeVisibleYet, setHasBecomeVisibleYet] = useState(false)
|
const [hasBecomeVisibleYet, setHasBecomeVisibleYet] = useState(false)
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
threshold: 0,
|
threshold: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -12,12 +12,8 @@ export default function LazyLoader(props) {
|
||||||
setHasBecomeVisibleYet(true)
|
setHasBecomeVisibleYet(true)
|
||||||
if (props.onVisible) props.onVisible()
|
if (props.onVisible) props.onVisible()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [inView])
|
}, [inView])
|
||||||
|
|
||||||
return (
|
return <div ref={ref}>{props.children}</div>
|
||||||
<div ref={ref}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,57 @@
|
||||||
import React from "react"
|
import React from 'react'
|
||||||
import { sectionTitles } from "../stats/behaviours"
|
import { sectionTitles } from '../stats/behaviours'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
import { useSiteContext } from "../site-context"
|
import { useSiteContext } from '../site-context'
|
||||||
|
|
||||||
export function FeatureSetupNotice({ feature, title, info, callToAction, onHideAction }) {
|
export function FeatureSetupNotice({
|
||||||
|
feature,
|
||||||
|
title,
|
||||||
|
info,
|
||||||
|
callToAction,
|
||||||
|
onHideAction
|
||||||
|
}) {
|
||||||
const site = useSiteContext()
|
const site = useSiteContext()
|
||||||
const sectionTitle = sectionTitles[feature]
|
const sectionTitle = sectionTitles[feature]
|
||||||
|
|
||||||
const requestHideSection = () => {
|
const requestHideSection = () => {
|
||||||
if (window.confirm(`Are you sure you want to hide ${sectionTitle}? You can make it visible again in your site settings later.`)) {
|
if (
|
||||||
api.mutation(`/api/${encodeURIComponent(site.domain)}/disable-feature`, { method: 'PUT', body: { feature: feature } })
|
window.confirm(
|
||||||
|
`Are you sure you want to hide ${sectionTitle}? You can make it visible again in your site settings later.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
api
|
||||||
|
.mutation(`/api/${encodeURIComponent(site.domain)}/disable-feature`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { feature: feature }
|
||||||
|
})
|
||||||
.then(() => onHideAction())
|
.then(() => onHideAction())
|
||||||
.catch((error) => {if (!(error instanceof api.ApiError)) {throw error}})
|
.catch((error) => {
|
||||||
|
if (!(error instanceof api.ApiError)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCallToAction() {
|
function renderCallToAction() {
|
||||||
return (
|
return (
|
||||||
<a href={callToAction.link} className="ml-2 sm:ml-4 button px-2 sm:px-4">
|
<a href={callToAction.link} className="ml-2 sm:ml-4 button px-2 sm:px-4">
|
||||||
<p className="flex flex-col justify-center text-xs sm:text-sm">{callToAction.action}</p>
|
<p className="flex flex-col justify-center text-xs sm:text-sm">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="ml-2 w-5 h-5">
|
{callToAction.action}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
</p>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="ml-2 w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|
@ -31,14 +61,15 @@ export function FeatureSetupNotice({ feature, title, info, callToAction, onHideA
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={requestHideSection}
|
onClick={requestHideSection}
|
||||||
className="inline-block px-2 sm:px-4 py-2 border border-gray-300 dark:border-gray-500 leading-5 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition ease-in-out duration-150">
|
className="inline-block px-2 sm:px-4 py-2 border border-gray-300 dark:border-gray-500 leading-5 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition ease-in-out duration-150"
|
||||||
|
>
|
||||||
Hide this report
|
Hide this report
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sm:mx-32 mt-6 mb-3" >
|
<div className="sm:mx-32 mt-6 mb-3">
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<div className="text-center mt-2 text-gray-800 dark:text-gray-200">
|
<div className="text-center mt-2 text-gray-800 dark:text-gray-200">
|
||||||
{title}
|
{title}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/** @format */
|
|
||||||
import { TransitionClasses } from '@headlessui/react'
|
import { TransitionClasses } from '@headlessui/react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react'
|
import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react'
|
||||||
import { isModifierPressed, Keybind } from '../keybinding'
|
import { isModifierPressed, Keybind } from '../keybinding'
|
||||||
import { useDebounce } from '../custom-hooks'
|
import { useDebounce } from '../custom-hooks'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by'
|
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { SortDirection } from '../hooks/use-order-by'
|
import { SortDirection } from '../hooks/use-order-by'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
// A custom hook that behaves like `useEffect`, but
|
// A custom hook that behaves like `useEffect`, but
|
||||||
// the function does not run on the initial render.
|
// the function does not run on the initial render.
|
||||||
|
|
@ -22,15 +22,20 @@ export function useDebounce(fn, delay = DEBOUNCE_DELAY) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) { clearTimeout(timerRef.current) }
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return useCallback((...args) => {
|
return useCallback(
|
||||||
clearTimeout(timerRef.current)
|
(...args) => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
fn(...args)
|
fn(...args)
|
||||||
}, delay)
|
}, delay)
|
||||||
}, [fn, delay])
|
},
|
||||||
|
[fn, delay]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { NavigateKeybind } from './keybinding'
|
import { NavigateKeybind } from './keybinding'
|
||||||
import { useRoutelessModalsContext } from './navigation/routeless-modals-context'
|
import { useRoutelessModalsContext } from './navigation/routeless-modals-context'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode, ReactElement } from 'react'
|
import React, { ReactNode, ReactElement } from 'react'
|
||||||
|
|
||||||
type ErrorBoundaryProps = {
|
type ErrorBoundaryProps = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { GoToSites, SomethingWentWrongMessage } from './something-went-wrong'
|
import { GoToSites, SomethingWentWrongMessage } from './something-went-wrong'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import RocketIcon from '../stats/modals/rocket-icon'
|
import RocketIcon from '../stats/modals/rocket-icon'
|
||||||
import { useInRouterContext } from 'react-router-dom'
|
import { useInRouterContext } from 'react-router-dom'
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export default function FunnelTooltip(palette, funnel) {
|
||||||
return (context) => {
|
return (context) => {
|
||||||
const tooltipModel = context.tooltip
|
const tooltipModel = context.tooltip
|
||||||
const dataIndex = tooltipModel.dataPoints[0].dataIndex
|
const dataIndex = tooltipModel.dataPoints[0].dataIndex
|
||||||
const offset = document.getElementById("funnel").getBoundingClientRect()
|
const offset = document.getElementById('funnel').getBoundingClientRect()
|
||||||
let tooltipEl = document.getElementById('chartjs-tooltip')
|
let tooltipEl = document.getElementById('chartjs-tooltip')
|
||||||
|
|
||||||
if (!tooltipEl) {
|
if (!tooltipEl) {
|
||||||
|
|
@ -14,7 +14,8 @@ export default function FunnelTooltip(palette, funnel) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooltipEl && offset && window.innerWidth < 768) {
|
if (tooltipEl && offset && window.innerWidth < 768) {
|
||||||
tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px'
|
tooltipEl.style.top =
|
||||||
|
offset.y + offset.height + window.scrollY + 15 + 'px'
|
||||||
tooltipEl.style.left = offset.x + 'px'
|
tooltipEl.style.left = offset.x + 'px'
|
||||||
tooltipEl.style.right = null
|
tooltipEl.style.right = null
|
||||||
tooltipEl.style.opacity = 1
|
tooltipEl.style.opacity = 1
|
||||||
|
|
@ -25,15 +26,14 @@ export default function FunnelTooltip(palette, funnel) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (tooltipModel.body) {
|
if (tooltipModel.body) {
|
||||||
const currentStep = funnel.steps[dataIndex]
|
const currentStep = funnel.steps[dataIndex]
|
||||||
const previousStep = (dataIndex > 0) ? funnel.steps[dataIndex - 1] : null
|
const previousStep = dataIndex > 0 ? funnel.steps[dataIndex - 1] : null
|
||||||
|
|
||||||
tooltipEl.innerHTML = `
|
tooltipEl.innerHTML = `
|
||||||
<aside class="text-gray-100 flex flex-col">
|
<aside class="text-gray-100 flex flex-col">
|
||||||
<div class="flex justify-between items-center border-b-2 border-gray-700 pb-2">
|
<div class="flex justify-between items-center border-b-2 border-gray-700 pb-2">
|
||||||
<span class="font-semibold mr-4 text-lg">${previousStep ? `<span class="mr-2">${previousStep.label}</span>` : ""}
|
<span class="font-semibold mr-4 text-lg">${previousStep ? `<span class="mr-2">${previousStep.label}</span>` : ''}
|
||||||
<span class="text-gray-500 mr-2">→</span>
|
<span class="text-gray-500 mr-2">→</span>
|
||||||
${tooltipModel.title}
|
${tooltipModel.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -45,7 +45,7 @@ export default function FunnelTooltip(palette, funnel) {
|
||||||
<span class="flex items-center mr-4">
|
<span class="flex items-center mr-4">
|
||||||
<div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div>
|
<div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div>
|
||||||
<span>
|
<span>
|
||||||
${dataIndex == 0 ? "Entered the funnel" : "Visitors"}
|
${dataIndex == 0 ? 'Entered the funnel' : 'Visitors'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -65,7 +65,7 @@ export default function FunnelTooltip(palette, funnel) {
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div>
|
<div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div>
|
||||||
<span>
|
<span>
|
||||||
${dataIndex == 0 ? "Never entered the funnel" : "Dropoff"}
|
${dataIndex == 0 ? 'Never entered the funnel' : 'Dropoff'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react'
|
||||||
import FlipMove from 'react-flip-move';
|
import FlipMove from 'react-flip-move'
|
||||||
import Chart from 'chart.js/auto';
|
import Chart from 'chart.js/auto'
|
||||||
import FunnelTooltip from './funnel-tooltip';
|
import FunnelTooltip from './funnel-tooltip'
|
||||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
||||||
import { numberShortFormatter } from '../util/number-formatter';
|
import { numberShortFormatter } from '../util/number-formatter'
|
||||||
import Bar from '../stats/bar';
|
import Bar from '../stats/bar'
|
||||||
|
|
||||||
import RocketIcon from '../stats/modals/rocket-icon';
|
import RocketIcon from '../stats/modals/rocket-icon'
|
||||||
|
|
||||||
import * as api from '../api';
|
|
||||||
import LazyLoader from '../components/lazy-loader';
|
|
||||||
import { useQueryContext } from '../query-context';
|
|
||||||
import { useSiteContext } from '../site-context';
|
|
||||||
|
|
||||||
|
import * as api from '../api'
|
||||||
|
import LazyLoader from '../components/lazy-loader'
|
||||||
|
import { useQueryContext } from '../query-context'
|
||||||
|
import { useSiteContext } from '../site-context'
|
||||||
|
|
||||||
export default function Funnel({ funnelName, tabs }) {
|
export default function Funnel({ funnelName, tabs }) {
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const [error, setError] = useState(undefined)
|
const [error, setError] = useState(undefined)
|
||||||
|
|
@ -46,25 +45,25 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query, funnelName, visible, isSmallScreen])
|
}, [query, funnelName, visible, isSmallScreen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current && funnel && visible && !isSmallScreen) {
|
if (canvasRef.current && funnel && visible && !isSmallScreen) {
|
||||||
initialiseChart()
|
initialiseChart()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [funnel, visible])
|
}, [funnel, visible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia('(max-width: 768px)')
|
const mediaQuery = window.matchMedia('(max-width: 768px)')
|
||||||
setSmallScreen(mediaQuery.matches)
|
setSmallScreen(mediaQuery.matches)
|
||||||
const handleScreenChange = (e) => {
|
const handleScreenChange = (e) => {
|
||||||
setSmallScreen(e.matches);
|
setSmallScreen(e.matches)
|
||||||
}
|
}
|
||||||
mediaQuery.addEventListener("change", handleScreenChange);
|
mediaQuery.addEventListener('change', handleScreenChange)
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener("change", handleScreenChange)
|
mediaQuery.removeEventListener('change', handleScreenChange)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -110,7 +109,9 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const calcOffset = (ctx) => {
|
const calcOffset = (ctx) => {
|
||||||
const conversionRate = parseFloat(funnel.steps[ctx.dataIndex].conversion_rate)
|
const conversionRate = parseFloat(
|
||||||
|
funnel.steps[ctx.dataIndex].conversion_rate
|
||||||
|
)
|
||||||
if (conversionRate > 90) {
|
if (conversionRate > 90) {
|
||||||
return -64
|
return -64
|
||||||
} else if (conversionRate > 20) {
|
} else if (conversionRate > 20) {
|
||||||
|
|
@ -129,7 +130,10 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
if (typeof funnelMeta === 'undefined') {
|
if (typeof funnelMeta === 'undefined') {
|
||||||
throw new Error('Could not fetch the funnel. Perhaps it was deleted?')
|
throw new Error('Could not fetch the funnel. Perhaps it was deleted?')
|
||||||
} else {
|
} else {
|
||||||
return api.get(`/api/stats/${encodeURIComponent(site.domain)}/funnels/${funnelMeta.id}`, query)
|
return api.get(
|
||||||
|
`/api/stats/${encodeURIComponent(site.domain)}/funnels/${funnelMeta.id}`,
|
||||||
|
query
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +153,7 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
|
|
||||||
c.fillStyle = color1
|
c.fillStyle = color1
|
||||||
c.strokeStyle = color2
|
c.strokeStyle = color2
|
||||||
c.fillRect(0, 0, shape.width, shape.height);
|
c.fillRect(0, 0, shape.width, shape.height)
|
||||||
|
|
||||||
c.beginPath()
|
c.beginPath()
|
||||||
c.moveTo(2, 0)
|
c.moveTo(2, 0)
|
||||||
|
|
@ -168,7 +172,7 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
const stepData = funnel.steps.map((step) => step.visitors)
|
const stepData = funnel.steps.map((step) => step.visitors)
|
||||||
|
|
||||||
const dropOffData = funnel.steps.map((step) => step.dropoff)
|
const dropOffData = funnel.steps.map((step) => step.dropoff)
|
||||||
const ctx = canvasRef.current.getContext("2d")
|
const ctx = canvasRef.current.getContext('2d')
|
||||||
|
|
||||||
const calcBarThickness = (ctx) => {
|
const calcBarThickness = (ctx) => {
|
||||||
if (ctx.dataset.data.length <= 3) {
|
if (ctx.dataset.data.length <= 3) {
|
||||||
|
|
@ -179,11 +183,12 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// passing those verbatim to make sure canvas rendering picks them up
|
// passing those verbatim to make sure canvas rendering picks them up
|
||||||
var fontFamily = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
var fontFamily =
|
||||||
|
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||||
|
|
||||||
var gradient = ctx.createLinearGradient(900, 0, 900, 900);
|
var gradient = ctx.createLinearGradient(900, 0, 900, 900)
|
||||||
gradient.addColorStop(1, palette.dropoffBackground);
|
gradient.addColorStop(1, palette.dropoffBackground)
|
||||||
gradient.addColorStop(0, palette.visitorsBackground);
|
gradient.addColorStop(0, palette.visitorsBackground)
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: labels,
|
labels: labels,
|
||||||
|
|
@ -194,17 +199,20 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
backgroundColor: gradient,
|
backgroundColor: gradient,
|
||||||
hoverBackgroundColor: gradient,
|
hoverBackgroundColor: gradient,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
stack: 'Stack 0',
|
stack: 'Stack 0'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Dropoff',
|
label: 'Dropoff',
|
||||||
data: dropOffData,
|
data: dropOffData,
|
||||||
backgroundColor: createDiagonalPattern(palette.dropoffBackground, palette.dropoffStripes),
|
backgroundColor: createDiagonalPattern(
|
||||||
|
palette.dropoffBackground,
|
||||||
|
palette.dropoffStripes
|
||||||
|
),
|
||||||
hoverBackgroundColor: palette.dropoffBackground,
|
hoverBackgroundColor: palette.dropoffBackground,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
stack: 'Stack 0',
|
stack: 'Stack 0'
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
|
@ -216,7 +224,7 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
barThickness: calcBarThickness,
|
barThickness: calcBarThickness,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false,
|
display: false
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
@ -234,10 +242,15 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
color: palette.dataLabelTextColor,
|
color: palette.dataLabelTextColor,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
clip: true,
|
clip: true,
|
||||||
font: { size: 12, weight: 'normal', lineHeight: 1.6, family: fontFamily },
|
font: {
|
||||||
|
size: 12,
|
||||||
|
weight: 'normal',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
family: fontFamily
|
||||||
|
},
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: { top: 8, bottom: 8, right: 8, left: 8 },
|
padding: { top: 8, bottom: 8, right: 8, left: 8 }
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: { display: false },
|
y: { display: false },
|
||||||
|
|
@ -250,10 +263,10 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
padding: 8,
|
padding: 8,
|
||||||
font: { weight: 'bold', family: fontFamily, size: 14 },
|
font: { weight: 'bold', family: fontFamily, size: 14 },
|
||||||
color: palette.stepNameLegendColor
|
color: palette.stepNameLegendColor
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chartRef.current = new Chart(ctx, config)
|
chartRef.current = new Chart(ctx, config)
|
||||||
|
|
@ -274,7 +287,9 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{header()}
|
{header()}
|
||||||
<div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">{error.message}</div>
|
<div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -284,8 +299,12 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
<div className="text-center text-gray-900 dark:text-gray-100 mt-16">
|
<div className="text-center text-gray-900 dark:text-gray-100 mt-16">
|
||||||
<RocketIcon />
|
<RocketIcon />
|
||||||
<div className="text-lg font-bold">Oops! Something went wrong</div>
|
<div className="text-lg font-bold">Oops! Something went wrong</div>
|
||||||
<div className="text-lg">{error.message ? error.message : 'Failed to render funnel'}</div>
|
<div className="text-lg">
|
||||||
<div className="text-xs mt-8">Please try refreshing your browser or selecting the funnel again.</div>
|
{error.message ? error.message : 'Failed to render funnel'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-8">
|
||||||
|
Please try refreshing your browser or selecting the funnel again.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -294,16 +313,24 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
|
|
||||||
const renderInner = () => {
|
const renderInner = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="mx-auto loading pt-44"><div></div></div>
|
return (
|
||||||
|
<div className="mx-auto loading pt-44">
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
return renderError()
|
return renderError()
|
||||||
} else if (funnel) {
|
} else if (funnel) {
|
||||||
const conversionRate = funnel.steps[funnel.steps.length - 1].conversion_rate
|
const conversionRate =
|
||||||
|
funnel.steps[funnel.steps.length - 1].conversion_rate
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{header()}
|
{header()}
|
||||||
<p className="mt-1 text-gray-500 text-sm">{funnel.steps.length}-step funnel • {conversionRate}% conversion rate</p>
|
<p className="mt-1 text-gray-500 text-sm">
|
||||||
|
{funnel.steps.length}-step funnel • {conversionRate}% conversion
|
||||||
|
rate
|
||||||
|
</p>
|
||||||
{isSmallScreen && <div className="mt-4">{renderBars(funnel)}</div>}
|
{isSmallScreen && <div className="mt-4">{renderBars(funnel)}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -320,16 +347,18 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
count={step.visitors}
|
count={step.visitors}
|
||||||
all={funnel.steps}
|
all={funnel.steps}
|
||||||
bg={palette.smallBarClass}
|
bg={palette.smallBarClass}
|
||||||
maxWidthDeduction={"5rem"}
|
maxWidthDeduction={'5rem'}
|
||||||
plot={'visitors'}
|
plot={'visitors'}
|
||||||
>
|
>
|
||||||
|
|
||||||
<span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all">
|
<span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all">
|
||||||
{step.label}
|
{step.label}
|
||||||
</span>
|
</span>
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|
||||||
<span className="font-medium dark:text-gray-200 w-20 text-right" tooltip={step.visitors.toLocaleString()}>
|
<span
|
||||||
|
className="font-medium dark:text-gray-200 w-20 text-right"
|
||||||
|
tooltip={step.visitors.toLocaleString()}
|
||||||
|
>
|
||||||
{numberShortFormatter(step.visitors)}
|
{numberShortFormatter(step.visitors)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -346,9 +375,7 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
<span className="inline-block w-20">Visitors</span>
|
<span className="inline-block w-20">Visitors</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<FlipMove>
|
<FlipMove>{funnel.steps.map(renderBar)}</FlipMove>
|
||||||
{funnel.steps.map(renderBar)}
|
|
||||||
</FlipMove>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +385,9 @@ export default function Funnel({ funnelName, tabs }) {
|
||||||
<LazyLoader onVisible={() => setVisible(true)}>
|
<LazyLoader onVisible={() => setVisible(true)}>
|
||||||
{renderInner()}
|
{renderInner()}
|
||||||
</LazyLoader>
|
</LazyLoader>
|
||||||
{!isSmallScreen && <canvas className="" id="funnel" ref={canvasRef}></canvas>}
|
{!isSmallScreen && (
|
||||||
|
<canvas className="" id="funnel" ref={canvasRef}></canvas>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
|
|
||||||
export default function FadeIn({className, show, children}) {
|
export default function FadeIn({ className, show, children }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${className || ''} ${show ? "fade-enter-active" : "fade-enter"}`}
|
className={`${className || ''} ${show ? 'fade-enter-active' : 'fade-enter'}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { SegmentsContextProvider, useSegmentsContext } from './segments-context'
|
import { SegmentsContextProvider, useSegmentsContext } from './segments-context'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/** @format */
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { remapToApiFilters } from '../util/filters'
|
import { remapToApiFilters } from '../util/filters'
|
||||||
import {
|
import {
|
||||||
formatSegmentIdAsLabelKey,
|
formatSegmentIdAsLabelKey,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { DashboardQuery, Filter } from '../query'
|
import { DashboardQuery, Filter } from '../query'
|
||||||
import { cleanLabels, remapFromApiFilters } from '../util/filters'
|
import { cleanLabels, remapFromApiFilters } from '../util/filters'
|
||||||
import { plainFilterText } from '../util/filter-text'
|
import { plainFilterText } from '../util/filter-text'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { Metric } from '../stats/reports/metrics'
|
import { Metric } from '../stats/reports/metrics'
|
||||||
import {
|
import {
|
||||||
OrderBy,
|
OrderBy,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Metric } from '../stats/reports/metrics'
|
import { Metric } from '../stats/reports/metrics'
|
||||||
import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage'
|
import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage'
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react'
|
||||||
|
|
||||||
function usePrevious<T>(value: T): T | undefined {
|
function usePrevious<T>(value: T): T | undefined {
|
||||||
const ref = useRef<T | undefined>(undefined);
|
const ref = useRef<T | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update the ref with the current value after render
|
// Update the ref with the current value after render
|
||||||
ref.current = value;
|
ref.current = value
|
||||||
}, [value]);
|
}, [value])
|
||||||
|
|
||||||
// Return the previous value
|
// Return the previous value
|
||||||
return ref.current;
|
return ref.current
|
||||||
}
|
}
|
||||||
|
|
||||||
export default usePrevious;
|
export default usePrevious
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import VisitorGraph from './stats/graph/visitor-graph'
|
import VisitorGraph from './stats/graph/visitor-graph'
|
||||||
import Sources from './stats/sources'
|
import Sources from './stats/sources'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { ReactNode, RefObject, useCallback, useEffect } from 'react'
|
import React, { ReactNode, RefObject, useCallback, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
AppNavigationTarget,
|
AppNavigationTarget,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useMemo, useRef } from 'react'
|
import React, { useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
FILTER_MODAL_TO_FILTER_GROUP,
|
FILTER_MODAL_TO_FILTER_GROUP,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
AppNavigationLink,
|
AppNavigationLink,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { DetailedHTMLProps, HTMLAttributes } from 'react'
|
import React, { DetailedHTMLProps, HTMLAttributes } from 'react'
|
||||||
import { useQueryContext } from '../query-context'
|
import { useQueryContext } from '../query-context'
|
||||||
import { FilterPill, FilterPillProps } from './filter-pill'
|
import { FilterPill, FilterPillProps } from './filter-pill'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '../../../test-utils'
|
import { render, screen } from '../../../test-utils'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'
|
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import React, { useRef, useState, useLayoutEffect, ReactNode } from 'react'
|
import React, { useRef, useState, useLayoutEffect, ReactNode } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export const MenuSeparator = () => (
|
export const MenuSeparator = () => (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useRef } from 'react'
|
import React, { useRef } from 'react'
|
||||||
import { clearedComparisonSearch } from '../../query'
|
import { clearedComparisonSearch } from '../../query'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { DateRangeCalendar } from './date-range-calendar'
|
import { DateRangeCalendar } from './date-range-calendar'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { useLayoutEffect, useRef } from 'react'
|
import React, { useLayoutEffect, useRef } from 'react'
|
||||||
import DatePicker from 'react-flatpickr'
|
import DatePicker from 'react-flatpickr'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query'
|
import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useMemo, useRef } from 'react'
|
import React, { useMemo, useRef } from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { useQueryContext } from '../../query-context'
|
import { useQueryContext } from '../../query-context'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useRef } from 'react'
|
import React, { useRef } from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { useQueryContext } from '../../query-context'
|
import { useQueryContext } from '../../query-context'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode, RefObject } from 'react'
|
import React, { ReactNode, RefObject } from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { popover } from '../../components/popover'
|
import { popover } from '../../components/popover'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useQueryContext } from '../../query-context'
|
import { useQueryContext } from '../../query-context'
|
||||||
import { useSiteContext } from '../../site-context'
|
import { useSiteContext } from '../../site-context'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { Popover, Transition } from '@headlessui/react'
|
import { Popover, Transition } from '@headlessui/react'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode, useRef } from 'react'
|
import React, { ReactNode, useRef } from 'react'
|
||||||
import SiteSwitcher from '../site-switcher'
|
import SiteSwitcher from '../site-switcher'
|
||||||
import { useSiteContext } from '../site-context'
|
import { useSiteContext } from '../site-context'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||||
import { RoutelessSegmentModal } from '../segments/routeless-segment-modals'
|
import { RoutelessSegmentModal } from '../segments/routeless-segment-modals'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { forwardRef, useCallback } from 'react'
|
import React, { forwardRef, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useAppNavigate } from './use-app-navigate'
|
import { useAppNavigate } from './use-app-navigate'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { createContext, useMemo, useContext, ReactNode } from 'react'
|
import React, { createContext, useMemo, useContext, ReactNode } from 'react'
|
||||||
import { useLocation } from 'react-router'
|
import { useLocation } from 'react-router'
|
||||||
import { useMountedEffect } from './custom-hooks'
|
import { useMountedEffect } from './custom-hooks'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ComparisonMode,
|
ComparisonMode,
|
||||||
getDashboardTimeSettings,
|
getDashboardTimeSettings,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
clearedComparisonSearch,
|
clearedComparisonSearch,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
nowForSite,
|
nowForSite,
|
||||||
formatISO,
|
formatISO,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/** @format */
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { createBrowserRouter, Outlet, useRouteError } from 'react-router-dom'
|
import { createBrowserRouter, Outlet, useRouteError } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
CreateSegmentModal,
|
CreateSegmentModal,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { SegmentAuthorship } from './segment-authorship'
|
import { SegmentAuthorship } from './segment-authorship'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
|
import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
|
||||||
import { dateForSite, formatDayShort } from '../util/date'
|
import { dateForSite, formatDayShort } from '../util/date'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { SegmentModal } from './segment-modals'
|
import { SegmentModal } from './segment-modals'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode, useState } from 'react'
|
import React, { ReactNode, useState } from 'react'
|
||||||
import ModalWithRouting from '../stats/modals/modal'
|
import ModalWithRouting from '../stats/modals/modal'
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/** @format */
|
|
||||||
import React, { HTMLAttributes } from 'react'
|
import React, { HTMLAttributes } from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { parseSiteFromDataset, PlausibleSite } from './site-context'
|
import { parseSiteFromDataset, PlausibleSite } from './site-context'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/** @format */
|
|
||||||
import React, { createContext, ReactNode, useContext } from 'react'
|
import React, { createContext, ReactNode, useContext } from 'react'
|
||||||
|
|
||||||
export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,34 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
|
|
||||||
function barWidth(count, all, plot) {
|
function barWidth(count, all, plot) {
|
||||||
let maxVal = all[0][plot];
|
let maxVal = all[0][plot]
|
||||||
|
|
||||||
for (const val of all) {
|
for (const val of all) {
|
||||||
if (val > maxVal) maxVal = val[plot]
|
if (val > maxVal) maxVal = val[plot]
|
||||||
}
|
}
|
||||||
|
|
||||||
return count / maxVal * 100
|
return (count / maxVal) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Bar({count, all, bg, maxWidthDeduction, children, plot = "visitors"}) {
|
export default function Bar({
|
||||||
|
count,
|
||||||
|
all,
|
||||||
|
bg,
|
||||||
|
maxWidthDeduction,
|
||||||
|
children,
|
||||||
|
plot = 'visitors'
|
||||||
|
}) {
|
||||||
const width = barWidth(count, all, plot)
|
const width = barWidth(count, all, plot)
|
||||||
const style = maxWidthDeduction ? {maxWidth: `calc(100% - ${maxWidthDeduction})`} : {}
|
const style = maxWidthDeduction
|
||||||
|
? { maxWidth: `calc(100% - ${maxWidthDeduction})` }
|
||||||
|
: {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full h-full relative" style={style}>
|
||||||
className="w-full h-full relative"
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
||||||
style={{width: `${width}%`}}
|
style={{ width: `${width}%` }}
|
||||||
>
|
></div>
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import * as api from '../../api';
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url'
|
||||||
|
|
||||||
import * as metrics from '../reports/metrics';
|
import * as metrics from '../reports/metrics'
|
||||||
import ListReport from '../reports/list';
|
import ListReport from '../reports/list'
|
||||||
import { useSiteContext } from '../../site-context';
|
import { useSiteContext } from '../../site-context'
|
||||||
import { useQueryContext } from '../../query-context';
|
import { useQueryContext } from '../../query-context'
|
||||||
import { conversionsRoute } from '../../router';
|
import { conversionsRoute } from '../../router'
|
||||||
|
|
||||||
export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const { query } = useQueryContext()
|
const { query } = useQueryContext()
|
||||||
|
|
||||||
function fetchConversions() {
|
function fetchConversions() {
|
||||||
|
|
@ -18,19 +18,27 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
||||||
|
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
return {
|
return {
|
||||||
prefix: "goal",
|
prefix: 'goal',
|
||||||
filter: ["is", "goal", [listItem.name]],
|
filter: ['is', 'goal', [listItem.name]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseMetrics() {
|
function chooseMetrics() {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: { plot: true } }),
|
metrics.createVisitors({
|
||||||
metrics.createEvents({ renderLabel: (_query) => "Total", meta: { hiddenOnMobile: true } }),
|
renderLabel: (_query) => 'Uniques',
|
||||||
|
meta: { plot: true }
|
||||||
|
}),
|
||||||
|
metrics.createEvents({
|
||||||
|
renderLabel: (_query) => 'Total',
|
||||||
|
meta: { hiddenOnMobile: true }
|
||||||
|
}),
|
||||||
metrics.createConversionRate(),
|
metrics.createConversionRate(),
|
||||||
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
BUILD_EXTRA &&
|
||||||
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
||||||
].filter(metric => !!metric)
|
BUILD_EXTRA &&
|
||||||
|
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
||||||
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
|
|
@ -42,7 +50,10 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
||||||
keyLabel="Goal"
|
keyLabel="Goal"
|
||||||
onClick={onGoalFilterClick}
|
onClick={onGoalFilterClick}
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
detailsLinkProps={{ path: conversionsRoute.path, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: conversionsRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
color="bg-red-50"
|
color="bg-red-50"
|
||||||
colMinWidth={90}
|
colMinWidth={90}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
import React from "react"
|
import React from 'react'
|
||||||
import Conversions from './conversions'
|
import Conversions from './conversions'
|
||||||
import ListReport from "../reports/list"
|
import ListReport from '../reports/list'
|
||||||
import * as metrics from '../reports/metrics'
|
import * as metrics from '../reports/metrics'
|
||||||
import * as url from "../../util/url"
|
import * as url from '../../util/url'
|
||||||
import * as api from "../../api"
|
import * as api from '../../api'
|
||||||
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS } from "../../util/filters"
|
import {
|
||||||
import { useSiteContext } from "../../site-context"
|
EVENT_PROPS_PREFIX,
|
||||||
import { useQueryContext } from "../../query-context"
|
getGoalFilter,
|
||||||
import { customPropsRoute } from "../../router"
|
FILTER_OPERATIONS
|
||||||
|
} from '../../util/filters'
|
||||||
|
import { useSiteContext } from '../../site-context'
|
||||||
|
import { useQueryContext } from '../../query-context'
|
||||||
|
import { customPropsRoute } from '../../router'
|
||||||
|
|
||||||
export const SPECIAL_GOALS = {
|
export const SPECIAL_GOALS = {
|
||||||
'404': { title: '404 Pages', prop: 'path' },
|
404: { title: '404 Pages', prop: 'path' },
|
||||||
'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
|
'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
|
||||||
'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
|
'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
|
||||||
'File Download': { title: 'File Downloads', prop: 'url' },
|
'File Download': { title: 'File Downloads', prop: 'url' },
|
||||||
'WP Search Queries': { title: 'WordPress Search Queries', prop: 'search_query' },
|
'WP Search Queries': {
|
||||||
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' },
|
title: 'WordPress Search Queries',
|
||||||
|
prop: 'search_query'
|
||||||
|
},
|
||||||
|
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpecialGoal(query) {
|
function getSpecialGoal(query) {
|
||||||
|
|
@ -28,7 +35,6 @@ function getSpecialGoal(query) {
|
||||||
return SPECIAL_GOALS[clauses[0]] || null
|
return SPECIAL_GOALS[clauses[0]] || null
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function specialTitleWhenGoalFilter(query, defaultTitle) {
|
export function specialTitleWhenGoalFilter(query, defaultTitle) {
|
||||||
|
|
@ -36,8 +42,8 @@ export function specialTitleWhenGoalFilter(query, defaultTitle) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpecialPropBreakdown({ prop, afterFetchData }) {
|
function SpecialPropBreakdown({ prop, afterFetchData }) {
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
|
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query)
|
return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query)
|
||||||
|
|
@ -54,16 +60,22 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
return {
|
return {
|
||||||
prefix: EVENT_PROPS_PREFIX,
|
prefix: EVENT_PROPS_PREFIX,
|
||||||
filter: ["is", `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
|
filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseMetrics() {
|
function chooseMetrics() {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }),
|
metrics.createVisitors({
|
||||||
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }),
|
renderLabel: (_query) => 'Visitors',
|
||||||
|
meta: { plot: true }
|
||||||
|
}),
|
||||||
|
metrics.createEvents({
|
||||||
|
renderLabel: (_query) => 'Events',
|
||||||
|
meta: { hiddenOnMobile: true }
|
||||||
|
}),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
].filter(metric => !!metric)
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,7 +85,11 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
||||||
getFilterInfo={getFilterInfo}
|
getFilterInfo={getFilterInfo}
|
||||||
keyLabel={prop}
|
keyLabel={prop}
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
detailsLinkProps={{ path: customPropsRoute.path, params: {propKey: url.maybeEncodeRouteParam(prop)}, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: customPropsRoute.path,
|
||||||
|
params: { propKey: url.maybeEncodeRouteParam(prop) },
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
getExternalLinkUrl={getExternalLinkUrlFactory()}
|
getExternalLinkUrl={getExternalLinkUrlFactory()}
|
||||||
maybeHideDetails={true}
|
maybeHideDetails={true}
|
||||||
color="bg-red-50"
|
color="bg-red-50"
|
||||||
|
|
@ -87,8 +103,18 @@ export default function GoalConversions({ afterFetchData, onGoalFilterClick }) {
|
||||||
|
|
||||||
const specialGoal = getSpecialGoal(query)
|
const specialGoal = getSpecialGoal(query)
|
||||||
if (specialGoal) {
|
if (specialGoal) {
|
||||||
return <SpecialPropBreakdown prop={specialGoal.prop} afterFetchData={afterFetchData} />
|
return (
|
||||||
|
<SpecialPropBreakdown
|
||||||
|
prop={specialGoal.prop}
|
||||||
|
afterFetchData={afterFetchData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
return <Conversions onGoalFilterClick={onGoalFilterClick} afterFetchData={afterFetchData} />
|
return (
|
||||||
|
<Conversions
|
||||||
|
onGoalFilterClick={onGoalFilterClick}
|
||||||
|
afterFetchData={afterFetchData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import React, { Fragment, useState, useEffect, useCallback, useRef } from 'react'
|
import React, {
|
||||||
|
Fragment,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef
|
||||||
|
} from 'react'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import * as storage from '../../util/storage'
|
import * as storage from '../../util/storage'
|
||||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
|
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
|
||||||
import GoalConversions, { specialTitleWhenGoalFilter, SPECIAL_GOALS } from './goal-conversions'
|
import GoalConversions, {
|
||||||
|
specialTitleWhenGoalFilter,
|
||||||
|
SPECIAL_GOALS
|
||||||
|
} from './goal-conversions'
|
||||||
import Properties from './props'
|
import Properties from './props'
|
||||||
import { FeatureSetupNotice } from '../../components/notice'
|
import { FeatureSetupNotice } from '../../components/notice'
|
||||||
import { hasConversionGoalFilter } from '../../util/filters'
|
import { hasConversionGoalFilter } from '../../util/filters'
|
||||||
|
|
@ -26,7 +35,8 @@ function maybeRequire() {
|
||||||
|
|
||||||
const Funnel = maybeRequire().default
|
const Funnel = maybeRequire().default
|
||||||
|
|
||||||
const ACTIVE_CLASS = 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
|
const ACTIVE_CLASS =
|
||||||
|
'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
|
||||||
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
|
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
|
||||||
|
|
||||||
export const CONVERSIONS = 'conversions'
|
export const CONVERSIONS = 'conversions'
|
||||||
|
|
@ -40,21 +50,29 @@ export const sectionTitles = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Behaviours({ importedDataInView }) {
|
export default function Behaviours({ importedDataInView }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const user = useUserContext();
|
const user = useUserContext()
|
||||||
const buttonRef = useRef();
|
const buttonRef = useRef()
|
||||||
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(user.role)
|
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(
|
||||||
|
user.role
|
||||||
|
)
|
||||||
const tabKey = storage.getDomainScopedStorageKey('behavioursTab', site.domain)
|
const tabKey = storage.getDomainScopedStorageKey('behavioursTab', site.domain)
|
||||||
const funnelKey = storage.getDomainScopedStorageKey('behavioursTabFunnel', site.domain)
|
const funnelKey = storage.getDomainScopedStorageKey(
|
||||||
|
'behavioursTabFunnel',
|
||||||
|
site.domain
|
||||||
|
)
|
||||||
const [enabledModes, setEnabledModes] = useState(getEnabledModes())
|
const [enabledModes, setEnabledModes] = useState(getEnabledModes())
|
||||||
const [mode, setMode] = useState(defaultMode())
|
const [mode, setMode] = useState(defaultMode())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const [funnelNames, _setFunnelNames] = useState(site.funnels.map(({ name }) => name))
|
const [funnelNames, _setFunnelNames] = useState(
|
||||||
|
site.funnels.map(({ name }) => name)
|
||||||
|
)
|
||||||
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
|
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
|
||||||
|
|
||||||
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
|
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] =
|
||||||
|
useState(false)
|
||||||
|
|
||||||
const [skipImportedReason, setSkipImportedReason] = useState(null)
|
const [skipImportedReason, setSkipImportedReason] = useState(null)
|
||||||
|
|
||||||
|
|
@ -63,11 +81,16 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
|
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
|
||||||
const isPageviewGoal = goalName.startsWith('Visit ')
|
const isPageviewGoal = goalName.startsWith('Visit ')
|
||||||
|
|
||||||
if (!isSpecialGoal && !isPageviewGoal && enabledModes.includes(PROPS) && site.hasProps) {
|
if (
|
||||||
|
!isSpecialGoal &&
|
||||||
|
!isPageviewGoal &&
|
||||||
|
enabledModes.includes(PROPS) &&
|
||||||
|
site.hasProps
|
||||||
|
) {
|
||||||
setShowingPropsForGoalFilter(true)
|
setShowingPropsForGoalFilter(true)
|
||||||
setMode(PROPS)
|
setMode(PROPS)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -76,18 +99,22 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
setShowingPropsForGoalFilter(false)
|
setShowingPropsForGoalFilter(false)
|
||||||
setMode(CONVERSIONS)
|
setMode(CONVERSIONS)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasConversionGoalFilter(query)])
|
}, [hasConversionGoalFilter(query)])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMode(defaultMode())
|
setMode(defaultMode())
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [enabledModes])
|
}, [enabledModes])
|
||||||
|
|
||||||
useEffect(() => setLoading(true), [query, mode])
|
useEffect(() => setLoading(true), [query, mode])
|
||||||
|
|
||||||
function disableMode(mode) {
|
function disableMode(mode) {
|
||||||
setEnabledModes(enabledModes.filter((m) => { return m !== mode }))
|
setEnabledModes(
|
||||||
|
enabledModes.filter((m) => {
|
||||||
|
return m !== mode
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFunnel(selectedFunnel) {
|
function setFunnel(selectedFunnel) {
|
||||||
|
|
@ -118,52 +145,69 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabFunnelPicker() {
|
function tabFunnelPicker() {
|
||||||
return <Menu as="div" className="relative inline-block text-left">
|
return (
|
||||||
<BlurMenuButtonOnEscape targetRef={buttonRef}/>
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
<div>
|
<BlurMenuButtonOnEscape targetRef={buttonRef} />
|
||||||
<Menu.Button ref={buttonRef} className="inline-flex justify-between focus:outline-none">
|
<div>
|
||||||
<span className={(mode == FUNNELS) ? ACTIVE_CLASS : DEFAULT_CLASS}>Funnels</span>
|
<Menu.Button
|
||||||
<ChevronDownIcon className="-mr-1 ml-1 h-4 w-4" aria-hidden="true" />
|
ref={buttonRef}
|
||||||
</Menu.Button>
|
className="inline-flex justify-between focus:outline-none"
|
||||||
</div>
|
>
|
||||||
|
<span className={mode == FUNNELS ? ACTIVE_CLASS : DEFAULT_CLASS}>
|
||||||
|
Funnels
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="-mr-1 ml-1 h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="text-left origin-top-right absolute right-0 mt-2 w-96 max-h-72 overflow-auto rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
<Menu.Items className="text-left origin-top-right absolute right-0 mt-2 w-96 max-h-72 overflow-auto rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{funnelNames.map((funnelName) => {
|
{funnelNames.map((funnelName) => {
|
||||||
return (
|
return (
|
||||||
<Menu.Item key={funnelName}>
|
<Menu.Item key={funnelName}>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<span
|
<span
|
||||||
onClick={setFunnel(funnelName)}
|
onClick={setFunnel(funnelName)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer' : 'text-gray-700 dark:text-gray-200',
|
active
|
||||||
'block px-4 py-2 text-sm',
|
? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer'
|
||||||
(mode === FUNNELS && selectedFunnel === funnelName) ? 'font-bold text-gray-500' : ''
|
: 'text-gray-700 dark:text-gray-200',
|
||||||
)}
|
'block px-4 py-2 text-sm',
|
||||||
>
|
mode === FUNNELS && selectedFunnel === funnelName
|
||||||
{funnelName}
|
? 'font-bold text-gray-500'
|
||||||
</span>
|
: ''
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
>
|
||||||
)
|
{funnelName}
|
||||||
})}
|
</span>
|
||||||
</div>
|
)}
|
||||||
</Menu.Items>
|
</Menu.Item>
|
||||||
</Transition>
|
)
|
||||||
</Menu>
|
})}
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabSwitcher(toMode, displayName) {
|
function tabSwitcher(toMode, displayName) {
|
||||||
const className = classNames({ [ACTIVE_CLASS]: mode == toMode, [DEFAULT_CLASS]: mode !== toMode })
|
const className = classNames({
|
||||||
|
[ACTIVE_CLASS]: mode == toMode,
|
||||||
|
[DEFAULT_CLASS]: mode !== toMode
|
||||||
|
})
|
||||||
const setTab = () => {
|
const setTab = () => {
|
||||||
storage.setItem(tabKey, toMode)
|
storage.setItem(tabKey, toMode)
|
||||||
setMode(toMode)
|
setMode(toMode)
|
||||||
|
|
@ -181,7 +225,9 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||||
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
|
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
|
||||||
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
|
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
|
||||||
{isEnabled(FUNNELS) && Funnel && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
|
{isEnabled(FUNNELS) &&
|
||||||
|
Funnel &&
|
||||||
|
(hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -193,37 +239,45 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
|
|
||||||
function renderConversions() {
|
function renderConversions() {
|
||||||
if (site.hasGoals) {
|
if (site.hasGoals) {
|
||||||
return <GoalConversions onGoalFilterClick={onGoalFilterClick} afterFetchData={afterFetchData} />
|
return (
|
||||||
}
|
<GoalConversions
|
||||||
else if (adminAccess) {
|
onGoalFilterClick={onGoalFilterClick}
|
||||||
|
afterFetchData={afterFetchData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (adminAccess) {
|
||||||
return (
|
return (
|
||||||
<FeatureSetupNotice
|
<FeatureSetupNotice
|
||||||
feature={CONVERSIONS}
|
feature={CONVERSIONS}
|
||||||
title={'Measure how often visitors complete specific actions'}
|
title={'Measure how often visitors complete specific actions'}
|
||||||
info={'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'}
|
info={
|
||||||
|
'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'
|
||||||
|
}
|
||||||
callToAction={{
|
callToAction={{
|
||||||
action: "Set up goals",
|
action: 'Set up goals',
|
||||||
link: `/${encodeURIComponent(site.domain)}/settings/goals`
|
link: `/${encodeURIComponent(site.domain)}/settings/goals`
|
||||||
}}
|
}}
|
||||||
onHideAction={onHideAction(CONVERSIONS)}
|
onHideAction={onHideAction(CONVERSIONS)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
return noDataYet()
|
||||||
}
|
}
|
||||||
else { return noDataYet() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFunnels() {
|
function renderFunnels() {
|
||||||
if (Funnel === null) {
|
if (Funnel === null) {
|
||||||
return featureUnavailable()
|
return featureUnavailable()
|
||||||
}
|
} else if (Funnel && selectedFunnel && site.funnelsAvailable) {
|
||||||
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
|
|
||||||
return <Funnel funnelName={selectedFunnel} />
|
return <Funnel funnelName={selectedFunnel} />
|
||||||
}
|
} else if (Funnel && adminAccess) {
|
||||||
else if (Funnel && adminAccess) {
|
|
||||||
let callToAction
|
let callToAction
|
||||||
|
|
||||||
if (site.funnelsAvailable) {
|
if (site.funnelsAvailable) {
|
||||||
callToAction = { action: 'Set up funnels', link: `/${encodeURIComponent(site.domain)}/settings/funnels` }
|
callToAction = {
|
||||||
|
action: 'Set up funnels',
|
||||||
|
link: `/${encodeURIComponent(site.domain)}/settings/funnels`
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
|
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
|
||||||
}
|
}
|
||||||
|
|
@ -232,13 +286,16 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
<FeatureSetupNotice
|
<FeatureSetupNotice
|
||||||
feature={FUNNELS}
|
feature={FUNNELS}
|
||||||
title={'Follow the visitor journey from entry to conversion'}
|
title={'Follow the visitor journey from entry to conversion'}
|
||||||
info={'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'}
|
info={
|
||||||
|
'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'
|
||||||
|
}
|
||||||
callToAction={callToAction}
|
callToAction={callToAction}
|
||||||
onHideAction={onHideAction(FUNNELS)}
|
onHideAction={onHideAction(FUNNELS)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
return noDataYet()
|
||||||
}
|
}
|
||||||
else { return noDataYet() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProps() {
|
function renderProps() {
|
||||||
|
|
@ -248,7 +305,10 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
let callToAction
|
let callToAction
|
||||||
|
|
||||||
if (site.propsAvailable) {
|
if (site.propsAvailable) {
|
||||||
callToAction = { action: 'Set up props', link: `/${encodeURIComponent(site.domain)}/settings/properties` }
|
callToAction = {
|
||||||
|
action: 'Set up props',
|
||||||
|
link: `/${encodeURIComponent(site.domain)}/settings/properties`
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
|
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
|
||||||
}
|
}
|
||||||
|
|
@ -257,12 +317,16 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
<FeatureSetupNotice
|
<FeatureSetupNotice
|
||||||
feature={PROPS}
|
feature={PROPS}
|
||||||
title={'Send custom data to create your own metrics'}
|
title={'Send custom data to create your own metrics'}
|
||||||
info={'You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don\'t track automatically.'}
|
info={
|
||||||
|
"You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don't track automatically."
|
||||||
|
}
|
||||||
callToAction={callToAction}
|
callToAction={callToAction}
|
||||||
onHideAction={onHideAction(PROPS)}
|
onHideAction={onHideAction(PROPS)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else { return noDataYet() }
|
} else {
|
||||||
|
return noDataYet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function noDataYet() {
|
function noDataYet() {
|
||||||
|
|
@ -282,7 +346,9 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHideAction(mode) {
|
function onHideAction(mode) {
|
||||||
return () => { disableMode(mode) }
|
return () => {
|
||||||
|
disableMode(mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent() {
|
function renderContent() {
|
||||||
|
|
@ -297,13 +363,21 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultMode() {
|
function defaultMode() {
|
||||||
if (enabledModes.length === 0) { return null }
|
if (enabledModes.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const storedMode = storage.getItem(tabKey)
|
const storedMode = storage.getItem(tabKey)
|
||||||
if (storedMode && enabledModes.includes(storedMode)) { return storedMode }
|
if (storedMode && enabledModes.includes(storedMode)) {
|
||||||
|
return storedMode
|
||||||
|
}
|
||||||
|
|
||||||
if (enabledModes.includes(CONVERSIONS)) { return CONVERSIONS }
|
if (enabledModes.includes(CONVERSIONS)) {
|
||||||
if (enabledModes.includes(PROPS)) { return PROPS }
|
return CONVERSIONS
|
||||||
|
}
|
||||||
|
if (enabledModes.includes(PROPS)) {
|
||||||
|
return PROPS
|
||||||
|
}
|
||||||
return FUNNELS
|
return FUNNELS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +391,7 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
// If the feature is not supported by the site owner's subscription,
|
// If the feature is not supported by the site owner's subscription,
|
||||||
// it only makes sense to display the feature tab to the owner itself
|
// it only makes sense to display the feature tab to the owner itself
|
||||||
// as only they can upgrade to make the feature available.
|
// as only they can upgrade to make the feature available.
|
||||||
const callToActionIsMissing = !isAvailable && user.role !== 'owner'
|
const callToActionIsMissing = !isAvailable && user.role !== 'owner'
|
||||||
|
|
||||||
if (!isOptedOut && !callToActionIsMissing) {
|
if (!isOptedOut && !callToActionIsMissing) {
|
||||||
enabledModes.push(feature)
|
enabledModes.push(feature)
|
||||||
|
|
@ -345,11 +419,27 @@ export default function Behaviours({ importedDataInView }) {
|
||||||
|
|
||||||
function renderImportedQueryUnsupportedWarning() {
|
function renderImportedQueryUnsupportedWarning() {
|
||||||
if (mode === CONVERSIONS) {
|
if (mode === CONVERSIONS) {
|
||||||
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
|
return (
|
||||||
|
<ImportedQueryUnsupportedWarning
|
||||||
|
loading={loading}
|
||||||
|
skipImportedReason={skipImportedReason}
|
||||||
|
/>
|
||||||
|
)
|
||||||
} else if (mode === PROPS) {
|
} else if (mode === PROPS) {
|
||||||
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} message="Imported data is unavailable in this view" />
|
return (
|
||||||
|
<ImportedQueryUnsupportedWarning
|
||||||
|
loading={loading}
|
||||||
|
skipImportedReason={skipImportedReason}
|
||||||
|
message="Imported data is unavailable in this view"
|
||||||
|
/>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
return <ImportedQueryUnsupportedWarning altCondition={importedDataInView} message="Imported data is unavailable in this view" />
|
return (
|
||||||
|
<ImportedQueryUnsupportedWarning
|
||||||
|
altCondition={importedDataInView}
|
||||||
|
message="Imported data is unavailable in this view"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import ListReport, { MIN_HEIGHT } from "../reports/list";
|
import ListReport, { MIN_HEIGHT } from '../reports/list'
|
||||||
import Combobox from '../../components/combobox';
|
import Combobox from '../../components/combobox'
|
||||||
import * as metrics from '../reports/metrics';
|
import * as metrics from '../reports/metrics'
|
||||||
import * as api from '../../api';
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url'
|
||||||
import * as storage from "../../util/storage";
|
import * as storage from '../../util/storage'
|
||||||
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasConversionGoalFilter } from "../../util/filters";
|
import {
|
||||||
import classNames from "classnames";
|
EVENT_PROPS_PREFIX,
|
||||||
import { useQueryContext } from "../../query-context";
|
getGoalFilter,
|
||||||
import { useSiteContext } from "../../site-context";
|
FILTER_OPERATIONS,
|
||||||
import { customPropsRoute } from "../../router";
|
hasConversionGoalFilter
|
||||||
|
} from '../../util/filters'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { useQueryContext } from '../../query-context'
|
||||||
|
import { useSiteContext } from '../../site-context'
|
||||||
|
import { customPropsRoute } from '../../router'
|
||||||
|
|
||||||
export default function Properties({ afterFetchData }) {
|
export default function Properties({ afterFetchData }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
|
|
||||||
const propKeyStorageName = `prop_key__${site.domain}`
|
const propKeyStorageName = `prop_key__${site.domain}`
|
||||||
const propKeyStorageNameForGoal = () => {
|
const propKeyStorageNameForGoal = () => {
|
||||||
|
|
@ -39,8 +43,8 @@ export default function Properties({ afterFetchData }) {
|
||||||
setPropKeyLoading(true)
|
setPropKeyLoading(true)
|
||||||
setPropKey(null)
|
setPropKey(null)
|
||||||
|
|
||||||
fetchPropKeyOptions()("").then((propKeys) => {
|
fetchPropKeyOptions()('').then((propKeys) => {
|
||||||
const propKeyValues = propKeys.map(entry => entry.value)
|
const propKeyValues = propKeys.map((entry) => entry.value)
|
||||||
|
|
||||||
if (propKeyValues.length > 0) {
|
if (propKeyValues.length > 0) {
|
||||||
const storedPropKey = getPropKeyFromStorage()
|
const storedPropKey = getPropKeyFromStorage()
|
||||||
|
|
@ -54,35 +58,45 @@ export default function Properties({ afterFetchData }) {
|
||||||
|
|
||||||
setPropKeyLoading(false)
|
setPropKeyLoading(false)
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
function getPropKeyFromStorage() {
|
function getPropKeyFromStorage() {
|
||||||
if (singleGoalFilterApplied()) {
|
if (singleGoalFilterApplied()) {
|
||||||
const storedForGoal = storage.getItem(propKeyStorageNameForGoal())
|
const storedForGoal = storage.getItem(propKeyStorageNameForGoal())
|
||||||
if (storedForGoal) { return storedForGoal }
|
if (storedForGoal) {
|
||||||
|
return storedForGoal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return storage.getItem(propKeyStorageName)
|
return storage.getItem(propKeyStorageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchProps() {
|
function fetchProps() {
|
||||||
return api.get(url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), query)
|
return api.get(
|
||||||
|
url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`),
|
||||||
|
query
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchPropKeyOptions = useCallback(() => {
|
const fetchPropKeyOptions = useCallback(() => {
|
||||||
return (input) => {
|
return (input) => {
|
||||||
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() })
|
return api.get(url.apiPath(site, '/suggestions/prop_key'), query, {
|
||||||
|
q: input.trim()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
function onPropKeySelect() {
|
function onPropKeySelect() {
|
||||||
return (selectedOptions) => {
|
return (selectedOptions) => {
|
||||||
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0].value
|
const newPropKey =
|
||||||
|
selectedOptions.length === 0 ? null : selectedOptions[0].value
|
||||||
|
|
||||||
if (newPropKey) {
|
if (newPropKey) {
|
||||||
const storageName = singleGoalFilterApplied() ? propKeyStorageNameForGoal() : propKeyStorageName
|
const storageName = singleGoalFilterApplied()
|
||||||
|
? propKeyStorageNameForGoal()
|
||||||
|
: propKeyStorageName
|
||||||
storage.setItem(storageName, newPropKey)
|
storage.setItem(storageName, newPropKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,13 +107,21 @@ export default function Properties({ afterFetchData }) {
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
function chooseMetrics() {
|
function chooseMetrics() {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }),
|
metrics.createVisitors({
|
||||||
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }),
|
renderLabel: (_query) => 'Visitors',
|
||||||
|
meta: { plot: true }
|
||||||
|
}),
|
||||||
|
metrics.createEvents({
|
||||||
|
renderLabel: (_query) => 'Events',
|
||||||
|
meta: { hiddenOnMobile: true }
|
||||||
|
}),
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||||
!hasConversionGoalFilter(query) && metrics.createPercentage(),
|
!hasConversionGoalFilter(query) && metrics.createPercentage(),
|
||||||
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
BUILD_EXTRA &&
|
||||||
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
||||||
].filter(metric => !!metric)
|
BUILD_EXTRA &&
|
||||||
|
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
||||||
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBreakdown() {
|
function renderBreakdown() {
|
||||||
|
|
@ -110,7 +132,11 @@ export default function Properties({ afterFetchData }) {
|
||||||
getFilterInfo={getFilterInfo}
|
getFilterInfo={getFilterInfo}
|
||||||
keyLabel={propKey}
|
keyLabel={propKey}
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
detailsLinkProps={{ path: customPropsRoute.path, params: { propKey }, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: customPropsRoute.path,
|
||||||
|
params: { propKey },
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
maybeHideDetails={true}
|
maybeHideDetails={true}
|
||||||
color="bg-red-50"
|
color="bg-red-50"
|
||||||
colMinWidth={90}
|
colMinWidth={90}
|
||||||
|
|
@ -120,22 +146,38 @@ export default function Properties({ afterFetchData }) {
|
||||||
|
|
||||||
const getFilterInfo = (listItem) => ({
|
const getFilterInfo = (listItem) => ({
|
||||||
prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
|
prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
|
||||||
filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
|
filter: ['is', `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
|
||||||
})
|
})
|
||||||
|
|
||||||
const comboboxDisabled = !propKeyLoading && !propKey
|
const comboboxDisabled = !propKeyLoading && !propKey
|
||||||
const comboboxPlaceholder = comboboxDisabled ? 'No custom properties found' : ''
|
const comboboxPlaceholder = comboboxDisabled
|
||||||
|
? 'No custom properties found'
|
||||||
|
: ''
|
||||||
const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
|
const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
|
||||||
const boxClass = classNames('pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500', {
|
const boxClass = classNames(
|
||||||
'pointer-events-none': comboboxDisabled
|
'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500',
|
||||||
})
|
{
|
||||||
|
'pointer-events-none': comboboxDisabled
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const COMBOBOX_HEIGHT = 40
|
const COMBOBOX_HEIGHT = 40
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mt-4" style={{ minHeight: `${COMBOBOX_HEIGHT + MIN_HEIGHT}px` }}>
|
<div
|
||||||
|
className="w-full mt-4"
|
||||||
|
style={{ minHeight: `${COMBOBOX_HEIGHT + MIN_HEIGHT}px` }}
|
||||||
|
>
|
||||||
<div style={{ minHeight: `${COMBOBOX_HEIGHT}px` }}>
|
<div style={{ minHeight: `${COMBOBOX_HEIGHT}px` }}>
|
||||||
<Combobox boxClass={boxClass} forceLoading={propKeyLoading} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={comboboxPlaceholder} />
|
<Combobox
|
||||||
|
boxClass={boxClass}
|
||||||
|
forceLoading={propKeyLoading}
|
||||||
|
fetchOptions={fetchPropKeyOptions()}
|
||||||
|
singleOption={true}
|
||||||
|
values={comboboxValues}
|
||||||
|
onSelect={onPropKeySelect()}
|
||||||
|
placeholder={comboboxPlaceholder}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{propKey && renderBreakdown()}
|
{propKey && renderBreakdown()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { AppNavigationLink } from '../navigation/use-app-navigate'
|
import { AppNavigationLink } from '../navigation/use-app-navigate'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,36 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react'
|
||||||
import * as storage from '../../util/storage';
|
import * as storage from '../../util/storage'
|
||||||
import { getFiltersByKeyPrefix, hasConversionGoalFilter, isFilteringOnFixedValue } from '../../util/filters';
|
import {
|
||||||
import ListReport from '../reports/list';
|
getFiltersByKeyPrefix,
|
||||||
import * as metrics from '../reports/metrics';
|
hasConversionGoalFilter,
|
||||||
import * as api from '../../api';
|
isFilteringOnFixedValue
|
||||||
import * as url from '../../util/url';
|
} from '../../util/filters'
|
||||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
import ListReport from '../reports/list'
|
||||||
import { useQueryContext } from '../../query-context';
|
import * as metrics from '../reports/metrics'
|
||||||
import { useSiteContext } from '../../site-context';
|
import * as api from '../../api'
|
||||||
|
import * as url from '../../util/url'
|
||||||
|
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
|
||||||
|
import { useQueryContext } from '../../query-context'
|
||||||
|
import { useSiteContext } from '../../site-context'
|
||||||
import {
|
import {
|
||||||
browsersRoute,
|
browsersRoute,
|
||||||
browserVersionsRoute,
|
browserVersionsRoute,
|
||||||
operatingSystemsRoute,
|
operatingSystemsRoute,
|
||||||
operatingSystemVersionsRoute,
|
operatingSystemVersionsRoute,
|
||||||
screenSizesRoute
|
screenSizesRoute
|
||||||
} from '../../router';
|
} from '../../router'
|
||||||
|
|
||||||
// Icons copied from https://github.com/alrra/browser-logos
|
// Icons copied from https://github.com/alrra/browser-logos
|
||||||
const BROWSER_ICONS = {
|
const BROWSER_ICONS = {
|
||||||
'Chrome': 'chrome.svg',
|
Chrome: 'chrome.svg',
|
||||||
'curl': 'curl.svg',
|
curl: 'curl.svg',
|
||||||
'Safari': 'safari.png',
|
Safari: 'safari.png',
|
||||||
'Firefox': 'firefox.svg',
|
Firefox: 'firefox.svg',
|
||||||
'Microsoft Edge': 'edge.svg',
|
'Microsoft Edge': 'edge.svg',
|
||||||
'Vivaldi': 'vivaldi.svg',
|
Vivaldi: 'vivaldi.svg',
|
||||||
'Opera': 'opera.svg',
|
Opera: 'opera.svg',
|
||||||
'Samsung Browser': 'samsung-internet.svg',
|
'Samsung Browser': 'samsung-internet.svg',
|
||||||
'Chromium': 'chromium.svg',
|
Chromium: 'chromium.svg',
|
||||||
'UC Browser': 'uc.svg',
|
'UC Browser': 'uc.svg',
|
||||||
'Yandex Browser': 'yandex.png', // Only PNG available in browser-logos
|
'Yandex Browser': 'yandex.png', // Only PNG available in browser-logos
|
||||||
// Logos underneath this line are not available in browser-logos. Grabbed from random places on the internets.
|
// Logos underneath this line are not available in browser-logos. Grabbed from random places on the internets.
|
||||||
|
|
@ -34,7 +38,7 @@ const BROWSER_ICONS = {
|
||||||
'MIUI Browser': 'miui.webp',
|
'MIUI Browser': 'miui.webp',
|
||||||
'Huawei Browser Mobile': 'huawei.png',
|
'Huawei Browser Mobile': 'huawei.png',
|
||||||
'QQ Browser': 'qq.png',
|
'QQ Browser': 'qq.png',
|
||||||
'Ecosia': 'ecosia.png',
|
Ecosia: 'ecosia.png',
|
||||||
'vivo Browser': 'vivo.png'
|
'vivo Browser': 'vivo.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,8 +55,8 @@ export function browserIconFor(browser) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Browsers({ afterFetchData }) {
|
function Browsers({ afterFetchData }) {
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, '/browsers'), query)
|
return api.get(url.apiPath(site, '/browsers'), query)
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +64,7 @@ function Browsers({ afterFetchData }) {
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
return {
|
return {
|
||||||
prefix: 'browser',
|
prefix: 'browser',
|
||||||
filter: ["is", "browser", [listItem['name']]]
|
filter: ['is', 'browser', [listItem['name']]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +77,7 @@ function Browsers({ afterFetchData }) {
|
||||||
metrics.createVisitors({ meta: { plot: true } }),
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||||
].filter(metric => !!metric)
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,14 +88,17 @@ function Browsers({ afterFetchData }) {
|
||||||
keyLabel="Browser"
|
keyLabel="Browser"
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
detailsLinkProps={{ path: browsersRoute.path, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: browsersRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserVersions({ afterFetchData }) {
|
function BrowserVersions({ afterFetchData }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, '/browser-versions'), query)
|
return api.get(url.apiPath(site, '/browser-versions'), query)
|
||||||
}
|
}
|
||||||
|
|
@ -101,12 +108,12 @@ function BrowserVersions({ afterFetchData }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
if (getSingleFilter(query, "browser") == '(not set)') {
|
if (getSingleFilter(query, 'browser') == '(not set)') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
prefix: 'browser_version',
|
prefix: 'browser_version',
|
||||||
filter: ["is", "browser_version", [listItem.version]]
|
filter: ['is', 'browser_version', [listItem.version]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +122,7 @@ function BrowserVersions({ afterFetchData }) {
|
||||||
metrics.createVisitors({ meta: { plot: true } }),
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||||
].filter(metric => !!metric)
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -126,46 +133,45 @@ function BrowserVersions({ afterFetchData }) {
|
||||||
keyLabel="Browser version"
|
keyLabel="Browser version"
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
detailsLinkProps={{ path: browserVersionsRoute.path, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: browserVersionsRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icons copied from https://github.com/ngeenx/operating-system-logos
|
// Icons copied from https://github.com/ngeenx/operating-system-logos
|
||||||
const OS_ICONS = {
|
const OS_ICONS = {
|
||||||
'iOS': 'ios.png',
|
iOS: 'ios.png',
|
||||||
'Mac': 'mac.png',
|
Mac: 'mac.png',
|
||||||
'Windows': 'windows.png',
|
Windows: 'windows.png',
|
||||||
'Windows Phone': 'windows.png',
|
'Windows Phone': 'windows.png',
|
||||||
'Android': 'android.png',
|
Android: 'android.png',
|
||||||
'GNU/Linux': 'gnu_linux.png',
|
'GNU/Linux': 'gnu_linux.png',
|
||||||
'Ubuntu': 'ubuntu.png',
|
Ubuntu: 'ubuntu.png',
|
||||||
'Chrome OS': 'chrome_os.png',
|
'Chrome OS': 'chrome_os.png',
|
||||||
'iPadOS': 'ipad_os.png',
|
iPadOS: 'ipad_os.png',
|
||||||
'Fire OS': 'fire_os.png',
|
'Fire OS': 'fire_os.png',
|
||||||
'HarmonyOS': 'harmony_os.png',
|
HarmonyOS: 'harmony_os.png',
|
||||||
'Tizen': 'tizen.png',
|
Tizen: 'tizen.png',
|
||||||
'PlayStation': 'playstation.png',
|
PlayStation: 'playstation.png',
|
||||||
'KaiOS': 'kai_os.png',
|
KaiOS: 'kai_os.png',
|
||||||
'Fedora': 'fedora.png',
|
Fedora: 'fedora.png',
|
||||||
'FreeBSD': 'freebsd.png',
|
FreeBSD: 'freebsd.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function osIconFor(os) {
|
export function osIconFor(os) {
|
||||||
const filename = OS_ICONS[os] || 'fallback.svg'
|
const filename = OS_ICONS[os] || 'fallback.svg'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img alt="" src={`/images/icon/os/${filename}`} className="w-4 h-4 mr-2" />
|
||||||
alt=""
|
|
||||||
src={`/images/icon/os/${filename}`}
|
|
||||||
className="w-4 h-4 mr-2"
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperatingSystems({ afterFetchData }) {
|
function OperatingSystems({ afterFetchData }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, '/operating-systems'), query)
|
return api.get(url.apiPath(site, '/operating-systems'), query)
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +179,7 @@ function OperatingSystems({ afterFetchData }) {
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
return {
|
return {
|
||||||
prefix: 'os',
|
prefix: 'os',
|
||||||
filter: ["is", "os", [listItem['name']]]
|
filter: ['is', 'os', [listItem['name']]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,8 +187,9 @@ function OperatingSystems({ afterFetchData }) {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ meta: { plot: true } }),
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||||
!hasConversionGoalFilter(query) && metrics.createPercentage({ meta: { hiddenonMobile: true } })
|
!hasConversionGoalFilter(query) &&
|
||||||
].filter(metric => !!metric)
|
metrics.createPercentage({ meta: { hiddenonMobile: true } })
|
||||||
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIcon(listItem) {
|
function renderIcon(listItem) {
|
||||||
|
|
@ -197,14 +204,17 @@ function OperatingSystems({ afterFetchData }) {
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
keyLabel="Operating system"
|
keyLabel="Operating system"
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
detailsLinkProps={{ path: operatingSystemsRoute.path, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: operatingSystemsRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperatingSystemVersions({ afterFetchData }) {
|
function OperatingSystemVersions({ afterFetchData }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
|
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, '/operating-system-versions'), query)
|
return api.get(url.apiPath(site, '/operating-system-versions'), query)
|
||||||
|
|
@ -215,12 +225,12 @@ function OperatingSystemVersions({ afterFetchData }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
if (getSingleFilter(query, "os") == '(not set)') {
|
if (getSingleFilter(query, 'os') == '(not set)') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
prefix: 'os_version',
|
prefix: 'os_version',
|
||||||
filter: ["is", "os_version", [listItem.version]]
|
filter: ['is', 'os_version', [listItem.version]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +239,7 @@ function OperatingSystemVersions({ afterFetchData }) {
|
||||||
metrics.createVisitors({ meta: { plot: true } }),
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||||
].filter(metric => !!metric)
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -240,15 +250,17 @@ function OperatingSystemVersions({ afterFetchData }) {
|
||||||
getFilterInfo={getFilterInfo}
|
getFilterInfo={getFilterInfo}
|
||||||
keyLabel="Operating System Version"
|
keyLabel="Operating System Version"
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
detailsLinkProps={{ path: operatingSystemVersionsRoute.path, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: operatingSystemVersionsRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScreenSizes({ afterFetchData }) {
|
function ScreenSizes({ afterFetchData }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
|
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, '/screen-sizes'), query)
|
return api.get(url.apiPath(site, '/screen-sizes'), query)
|
||||||
|
|
@ -261,7 +273,7 @@ function ScreenSizes({ afterFetchData }) {
|
||||||
function getFilterInfo(listItem) {
|
function getFilterInfo(listItem) {
|
||||||
return {
|
return {
|
||||||
prefix: 'screen',
|
prefix: 'screen',
|
||||||
filter: ["is", "screen", [listItem['name']]]
|
filter: ['is', 'screen', [listItem['name']]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,7 +282,7 @@ function ScreenSizes({ afterFetchData }) {
|
||||||
metrics.createVisitors({ meta: { plot: true } }),
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||||
].filter(metric => !!metric)
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -281,7 +293,10 @@ function ScreenSizes({ afterFetchData }) {
|
||||||
keyLabel="Screen size"
|
keyLabel="Screen size"
|
||||||
metrics={chooseMetrics()}
|
metrics={chooseMetrics()}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
detailsLinkProps={{ path: screenSizesRoute.path, search: (search) => search }}
|
detailsLinkProps={{
|
||||||
|
path: screenSizesRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -290,21 +305,94 @@ export function screenSizeIconFor(screenSize) {
|
||||||
let svg = null
|
let svg = null
|
||||||
|
|
||||||
if (screenSize === 'Mobile') {
|
if (screenSize === 'Mobile') {
|
||||||
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" /></svg>
|
svg = (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="-mt-px feather"
|
||||||
|
>
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
} else if (screenSize === 'Tablet') {
|
} else if (screenSize === 'Tablet') {
|
||||||
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="4" y="2" width="16" height="20" rx="2" ry="2" transform="rotate(180 12 12)" /><line x1="12" y1="18" x2="12" y2="18" /></svg>
|
svg = (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="-mt-px feather"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="2"
|
||||||
|
width="16"
|
||||||
|
height="20"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
transform="rotate(180 12 12)"
|
||||||
|
/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
} else if (screenSize === 'Laptop') {
|
} else if (screenSize === 'Laptop') {
|
||||||
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="2" y1="20" x2="22" y2="20" /></svg>
|
svg = (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="-mt-px feather"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="2" y1="20" x2="22" y2="20" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
} else if (screenSize === 'Desktop') {
|
} else if (screenSize === 'Desktop') {
|
||||||
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>
|
svg = (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="-mt-px feather"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="mr-1.5">{svg}</span>
|
return <span className="mr-1.5">{svg}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Devices() {
|
export default function Devices() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
|
|
||||||
const tabKey = `deviceTab__${site.domain}`
|
const tabKey = `deviceTab__${site.domain}`
|
||||||
const storedTab = storage.getItem(tabKey)
|
const storedTab = storage.getItem(tabKey)
|
||||||
|
|
@ -347,9 +435,7 @@ export default function Devices() {
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500">
|
||||||
className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500"
|
|
||||||
>
|
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
@ -370,7 +456,10 @@ export default function Devices() {
|
||||||
<div className="flex justify-between w-full">
|
<div className="flex justify-between w-full">
|
||||||
<div className="flex gap-x-1">
|
<div className="flex gap-x-1">
|
||||||
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
||||||
<ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
|
<ImportedQueryUnsupportedWarning
|
||||||
|
loading={loading}
|
||||||
|
skipImportedReason={skipImportedReason}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||||
{renderPill('Browser', 'browser')}
|
{renderPill('Browser', 'browser')}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import {parseUTCDate, formatMonthYYYY, formatDayShort} from '../../util/date'
|
import { parseUTCDate, formatMonthYYYY, formatDayShort } from '../../util/date'
|
||||||
|
|
||||||
const browserDateFormat = Intl.DateTimeFormat(navigator.language, { hour: 'numeric' })
|
const browserDateFormat = Intl.DateTimeFormat(navigator.language, {
|
||||||
|
hour: 'numeric'
|
||||||
|
})
|
||||||
|
|
||||||
const is12HourClock = function () {
|
const is12HourClock = function () {
|
||||||
return browserDateFormat.resolvedOptions().hour12
|
return browserDateFormat.resolvedOptions().hour12
|
||||||
|
|
@ -19,7 +21,9 @@ const monthIntervalFormatter = {
|
||||||
const weekIntervalFormatter = {
|
const weekIntervalFormatter = {
|
||||||
long(isoDate, options) {
|
long(isoDate, options) {
|
||||||
const formatted = this.short(isoDate, options)
|
const formatted = this.short(isoDate, options)
|
||||||
return options.isBucketPartial ? `Partial week of ${formatted}` : `Week of ${formatted}`
|
return options.isBucketPartial
|
||||||
|
? `Partial week of ${formatted}`
|
||||||
|
: `Week of ${formatted}`
|
||||||
},
|
},
|
||||||
short(isoDate, options) {
|
short(isoDate, options) {
|
||||||
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
|
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
|
||||||
|
|
@ -98,9 +102,20 @@ const factory = {
|
||||||
* @param {boolean} config.shouldShowYear - Should the year be appended to the date?
|
* @param {boolean} config.shouldShowYear - Should the year be appended to the date?
|
||||||
* Defaults to false. Rendering year string is a newer opt-in feature to be enabled where needed.
|
* Defaults to false. Rendering year string is a newer opt-in feature to be enabled where needed.
|
||||||
*/
|
*/
|
||||||
export default function dateFormatter({ interval, longForm, period, isPeriodFull, shouldShowYear = false }) {
|
export default function dateFormatter({
|
||||||
|
interval,
|
||||||
|
longForm,
|
||||||
|
period,
|
||||||
|
isPeriodFull,
|
||||||
|
shouldShowYear = false
|
||||||
|
}) {
|
||||||
const displayMode = longForm ? 'long' : 'short'
|
const displayMode = longForm ? 'long' : 'short'
|
||||||
const options = { period: period, interval: interval, isBucketPartial: !isPeriodFull, shouldShowYear }
|
const options = {
|
||||||
|
period: period,
|
||||||
|
interval: interval,
|
||||||
|
isBucketPartial: !isPeriodFull,
|
||||||
|
shouldShowYear
|
||||||
|
}
|
||||||
return function (isoDate, _index, _ticks) {
|
return function (isoDate, _index, _ticks) {
|
||||||
return factory[interval][displayMode](isoDate, options)
|
return factory[interval][displayMode](isoDate, options)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,64 +5,101 @@ import { METRIC_LABELS } from './graph-util'
|
||||||
import { MetricFormatterShort } from '../reports/metric-formatter'
|
import { MetricFormatterShort } from '../reports/metric-formatter'
|
||||||
import { ChangeArrow } from '../reports/change-arrow'
|
import { ChangeArrow } from '../reports/change-arrow'
|
||||||
|
|
||||||
const renderBucketLabel = function(query, graphData, label, comparison = false) {
|
const renderBucketLabel = function (
|
||||||
|
query,
|
||||||
|
graphData,
|
||||||
|
label,
|
||||||
|
comparison = false
|
||||||
|
) {
|
||||||
let isPeriodFull = graphData.full_intervals?.[label]
|
let isPeriodFull = graphData.full_intervals?.[label]
|
||||||
if (comparison) isPeriodFull = true
|
if (comparison) isPeriodFull = true
|
||||||
|
|
||||||
const formattedLabel = dateFormatter({
|
const formattedLabel = dateFormatter({
|
||||||
interval: graphData.interval, longForm: true, period: query.period, isPeriodFull,
|
interval: graphData.interval,
|
||||||
|
longForm: true,
|
||||||
|
period: query.period,
|
||||||
|
isPeriodFull
|
||||||
})(label)
|
})(label)
|
||||||
|
|
||||||
if (query.period === 'realtime') {
|
if (query.period === 'realtime') {
|
||||||
return dateFormatter({
|
return dateFormatter({
|
||||||
interval: graphData.interval, longForm: true, period: query.period,
|
interval: graphData.interval,
|
||||||
|
longForm: true,
|
||||||
|
period: query.period
|
||||||
})(label)
|
})(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graphData.interval === 'hour' || graphData.interval == 'minute') {
|
if (graphData.interval === 'hour' || graphData.interval == 'minute') {
|
||||||
const date = dateFormatter({ interval: "day", longForm: true, period: query.period })(label)
|
const date = dateFormatter({
|
||||||
|
interval: 'day',
|
||||||
|
longForm: true,
|
||||||
|
period: query.period
|
||||||
|
})(label)
|
||||||
return `${date}, ${formattedLabel}`
|
return `${date}, ${formattedLabel}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return formattedLabel
|
return formattedLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculatePercentageDifference = function(oldValue, newValue) {
|
const calculatePercentageDifference = function (oldValue, newValue) {
|
||||||
if (oldValue == 0 && newValue > 0) {
|
if (oldValue == 0 && newValue > 0) {
|
||||||
return 100
|
return 100
|
||||||
} else if (oldValue == 0 && newValue == 0) {
|
} else if (oldValue == 0 && newValue == 0) {
|
||||||
return 0
|
return 0
|
||||||
} else {
|
} else {
|
||||||
return Math.round((newValue - oldValue) / oldValue * 100)
|
return Math.round(((newValue - oldValue) / oldValue) * 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildTooltipData = function(query, graphData, metric, tooltipModel) {
|
const buildTooltipData = function (query, graphData, metric, tooltipModel) {
|
||||||
const data = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "y")
|
const data = tooltipModel.dataPoints.find(
|
||||||
const comparisonData = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "yComparison")
|
(dataPoint) => dataPoint.dataset.yAxisID == 'y'
|
||||||
|
)
|
||||||
|
const comparisonData = tooltipModel.dataPoints.find(
|
||||||
|
(dataPoint) => dataPoint.dataset.yAxisID == 'yComparison'
|
||||||
|
)
|
||||||
|
|
||||||
const label = data && renderBucketLabel(query, graphData, graphData.labels[data.dataIndex])
|
const label =
|
||||||
const comparisonLabel = comparisonData && renderBucketLabel(query, graphData, graphData.comparison_labels[comparisonData.dataIndex], true)
|
data &&
|
||||||
|
renderBucketLabel(query, graphData, graphData.labels[data.dataIndex])
|
||||||
|
const comparisonLabel =
|
||||||
|
comparisonData &&
|
||||||
|
renderBucketLabel(
|
||||||
|
query,
|
||||||
|
graphData,
|
||||||
|
graphData.comparison_labels[comparisonData.dataIndex],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
const value = graphData.plot[data.dataIndex]
|
const value = graphData.plot[data.dataIndex]
|
||||||
|
|
||||||
const formatter = MetricFormatterShort[metric]
|
const formatter = MetricFormatterShort[metric]
|
||||||
const comparisonValue = graphData.comparison_plot?.[comparisonData.dataIndex]
|
const comparisonValue = graphData.comparison_plot?.[comparisonData.dataIndex]
|
||||||
const comparisonDifference = label && comparisonData && calculatePercentageDifference(comparisonValue, value)
|
const comparisonDifference =
|
||||||
|
label &&
|
||||||
|
comparisonData &&
|
||||||
|
calculatePercentageDifference(comparisonValue, value)
|
||||||
|
|
||||||
const formattedValue = formatter(value)
|
const formattedValue = formatter(value)
|
||||||
const formattedComparisonValue = comparisonData && formatter(comparisonValue)
|
const formattedComparisonValue = comparisonData && formatter(comparisonValue)
|
||||||
|
|
||||||
return { label, formattedValue, comparisonLabel, formattedComparisonValue, comparisonDifference }
|
return {
|
||||||
|
label,
|
||||||
|
formattedValue,
|
||||||
|
comparisonLabel,
|
||||||
|
formattedComparisonValue,
|
||||||
|
comparisonDifference
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let tooltipRoot
|
let tooltipRoot
|
||||||
|
|
||||||
export default function GraphTooltip(graphData, metric, query) {
|
export default function GraphTooltip(graphData, metric, query) {
|
||||||
return (context) => {
|
return (context) => {
|
||||||
const tooltipModel = context.tooltip
|
const tooltipModel = context.tooltip
|
||||||
const offset = document.getElementById("main-graph-canvas").getBoundingClientRect()
|
const offset = document
|
||||||
|
.getElementById('main-graph-canvas')
|
||||||
|
.getBoundingClientRect()
|
||||||
let tooltipEl = document.getElementById('chartjs-tooltip')
|
let tooltipEl = document.getElementById('chartjs-tooltip')
|
||||||
|
|
||||||
if (!tooltipEl) {
|
if (!tooltipEl) {
|
||||||
|
|
@ -75,7 +112,8 @@ export default function GraphTooltip(graphData, metric, query) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooltipEl && offset && window.innerWidth < 768) {
|
if (tooltipEl && offset && window.innerWidth < 768) {
|
||||||
tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px'
|
tooltipEl.style.top =
|
||||||
|
offset.y + offset.height + window.scrollY + 15 + 'px'
|
||||||
tooltipEl.style.left = offset.x + 'px'
|
tooltipEl.style.left = offset.x + 'px'
|
||||||
tooltipEl.style.right = null
|
tooltipEl.style.right = null
|
||||||
tooltipEl.style.opacity = 1
|
tooltipEl.style.opacity = 1
|
||||||
|
|
@ -87,41 +125,64 @@ export default function GraphTooltip(graphData, metric, query) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooltipModel.body) {
|
if (tooltipModel.body) {
|
||||||
const tooltipData = buildTooltipData(query, graphData, metric, tooltipModel)
|
const tooltipData = buildTooltipData(
|
||||||
|
query,
|
||||||
|
graphData,
|
||||||
|
metric,
|
||||||
|
tooltipModel
|
||||||
|
)
|
||||||
|
|
||||||
tooltipRoot.render(
|
tooltipRoot.render(
|
||||||
<aside className="text-gray-100 flex flex-col">
|
<aside className="text-gray-100 flex flex-col">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-semibold mr-4 text-lg">{METRIC_LABELS[metric]}</span>
|
<span className="font-semibold mr-4 text-lg">
|
||||||
|
{METRIC_LABELS[metric]}
|
||||||
|
</span>
|
||||||
{tooltipData.comparisonDifference ? (
|
{tooltipData.comparisonDifference ? (
|
||||||
<div className="inline-flex items-center space-x-1">
|
<div className="inline-flex items-center space-x-1">
|
||||||
<ChangeArrow metric={metric} change={tooltipData.comparisonDifference} />
|
<ChangeArrow
|
||||||
</div>) : null}
|
metric={metric}
|
||||||
|
change={tooltipData.comparisonDifference}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tooltipData.label ? (
|
{tooltipData.label ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-row justify-between items-center">
|
<div className="flex flex-row justify-between items-center">
|
||||||
<span className="flex items-center mr-4">
|
<span className="flex items-center mr-4">
|
||||||
<div className="w-3 h-3 mr-1 rounded-full" style={{ backgroundColor: "rgba(101,116,205)" }}></div>
|
<div
|
||||||
<span>{tooltipData.label}</span>
|
className="w-3 h-3 mr-1 rounded-full"
|
||||||
</span>
|
style={{ backgroundColor: 'rgba(101,116,205)' }}
|
||||||
<span className="text-base font-bold">{tooltipData.formattedValue}</span>
|
></div>
|
||||||
</div>
|
<span>{tooltipData.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-bold">
|
||||||
|
{tooltipData.formattedValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{tooltipData.comparisonLabel ? (
|
{tooltipData.comparisonLabel ? (
|
||||||
<div className="flex flex-row justify-between items-center">
|
<div className="flex flex-row justify-between items-center">
|
||||||
<span className="flex items-center mr-4">
|
<span className="flex items-center mr-4">
|
||||||
<div className="w-3 h-3 mr-1 rounded-full bg-gray-500"></div>
|
<div className="w-3 h-3 mr-1 rounded-full bg-gray-500"></div>
|
||||||
<span>{tooltipData.comparisonLabel}</span>
|
<span>{tooltipData.comparisonLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-bold">{tooltipData.formattedComparisonValue}</span>
|
<span className="text-base font-bold">
|
||||||
</div>) : null}
|
{tooltipData.formattedComparisonValue}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{graphData.interval === "month" ? (<span className="font-semibold italic">Click to view month</span>) : null}
|
{graphData.interval === 'month' ? (
|
||||||
{graphData.interval === "day" ? (<span className="font-semibold italic">Click to view day</span>) : null}
|
<span className="font-semibold italic">Click to view month</span>
|
||||||
|
) : null}
|
||||||
|
{graphData.interval === 'day' ? (
|
||||||
|
<span className="font-semibold italic">Click to view day</span>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
export const METRIC_LABELS = {
|
export const METRIC_LABELS = {
|
||||||
'visitors': 'Visitors',
|
visitors: 'Visitors',
|
||||||
'pageviews': 'Pageviews',
|
pageviews: 'Pageviews',
|
||||||
'events': 'Total Conversions',
|
events: 'Total Conversions',
|
||||||
'views_per_visit': 'Views per Visit',
|
views_per_visit: 'Views per Visit',
|
||||||
'visits': 'Visits',
|
visits: 'Visits',
|
||||||
'bounce_rate': 'Bounce Rate',
|
bounce_rate: 'Bounce Rate',
|
||||||
'visit_duration': 'Visit Duration',
|
visit_duration: 'Visit Duration',
|
||||||
'conversions': 'Converted Visitors',
|
conversions: 'Converted Visitors',
|
||||||
'conversion_rate': 'Conversion Rate',
|
conversion_rate: 'Conversion Rate',
|
||||||
'average_revenue': 'Average Revenue',
|
average_revenue: 'Average Revenue',
|
||||||
'total_revenue': 'Total Revenue',
|
total_revenue: 'Total Revenue',
|
||||||
'scroll_depth': 'Scroll Depth',
|
scroll_depth: 'Scroll Depth',
|
||||||
'time_on_page': 'Time on Page',
|
time_on_page: 'Time on Page'
|
||||||
}
|
}
|
||||||
|
|
||||||
function plottable(dataArray) {
|
function plottable(dataArray) {
|
||||||
|
|
@ -25,54 +25,73 @@ function plottable(dataArray) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildComparisonDataset = function(comparisonPlot) {
|
const buildComparisonDataset = function (comparisonPlot) {
|
||||||
if (!comparisonPlot) return []
|
if (!comparisonPlot) return []
|
||||||
|
|
||||||
return [{
|
return [
|
||||||
data: plottable(comparisonPlot),
|
{
|
||||||
borderColor: 'rgba(60,70,110,0.2)',
|
data: plottable(comparisonPlot),
|
||||||
pointBackgroundColor: 'rgba(60,70,110,0.2)',
|
borderColor: 'rgba(60,70,110,0.2)',
|
||||||
pointHoverBackgroundColor: 'rgba(60, 70, 110)',
|
pointBackgroundColor: 'rgba(60,70,110,0.2)',
|
||||||
yAxisID: 'yComparison',
|
pointHoverBackgroundColor: 'rgba(60, 70, 110)',
|
||||||
}]
|
yAxisID: 'yComparison'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildDashedDataset = function(plot, presentIndex) {
|
const buildDashedDataset = function (plot, presentIndex) {
|
||||||
if (!presentIndex) return []
|
if (!presentIndex) return []
|
||||||
|
|
||||||
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1);
|
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1)
|
||||||
const dashedPlot = (new Array(presentIndex - 1)).concat(dashedPart)
|
const dashedPlot = new Array(presentIndex - 1).concat(dashedPart)
|
||||||
|
|
||||||
return [{
|
return [
|
||||||
data: plottable(dashedPlot),
|
{
|
||||||
borderDash: [3, 3],
|
data: plottable(dashedPlot),
|
||||||
borderColor: 'rgba(101,116,205)',
|
borderDash: [3, 3],
|
||||||
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
borderColor: 'rgba(101,116,205)',
|
||||||
yAxisID: 'y',
|
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||||
}]
|
yAxisID: 'y'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildMainPlotDataset = function(plot, presentIndex) {
|
const buildMainPlotDataset = function (plot, presentIndex) {
|
||||||
const data = presentIndex ? plot.slice(0, presentIndex) : plot
|
const data = presentIndex ? plot.slice(0, presentIndex) : plot
|
||||||
|
|
||||||
return [{
|
return [
|
||||||
data: plottable(data),
|
{
|
||||||
borderColor: 'rgba(101,116,205)',
|
data: plottable(data),
|
||||||
pointBackgroundColor: 'rgba(101,116,205)',
|
borderColor: 'rgba(101,116,205)',
|
||||||
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
pointBackgroundColor: 'rgba(101,116,205)',
|
||||||
yAxisID: 'y',
|
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||||
}]
|
yAxisID: 'y'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildDataSet = (plot, comparisonPlot, present_index, ctx, label) => {
|
export const buildDataSet = (
|
||||||
var gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
plot,
|
||||||
var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
comparisonPlot,
|
||||||
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)');
|
present_index,
|
||||||
gradient.addColorStop(1, 'rgba(101,116,205, 0)');
|
ctx,
|
||||||
prev_gradient.addColorStop(0, 'rgba(101,116,205, 0.075)');
|
label
|
||||||
prev_gradient.addColorStop(1, 'rgba(101,116,205, 0)');
|
) => {
|
||||||
|
var gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||||
|
var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||||
|
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)')
|
||||||
|
gradient.addColorStop(1, 'rgba(101,116,205, 0)')
|
||||||
|
prev_gradient.addColorStop(0, 'rgba(101,116,205, 0.075)')
|
||||||
|
prev_gradient.addColorStop(1, 'rgba(101,116,205, 0)')
|
||||||
|
|
||||||
const defaultOptions = { label, borderWidth: 3, pointBorderColor: "transparent", pointHoverRadius: 4, backgroundColor: gradient, fill: true }
|
const defaultOptions = {
|
||||||
|
label,
|
||||||
|
borderWidth: 3,
|
||||||
|
pointBorderColor: 'transparent',
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
fill: true
|
||||||
|
}
|
||||||
|
|
||||||
const dataset = [
|
const dataset = [
|
||||||
...buildMainPlotDataset(plot, present_index),
|
...buildMainPlotDataset(plot, present_index),
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import React, { Fragment, useRef } from 'react';
|
import React, { Fragment, useRef } from 'react'
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames'
|
||||||
import * as storage from '../../util/storage';
|
import * as storage from '../../util/storage'
|
||||||
import { BlurMenuButtonOnEscape, isModifierPressed, isTyping, Keybind } from '../../keybinding';
|
import {
|
||||||
import { useQueryContext } from '../../query-context';
|
BlurMenuButtonOnEscape,
|
||||||
import { useSiteContext } from '../../site-context';
|
isModifierPressed,
|
||||||
import { useMatch } from 'react-router-dom';
|
isTyping,
|
||||||
import { rootRoute } from '../../router';
|
Keybind
|
||||||
import { popover } from '../../components/popover';
|
} from '../../keybinding'
|
||||||
|
import { useQueryContext } from '../../query-context'
|
||||||
|
import { useSiteContext } from '../../site-context'
|
||||||
|
import { useMatch } from 'react-router-dom'
|
||||||
|
import { rootRoute } from '../../router'
|
||||||
|
import { popover } from '../../components/popover'
|
||||||
|
|
||||||
const INTERVAL_LABELS = {
|
const INTERVAL_LABELS = {
|
||||||
'minute': 'Minutes',
|
minute: 'Minutes',
|
||||||
'hour': 'Hours',
|
hour: 'Hours',
|
||||||
'day': 'Days',
|
day: 'Days',
|
||||||
'week': 'Weeks',
|
week: 'Weeks',
|
||||||
'month': 'Months'
|
month: 'Months'
|
||||||
}
|
}
|
||||||
|
|
||||||
function validIntervals(site, query) {
|
function validIntervals(site, query) {
|
||||||
|
|
@ -36,11 +41,11 @@ function validIntervals(site, query) {
|
||||||
|
|
||||||
function getDefaultInterval(query, validIntervals) {
|
function getDefaultInterval(query, validIntervals) {
|
||||||
const defaultByPeriod = {
|
const defaultByPeriod = {
|
||||||
'day': 'hour',
|
day: 'hour',
|
||||||
'7d': 'day',
|
'7d': 'day',
|
||||||
'6mo': 'month',
|
'6mo': 'month',
|
||||||
'12mo': 'month',
|
'12mo': 'month',
|
||||||
'year': 'month'
|
year: 'month'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.period === 'custom') {
|
if (query.period === 'custom') {
|
||||||
|
|
@ -74,7 +79,7 @@ function storeInterval(period, domain, interval) {
|
||||||
storage.setItem(`interval__${period}__${domain}`, interval)
|
storage.setItem(`interval__${period}__${domain}`, interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCurrentInterval = function(site, query) {
|
export const getCurrentInterval = function (site, query) {
|
||||||
const options = validIntervals(site, query)
|
const options = validIntervals(site, query)
|
||||||
|
|
||||||
const storedInterval = getStoredInterval(query.period, site.domain)
|
const storedInterval = getStoredInterval(query.period, site.domain)
|
||||||
|
|
@ -89,8 +94,8 @@ export const getCurrentInterval = function(site, query) {
|
||||||
|
|
||||||
export function IntervalPicker({ onIntervalUpdate }) {
|
export function IntervalPicker({ onIntervalUpdate }) {
|
||||||
const menuElement = useRef(null)
|
const menuElement = useRef(null)
|
||||||
const {query} = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const dashboardRouteMatch = useMatch(rootRoute.path)
|
const dashboardRouteMatch = useMatch(rootRoute.path)
|
||||||
|
|
||||||
if (query.period == 'realtime') return null
|
if (query.period == 'realtime') return null
|
||||||
|
|
@ -98,7 +103,6 @@ export function IntervalPicker({ onIntervalUpdate }) {
|
||||||
const options = validIntervals(site, query)
|
const options = validIntervals(site, query)
|
||||||
const currentInterval = getCurrentInterval(site, query)
|
const currentInterval = getCurrentInterval(site, query)
|
||||||
|
|
||||||
|
|
||||||
function updateInterval(interval) {
|
function updateInterval(interval) {
|
||||||
storeInterval(query.period, site.domain, interval)
|
storeInterval(query.period, site.domain, interval)
|
||||||
onIntervalUpdate(interval)
|
onIntervalUpdate(interval)
|
||||||
|
|
@ -106,13 +110,23 @@ export function IntervalPicker({ onIntervalUpdate }) {
|
||||||
|
|
||||||
function renderDropdownItem(option) {
|
function renderDropdownItem(option) {
|
||||||
return (
|
return (
|
||||||
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}>
|
<Menu.Item
|
||||||
|
onClick={() => updateInterval(option)}
|
||||||
|
key={option}
|
||||||
|
disabled={option == currentInterval}
|
||||||
|
>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<span className={classNames({
|
<span
|
||||||
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer': active,
|
className={classNames(
|
||||||
'text-gray-700 dark:text-gray-200': !active,
|
{
|
||||||
'font-bold cursor-none select-none': option == currentInterval,
|
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer':
|
||||||
}, 'block px-4 py-2 text-sm')}>
|
active,
|
||||||
|
'text-gray-700 dark:text-gray-200': !active,
|
||||||
|
'font-bold cursor-none select-none': option == currentInterval
|
||||||
|
},
|
||||||
|
'block px-4 py-2 text-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{INTERVAL_LABELS[option]}
|
{INTERVAL_LABELS[option]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -144,11 +158,7 @@ export function IntervalPicker({ onIntervalUpdate }) {
|
||||||
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
|
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|
||||||
<Transition
|
<Transition as={Fragment} show={open} {...popover.transition.props}>
|
||||||
as={Fragment}
|
|
||||||
show={open}
|
|
||||||
{...popover.transition.props}
|
|
||||||
>
|
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
|
className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
|
||||||
static
|
static
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { useAppNavigate } from '../../navigation/use-app-navigate';
|
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||||
import { useQueryContext } from '../../query-context';
|
import { useQueryContext } from '../../query-context'
|
||||||
import Chart from 'chart.js/auto';
|
import Chart from 'chart.js/auto'
|
||||||
import GraphTooltip from './graph-tooltip'
|
import GraphTooltip from './graph-tooltip'
|
||||||
import { buildDataSet, METRIC_LABELS } from './graph-util'
|
import { buildDataSet, METRIC_LABELS } from './graph-util'
|
||||||
import dateFormatter from './date-formatter';
|
import dateFormatter from './date-formatter'
|
||||||
import FadeIn from '../../fade-in';
|
import FadeIn from '../../fade-in'
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames'
|
||||||
import { hasConversionGoalFilter } from '../../util/filters';
|
import { hasConversionGoalFilter } from '../../util/filters'
|
||||||
import { MetricFormatterShort } from '../reports/metric-formatter'
|
import { MetricFormatterShort } from '../reports/metric-formatter'
|
||||||
|
|
||||||
const calculateMaximumY = function(dataset) {
|
const calculateMaximumY = function (dataset) {
|
||||||
const yAxisValues = dataset
|
const yAxisValues = dataset
|
||||||
.flatMap((item) => item.data)
|
.flatMap((item) => item.data)
|
||||||
.map((item) => item || 0)
|
.map((item) => item || 0)
|
||||||
|
|
@ -24,9 +24,9 @@ const calculateMaximumY = function(dataset) {
|
||||||
|
|
||||||
class LineGraph extends React.Component {
|
class LineGraph extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props)
|
||||||
this.regenerateChart = this.regenerateChart.bind(this);
|
this.regenerateChart = this.regenerateChart.bind(this)
|
||||||
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
this.updateWindowDimensions = this.updateWindowDimensions.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
getGraphMetric() {
|
getGraphMetric() {
|
||||||
|
|
@ -42,9 +42,15 @@ class LineGraph extends React.Component {
|
||||||
regenerateChart() {
|
regenerateChart() {
|
||||||
const { graphData, query } = this.props
|
const { graphData, query } = this.props
|
||||||
const metric = this.getGraphMetric()
|
const metric = this.getGraphMetric()
|
||||||
const graphEl = document.getElementById("main-graph-canvas")
|
const graphEl = document.getElementById('main-graph-canvas')
|
||||||
this.ctx = graphEl.getContext('2d');
|
this.ctx = graphEl.getContext('2d')
|
||||||
const dataSet = buildDataSet(graphData.plot, graphData.comparison_plot, graphData.present_index, this.ctx, METRIC_LABELS[metric])
|
const dataSet = buildDataSet(
|
||||||
|
graphData.plot,
|
||||||
|
graphData.comparison_plot,
|
||||||
|
graphData.present_index,
|
||||||
|
this.ctx,
|
||||||
|
METRIC_LABELS[metric]
|
||||||
|
)
|
||||||
|
|
||||||
return new Chart(this.ctx, {
|
return new Chart(this.ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
|
|
@ -62,7 +68,7 @@ class LineGraph extends React.Component {
|
||||||
intersect: false,
|
intersect: false,
|
||||||
position: 'average',
|
position: 'average',
|
||||||
external: GraphTooltip(graphData, metric, query)
|
external: GraphTooltip(graphData, metric, query)
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
|
@ -82,41 +88,42 @@ class LineGraph extends React.Component {
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
zeroLineColor: 'transparent',
|
zeroLineColor: 'transparent',
|
||||||
drawBorder: false,
|
drawBorder: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
yComparison: {
|
yComparison: {
|
||||||
min: 0,
|
min: 0,
|
||||||
suggestedMax: calculateMaximumY(dataSet),
|
suggestedMax: calculateMaximumY(dataSet),
|
||||||
display: false,
|
display: false,
|
||||||
grid: { display: false },
|
grid: { display: false }
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: function(val, _index, _ticks) {
|
callback: function (val, _index, _ticks) {
|
||||||
if (this.getLabelForValue(val) == "__blank__") return ""
|
if (this.getLabelForValue(val) == '__blank__') return ''
|
||||||
|
|
||||||
const hasMultipleYears =
|
const hasMultipleYears =
|
||||||
graphData.labels
|
graphData.labels
|
||||||
.filter((date) => typeof date === 'string')
|
.filter((date) => typeof date === 'string')
|
||||||
.map(date => date.split('-')[0])
|
.map((date) => date.split('-')[0])
|
||||||
.filter((value, index, list) => list.indexOf(value) === index)
|
.filter(
|
||||||
.length > 1
|
(value, index, list) => list.indexOf(value) === index
|
||||||
|
).length > 1
|
||||||
|
|
||||||
if (graphData.interval === 'hour' && query.period !== 'day') {
|
if (graphData.interval === 'hour' && query.period !== 'day') {
|
||||||
const date = dateFormatter({
|
const date = dateFormatter({
|
||||||
interval: "day",
|
interval: 'day',
|
||||||
longForm: false,
|
longForm: false,
|
||||||
period: query.period,
|
period: query.period,
|
||||||
shouldShowYear: hasMultipleYears,
|
shouldShowYear: hasMultipleYears
|
||||||
})(this.getLabelForValue(val))
|
})(this.getLabelForValue(val))
|
||||||
|
|
||||||
const hour = dateFormatter({
|
const hour = dateFormatter({
|
||||||
interval: graphData.interval,
|
interval: graphData.interval,
|
||||||
longForm: false,
|
longForm: false,
|
||||||
period: query.period,
|
period: query.period,
|
||||||
shouldShowYear: hasMultipleYears,
|
shouldShowYear: hasMultipleYears
|
||||||
})(this.getLabelForValue(val))
|
})(this.getLabelForValue(val))
|
||||||
|
|
||||||
// Returns a combination of date and hour. This is because
|
// Returns a combination of date and hour. This is because
|
||||||
|
|
@ -125,14 +132,22 @@ class LineGraph extends React.Component {
|
||||||
return `${date}, ${hour}`
|
return `${date}, ${hour}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graphData.interval === 'minute' && query.period !== 'realtime') {
|
if (
|
||||||
|
graphData.interval === 'minute' &&
|
||||||
|
query.period !== 'realtime'
|
||||||
|
) {
|
||||||
return dateFormatter({
|
return dateFormatter({
|
||||||
interval: "hour", longForm: false, period: query.period,
|
interval: 'hour',
|
||||||
|
longForm: false,
|
||||||
|
period: query.period
|
||||||
})(this.getLabelForValue(val))
|
})(this.getLabelForValue(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
return dateFormatter({
|
return dateFormatter({
|
||||||
interval: graphData.interval, longForm: false, period: query.period, shouldShowYear: hasMultipleYears,
|
interval: graphData.interval,
|
||||||
|
longForm: false,
|
||||||
|
period: query.period,
|
||||||
|
shouldShowYear: hasMultipleYears
|
||||||
})(this.getLabelForValue(val))
|
})(this.getLabelForValue(val))
|
||||||
},
|
},
|
||||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
||||||
|
|
@ -141,73 +156,73 @@ class LineGraph extends React.Component {
|
||||||
},
|
},
|
||||||
interaction: {
|
interaction: {
|
||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false,
|
intersect: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
repositionTooltip(e) {
|
repositionTooltip(e) {
|
||||||
const tooltipEl = document.getElementById('chartjs-tooltip');
|
const tooltipEl = document.getElementById('chartjs-tooltip')
|
||||||
if (tooltipEl && window.innerWidth >= 768) {
|
if (tooltipEl && window.innerWidth >= 768) {
|
||||||
if (e.clientX > 0.66 * window.innerWidth) {
|
if (e.clientX > 0.66 * window.innerWidth) {
|
||||||
tooltipEl.style.right = (window.innerWidth - e.clientX) + window.pageXOffset + 'px'
|
tooltipEl.style.right =
|
||||||
tooltipEl.style.left = null;
|
window.innerWidth - e.clientX + window.pageXOffset + 'px'
|
||||||
|
tooltipEl.style.left = null
|
||||||
} else {
|
} else {
|
||||||
tooltipEl.style.right = null;
|
tooltipEl.style.right = null
|
||||||
tooltipEl.style.left = e.clientX + window.pageXOffset + 'px'
|
tooltipEl.style.left = e.clientX + window.pageXOffset + 'px'
|
||||||
}
|
}
|
||||||
tooltipEl.style.top = e.clientY + window.pageYOffset + 'px'
|
tooltipEl.style.top = e.clientY + window.pageYOffset + 'px'
|
||||||
tooltipEl.style.opacity = 1;
|
tooltipEl.style.opacity = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.props.graphData) {
|
if (this.props.graphData) {
|
||||||
this.chart = this.regenerateChart();
|
this.chart = this.regenerateChart()
|
||||||
}
|
}
|
||||||
window.addEventListener('mousemove', this.repositionTooltip);
|
window.addEventListener('mousemove', this.repositionTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { graphData, darkTheme } = this.props;
|
const { graphData, darkTheme } = this.props
|
||||||
const tooltip = document.getElementById('chartjs-tooltip');
|
const tooltip = document.getElementById('chartjs-tooltip')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
graphData !== prevProps.graphData ||
|
graphData !== prevProps.graphData ||
|
||||||
darkTheme !== prevProps.darkTheme
|
darkTheme !== prevProps.darkTheme
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (graphData) {
|
if (graphData) {
|
||||||
if (this.chart) {
|
if (this.chart) {
|
||||||
this.chart.destroy();
|
this.chart.destroy()
|
||||||
}
|
}
|
||||||
this.chart = this.regenerateChart();
|
this.chart = this.regenerateChart()
|
||||||
this.chart.update();
|
this.chart.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooltip) {
|
if (tooltip) {
|
||||||
tooltip.style.display = 'none';
|
tooltip.style.display = 'none'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!graphData) {
|
if (!graphData) {
|
||||||
if (this.chart) {
|
if (this.chart) {
|
||||||
this.chart.destroy();
|
this.chart.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooltip) {
|
if (tooltip) {
|
||||||
tooltip.style.display = 'none';
|
tooltip.style.display = 'none'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
// Ensure that the tooltip doesn't hang around when we are loading more data
|
// Ensure that the tooltip doesn't hang around when we are loading more data
|
||||||
const tooltip = document.getElementById('chartjs-tooltip');
|
const tooltip = document.getElementById('chartjs-tooltip')
|
||||||
if (tooltip) {
|
if (tooltip) {
|
||||||
tooltip.style.opacity = 0;
|
tooltip.style.opacity = 0
|
||||||
tooltip.style.display = 'none';
|
tooltip.style.display = 'none'
|
||||||
}
|
}
|
||||||
window.removeEventListener('mousemove', this.repositionTooltip)
|
window.removeEventListener('mousemove', this.repositionTooltip)
|
||||||
}
|
}
|
||||||
|
|
@ -222,8 +237,12 @@ class LineGraph extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeHopToHoveredPeriod(e) {
|
maybeHopToHoveredPeriod(e) {
|
||||||
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
|
const element = this.chart.getElementsAtEventForMode(e, 'index', {
|
||||||
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
|
intersect: false
|
||||||
|
})[0]
|
||||||
|
const date =
|
||||||
|
this.props.graphData.labels[element.index] ||
|
||||||
|
this.props.graphData.comparison_labels[element.index]
|
||||||
|
|
||||||
if (this.props.graphData.interval === 'month') {
|
if (this.props.graphData.interval === 'month') {
|
||||||
this.props.navigate({
|
this.props.navigate({
|
||||||
|
|
@ -238,7 +257,9 @@ class LineGraph extends React.Component {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { graphData } = this.props
|
const { graphData } = this.props
|
||||||
const canvasClass = classNames('mt-4 select-none', { 'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval) })
|
const canvasClass = classNames('mt-4 select-none', {
|
||||||
|
'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeIn show={graphData}>
|
<FadeIn show={graphData}>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
import React from "react"
|
import React from 'react'
|
||||||
|
|
||||||
export default function SamplingNotice({topStatData}) {
|
export default function SamplingNotice({ topStatData }) {
|
||||||
const samplePercent = topStatData?.samplePercent
|
const samplePercent = topStatData?.samplePercent
|
||||||
|
|
||||||
if (samplePercent && samplePercent < 100) {
|
if (samplePercent && samplePercent < 100) {
|
||||||
return (
|
return (
|
||||||
<div tooltip={`Stats based on a ${samplePercent}% sample of all visitors`} className="cursor-pointer w-4 h-4 mx-2">
|
<div
|
||||||
<svg className="absolute w-4 h-4 dark:text-gray-300 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
tooltip={`Stats based on a ${samplePercent}% sample of all visitors`}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="cursor-pointer w-4 h-4 mx-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="absolute w-4 h-4 dark:text-gray-300 text-gray-700"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from 'react'
|
||||||
import * as api from '../../api';
|
import * as api from '../../api'
|
||||||
import { getCurrentInterval } from "./interval-picker";
|
import { getCurrentInterval } from './interval-picker'
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from '../../site-context'
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from '../../query-context'
|
||||||
|
|
||||||
export default function StatsExport() {
|
export default function StatsExport() {
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
function startExport() {
|
function startExport() {
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
document.cookie = "exporting="
|
document.cookie = 'exporting='
|
||||||
pollExportReady()
|
pollExportReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,21 +25,48 @@ export default function StatsExport() {
|
||||||
|
|
||||||
function renderLoading() {
|
function renderLoading() {
|
||||||
return (
|
return (
|
||||||
<svg className="animate-spin h-4 w-4 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="animate-spin h-4 w-4 text-indigo-500"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderExportLink() {
|
function renderExportLink() {
|
||||||
const interval = getCurrentInterval(site, query)
|
const interval = getCurrentInterval(site, query)
|
||||||
const queryParams = api.queryToSearchParams(query, [{ interval, comparison: undefined }])
|
const queryParams = api.queryToSearchParams(query, [
|
||||||
|
{ interval, comparison: undefined }
|
||||||
|
])
|
||||||
const endpoint = `/${encodeURIComponent(site.domain)}/export?${queryParams}`
|
const endpoint = `/${encodeURIComponent(site.domain)}/export?${queryParams}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={endpoint} download onClick={startExport}>
|
<a href={endpoint} download onClick={startExport}>
|
||||||
<svg className="absolute text-gray-700 feather dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
className="absolute text-gray-700 feather dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
<polyline points="7 10 12 15 17 10"></polyline>
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Tooltip } from '../../util/tooltip'
|
import { Tooltip } from '../../util/tooltip'
|
||||||
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
|
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import * as api from '../../api';
|
import * as api from '../../api'
|
||||||
import * as storage from '../../util/storage';
|
import * as storage from '../../util/storage'
|
||||||
import TopStats from './top-stats';
|
import TopStats from './top-stats'
|
||||||
import { IntervalPicker, getCurrentInterval } from './interval-picker';
|
import { IntervalPicker, getCurrentInterval } from './interval-picker'
|
||||||
import StatsExport from './stats-export';
|
import StatsExport from './stats-export'
|
||||||
import WithImportedSwitch from './with-imported-switch';
|
import WithImportedSwitch from './with-imported-switch'
|
||||||
import SamplingNotice from './sampling-notice';
|
import SamplingNotice from './sampling-notice'
|
||||||
import FadeIn from '../../fade-in';
|
import FadeIn from '../../fade-in'
|
||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url'
|
||||||
import { isComparisonEnabled } from '../../query-time-periods';
|
import { isComparisonEnabled } from '../../query-time-periods'
|
||||||
import LineGraphWithRouter from './line-graph';
|
import LineGraphWithRouter from './line-graph'
|
||||||
import { useQueryContext } from '../../query-context';
|
import { useQueryContext } from '../../query-context'
|
||||||
import { useSiteContext } from '../../site-context';
|
import { useSiteContext } from '../../site-context'
|
||||||
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
function fetchTopStats(site, query) {
|
function fetchTopStats(site, query) {
|
||||||
|
|
@ -31,11 +31,12 @@ function fetchMainGraph(site, query, metric, interval) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VisitorGraph({ updateImportedDataInView }) {
|
export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
|
|
||||||
const isRealtime = query.period === 'realtime'
|
const isRealtime = query.period === 'realtime'
|
||||||
const isDarkTheme = document.querySelector('html').classList.contains('dark') || false
|
const isDarkTheme =
|
||||||
|
document.querySelector('html').classList.contains('dark') || false
|
||||||
|
|
||||||
const topStatsBoundary = useRef(null)
|
const topStatsBoundary = useRef(null)
|
||||||
|
|
||||||
|
|
@ -50,18 +51,23 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
// long as new graph data is fetched.
|
// long as new graph data is fetched.
|
||||||
const [graphRefreshing, setGraphRefreshing] = useState(false)
|
const [graphRefreshing, setGraphRefreshing] = useState(false)
|
||||||
|
|
||||||
|
const onIntervalUpdate = useCallback(
|
||||||
|
(newInterval) => {
|
||||||
|
setGraphData(null)
|
||||||
|
setGraphRefreshing(true)
|
||||||
|
fetchGraphData(getStoredMetric(), newInterval)
|
||||||
|
},
|
||||||
|
[query]
|
||||||
|
)
|
||||||
|
|
||||||
const onIntervalUpdate = useCallback((newInterval) => {
|
const onMetricUpdate = useCallback(
|
||||||
setGraphData(null)
|
(newMetric) => {
|
||||||
setGraphRefreshing(true)
|
setGraphData(null)
|
||||||
fetchGraphData(getStoredMetric(), newInterval)
|
setGraphRefreshing(true)
|
||||||
}, [query])
|
fetchGraphData(newMetric, getCurrentInterval(site, query))
|
||||||
|
},
|
||||||
const onMetricUpdate = useCallback((newMetric) => {
|
[query]
|
||||||
setGraphData(null)
|
)
|
||||||
setGraphRefreshing(true)
|
|
||||||
fetchGraphData(newMetric, getCurrentInterval(site, query))
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTopStatData(null)
|
setTopStatData(null)
|
||||||
|
|
@ -80,7 +86,9 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (topStatData) { storeTopStatsContainerHeight() }
|
if (topStatData) {
|
||||||
|
storeTopStatsContainerHeight()
|
||||||
|
}
|
||||||
}, [topStatData])
|
}, [topStatData])
|
||||||
|
|
||||||
async function fetchTopStatsAndGraphData() {
|
async function fetchTopStatsAndGraphData() {
|
||||||
|
|
@ -107,12 +115,11 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchGraphData(metric, interval) {
|
function fetchGraphData(metric, interval) {
|
||||||
fetchMainGraph(site, query, metric, interval)
|
fetchMainGraph(site, query, metric, interval).then((res) => {
|
||||||
.then((res) => {
|
setGraphData(res)
|
||||||
setGraphData(res)
|
setGraphLoading(false)
|
||||||
setGraphLoading(false)
|
setGraphRefreshing(false)
|
||||||
setGraphRefreshing(false)
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredMetric() {
|
function getStoredMetric() {
|
||||||
|
|
@ -120,7 +127,10 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeTopStatsContainerHeight() {
|
function storeTopStatsContainerHeight() {
|
||||||
storage.setItem(`topStatsHeight__${site.domain}`, document.getElementById('top-stats-container').clientHeight)
|
storage.setItem(
|
||||||
|
`topStatsHeight__${site.domain}`,
|
||||||
|
document.getElementById('top-stats-container').clientHeight
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function is used for maintaining the main-graph/top-stats container height in the
|
// This function is used for maintaining the main-graph/top-stats container height in the
|
||||||
|
|
@ -136,16 +146,25 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function importedSwitchVisible() {
|
function importedSwitchVisible() {
|
||||||
return !!topStatData?.with_imported_switch && topStatData?.with_imported_switch.visible
|
return (
|
||||||
|
!!topStatData?.with_imported_switch &&
|
||||||
|
topStatData?.with_imported_switch.visible
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImportedIntervalUnsupportedWarning() {
|
function renderImportedIntervalUnsupportedWarning() {
|
||||||
const unsupportedInterval = ['hour', 'minute'].includes(getCurrentInterval(site, query))
|
const unsupportedInterval = ['hour', 'minute'].includes(
|
||||||
const showingImported = importedSwitchVisible() && query.with_imported === true
|
getCurrentInterval(site, query)
|
||||||
|
)
|
||||||
|
const showingImported =
|
||||||
|
importedSwitchVisible() && query.with_imported === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeIn show={showingImported && unsupportedInterval} className="h-6 mr-1">
|
<FadeIn
|
||||||
<span tooltip={"Interval is too short to graph imported data"}>
|
show={showingImported && unsupportedInterval}
|
||||||
|
className="h-6 mr-1"
|
||||||
|
>
|
||||||
|
<span tooltip={'Interval is too short to graph imported data'}>
|
||||||
<ExclamationCircleIcon className="w-6 h-6 text-gray-700 dark:text-gray-300" />
|
<ExclamationCircleIcon className="w-6 h-6 text-gray-700 dark:text-gray-300" />
|
||||||
</span>
|
</span>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
|
|
@ -153,10 +172,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825"}>
|
<div
|
||||||
|
className={
|
||||||
|
'relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825'
|
||||||
|
}
|
||||||
|
>
|
||||||
{(topStatsLoading || graphLoading) && renderLoader()}
|
{(topStatsLoading || graphLoading) && renderLoader()}
|
||||||
<FadeIn show={!(topStatsLoading || graphLoading)}>
|
<FadeIn show={!(topStatsLoading || graphLoading)}>
|
||||||
<div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}>
|
<div
|
||||||
|
id="top-stats-container"
|
||||||
|
className="flex flex-wrap"
|
||||||
|
ref={topStatsBoundary}
|
||||||
|
style={{ height: getTopStatsHeight() }}
|
||||||
|
>
|
||||||
<TopStats
|
<TopStats
|
||||||
graphableMetrics={topStatData?.graphable_metrics || []}
|
graphableMetrics={topStatData?.graphable_metrics || []}
|
||||||
data={topStatData}
|
data={topStatData}
|
||||||
|
|
@ -170,16 +198,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
||||||
{renderImportedIntervalUnsupportedWarning()}
|
{renderImportedIntervalUnsupportedWarning()}
|
||||||
{!isRealtime && <StatsExport />}
|
{!isRealtime && <StatsExport />}
|
||||||
<SamplingNotice samplePercent={topStatData} />
|
<SamplingNotice samplePercent={topStatData} />
|
||||||
{importedSwitchVisible() &&
|
{importedSwitchVisible() && (
|
||||||
<WithImportedSwitch
|
<WithImportedSwitch
|
||||||
tooltipMessage={topStatData.with_imported_switch.tooltip_msg}
|
tooltipMessage={topStatData.with_imported_switch.tooltip_msg}
|
||||||
disabled={!topStatData.with_imported_switch.togglable}
|
disabled={!topStatData.with_imported_switch.togglable}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
<IntervalPicker onIntervalUpdate={onIntervalUpdate} />
|
<IntervalPicker onIntervalUpdate={onIntervalUpdate} />
|
||||||
</div>
|
</div>
|
||||||
<LineGraphWithRouter
|
<LineGraphWithRouter
|
||||||
graphData={{...graphData, interval: getCurrentInterval(site, query)}}
|
graphData={{
|
||||||
|
...graphData,
|
||||||
|
interval: getCurrentInterval(site, query)
|
||||||
|
}}
|
||||||
darkTheme={isDarkTheme}
|
darkTheme={isDarkTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
import React from "react"
|
import React from 'react'
|
||||||
import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
|
import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
|
||||||
import classNames from "classnames"
|
import classNames from 'classnames'
|
||||||
import { useQueryContext } from "../../query-context"
|
import { useQueryContext } from '../../query-context'
|
||||||
import { AppNavigationLink } from "../../navigation/use-app-navigate"
|
import { AppNavigationLink } from '../../navigation/use-app-navigate'
|
||||||
|
|
||||||
export default function WithImportedSwitch({ tooltipMessage, disabled }) {
|
export default function WithImportedSwitch({ tooltipMessage, disabled }) {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const importsSwitchedOn = query.with_imported;
|
const importsSwitchedOn = query.with_imported
|
||||||
|
|
||||||
const iconClass = classNames("mt-0.5", {
|
const iconClass = classNames('mt-0.5', {
|
||||||
"dark:text-gray-300 text-gray-700": importsSwitchedOn,
|
'dark:text-gray-300 text-gray-700': importsSwitchedOn,
|
||||||
"dark:text-gray-500 text-gray-400": !importsSwitchedOn,
|
'dark:text-gray-500 text-gray-400': !importsSwitchedOn
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div tooltip={tooltipMessage} className="w-4 h-4 mx-2">
|
<div tooltip={tooltipMessage} className="w-4 h-4 mx-2">
|
||||||
<AppNavigationLink search={disabled ? (search) => search : (search) => ({...search, with_imported: !importsSwitchedOn}) }>
|
<AppNavigationLink
|
||||||
|
search={
|
||||||
|
disabled
|
||||||
|
? (search) => search
|
||||||
|
: (search) => ({ ...search, with_imported: !importsSwitchedOn })
|
||||||
|
}
|
||||||
|
>
|
||||||
<BarsArrowUpIcon className={iconClass} />
|
<BarsArrowUpIcon className={iconClass} />
|
||||||
</AppNavigationLink>
|
</AppNavigationLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
||||||
import FadeIn from "../fade-in";
|
import FadeIn from '../fade-in'
|
||||||
import { useQueryContext } from "../query-context";
|
import { useQueryContext } from '../query-context'
|
||||||
|
|
||||||
export default function ImportedQueryUnsupportedWarning({ loading, skipImportedReason, altCondition, message }) {
|
export default function ImportedQueryUnsupportedWarning({
|
||||||
const { query } = useQueryContext();
|
loading,
|
||||||
const tooltipMessage = message || "Imported data is excluded due to applied filters"
|
skipImportedReason,
|
||||||
const show = query && query.with_imported && skipImportedReason === "unsupported_query" && query.period !== 'realtime'
|
altCondition,
|
||||||
|
message
|
||||||
|
}) {
|
||||||
|
const { query } = useQueryContext()
|
||||||
|
const tooltipMessage =
|
||||||
|
message || 'Imported data is excluded due to applied filters'
|
||||||
|
const show =
|
||||||
|
query &&
|
||||||
|
query.with_imported &&
|
||||||
|
skipImportedReason === 'unsupported_query' &&
|
||||||
|
query.period !== 'realtime'
|
||||||
|
|
||||||
if (show || altCondition) {
|
if (show || altCondition) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export const GeolocationNotice = () => {
|
export const GeolocationNotice = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,260 +1,299 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
|
|
||||||
import * as storage from '../../util/storage';
|
import * as storage from '../../util/storage'
|
||||||
import CountriesMap from './map';
|
import CountriesMap from './map'
|
||||||
|
|
||||||
import * as api from '../../api';
|
import * as api from '../../api'
|
||||||
import { apiPath } from '../../util/url';
|
import { apiPath } from '../../util/url'
|
||||||
import ListReport from '../reports/list';
|
import ListReport from '../reports/list'
|
||||||
import * as metrics from '../reports/metrics';
|
import * as metrics from '../reports/metrics'
|
||||||
import { hasConversionGoalFilter, getFiltersByKeyPrefix } from '../../util/filters';
|
import {
|
||||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
hasConversionGoalFilter,
|
||||||
import { citiesRoute, countriesRoute, regionsRoute } from '../../router';
|
getFiltersByKeyPrefix
|
||||||
import { useQueryContext } from '../../query-context';
|
} from '../../util/filters'
|
||||||
import { useSiteContext } from '../../site-context';
|
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
|
||||||
|
import { citiesRoute, countriesRoute, regionsRoute } from '../../router'
|
||||||
|
import { useQueryContext } from '../../query-context'
|
||||||
|
import { useSiteContext } from '../../site-context'
|
||||||
|
|
||||||
function Countries({ query, site, onClick, afterFetchData }) {
|
function Countries({ query, site, onClick, afterFetchData }) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(apiPath(site, '/countries'), query, { limit: 9 })
|
return api.get(apiPath(site, '/countries'), query, { limit: 9 })
|
||||||
}
|
|
||||||
|
|
||||||
function renderIcon(country) {
|
|
||||||
return <span className="mr-2">{country.flag}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterInfo(listItem) {
|
|
||||||
return {
|
|
||||||
prefix: "country",
|
|
||||||
filter: ["is", "country", [listItem['code']]],
|
|
||||||
labels: { [listItem['code']]: listItem['name'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseMetrics() {
|
|
||||||
return [
|
|
||||||
metrics.createVisitors({ meta: {plot: true}}),
|
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
|
||||||
].filter(metric => !!metric)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function renderIcon(country) {
|
||||||
<ListReport
|
return <span className="mr-2">{country.flag}</span>
|
||||||
fetchData={fetchData}
|
}
|
||||||
afterFetchData={afterFetchData}
|
|
||||||
getFilterInfo={getFilterInfo}
|
function getFilterInfo(listItem) {
|
||||||
onClick={onClick}
|
return {
|
||||||
keyLabel="Country"
|
prefix: 'country',
|
||||||
metrics={chooseMetrics()}
|
filter: ['is', 'country', [listItem['code']]],
|
||||||
detailsLinkProps={{ path: countriesRoute.path, search: (search) => search }}
|
labels: { [listItem['code']]: listItem['name'] }
|
||||||
renderIcon={renderIcon}
|
}
|
||||||
color="bg-orange-50"
|
}
|
||||||
/>
|
|
||||||
)
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
|
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||||
|
].filter((metric) => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListReport
|
||||||
|
fetchData={fetchData}
|
||||||
|
afterFetchData={afterFetchData}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
onClick={onClick}
|
||||||
|
keyLabel="Country"
|
||||||
|
metrics={chooseMetrics()}
|
||||||
|
detailsLinkProps={{
|
||||||
|
path: countriesRoute.path,
|
||||||
|
search: (search) => search
|
||||||
|
}}
|
||||||
|
renderIcon={renderIcon}
|
||||||
|
color="bg-orange-50"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Regions({ query, site, onClick, afterFetchData }) {
|
function Regions({ query, site, onClick, afterFetchData }) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(apiPath(site, '/regions'), query, { limit: 9 })
|
return api.get(apiPath(site, '/regions'), query, { limit: 9 })
|
||||||
}
|
|
||||||
|
|
||||||
function renderIcon(region) {
|
|
||||||
return <span className="mr-2">{region.country_flag}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterInfo(listItem) {
|
|
||||||
return {
|
|
||||||
prefix: "region",
|
|
||||||
filter: ["is", "region", [listItem['code']]],
|
|
||||||
labels: { [listItem['code']]: listItem['name'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseMetrics() {
|
|
||||||
return [
|
|
||||||
metrics.createVisitors({ meta: {plot: true}}),
|
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
|
||||||
].filter(metric => !!metric)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function renderIcon(region) {
|
||||||
<ListReport
|
return <span className="mr-2">{region.country_flag}</span>
|
||||||
fetchData={fetchData}
|
}
|
||||||
afterFetchData={afterFetchData}
|
|
||||||
getFilterInfo={getFilterInfo}
|
function getFilterInfo(listItem) {
|
||||||
onClick={onClick}
|
return {
|
||||||
keyLabel="Region"
|
prefix: 'region',
|
||||||
metrics={chooseMetrics()}
|
filter: ['is', 'region', [listItem['code']]],
|
||||||
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
|
labels: { [listItem['code']]: listItem['name'] }
|
||||||
renderIcon={renderIcon}
|
}
|
||||||
color="bg-orange-50"
|
}
|
||||||
/>
|
|
||||||
)
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
|
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||||
|
].filter((metric) => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListReport
|
||||||
|
fetchData={fetchData}
|
||||||
|
afterFetchData={afterFetchData}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
onClick={onClick}
|
||||||
|
keyLabel="Region"
|
||||||
|
metrics={chooseMetrics()}
|
||||||
|
detailsLinkProps={{ path: regionsRoute.path, search: (search) => search }}
|
||||||
|
renderIcon={renderIcon}
|
||||||
|
color="bg-orange-50"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Cities({ query, site, afterFetchData }) {
|
function Cities({ query, site, afterFetchData }) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(apiPath(site, '/cities'), query, { limit: 9 })
|
return api.get(apiPath(site, '/cities'), query, { limit: 9 })
|
||||||
}
|
|
||||||
|
|
||||||
function renderIcon(city) {
|
|
||||||
return <span className="mr-2">{city.country_flag}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterInfo(listItem) {
|
|
||||||
return {
|
|
||||||
prefix: "city",
|
|
||||||
filter: ["is", "city", [listItem['code']]],
|
|
||||||
labels: { [listItem['code']]: listItem['name'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseMetrics() {
|
|
||||||
return [
|
|
||||||
metrics.createVisitors({ meta: {plot: true}}),
|
|
||||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
|
||||||
].filter(metric => !!metric)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function renderIcon(city) {
|
||||||
<ListReport
|
return <span className="mr-2">{city.country_flag}</span>
|
||||||
fetchData={fetchData}
|
}
|
||||||
afterFetchData={afterFetchData}
|
|
||||||
getFilterInfo={getFilterInfo}
|
function getFilterInfo(listItem) {
|
||||||
keyLabel="City"
|
return {
|
||||||
metrics={chooseMetrics()}
|
prefix: 'city',
|
||||||
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
|
filter: ['is', 'city', [listItem['code']]],
|
||||||
renderIcon={renderIcon}
|
labels: { [listItem['code']]: listItem['name'] }
|
||||||
color="bg-orange-50"
|
}
|
||||||
/>
|
}
|
||||||
)
|
|
||||||
|
function chooseMetrics() {
|
||||||
|
return [
|
||||||
|
metrics.createVisitors({ meta: { plot: true } }),
|
||||||
|
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||||
|
].filter((metric) => !!metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListReport
|
||||||
|
fetchData={fetchData}
|
||||||
|
afterFetchData={afterFetchData}
|
||||||
|
getFilterInfo={getFilterInfo}
|
||||||
|
keyLabel="City"
|
||||||
|
metrics={chooseMetrics()}
|
||||||
|
detailsLinkProps={{ path: citiesRoute.path, search: (search) => search }}
|
||||||
|
renderIcon={renderIcon}
|
||||||
|
color="bg-orange-50"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const labelFor = {
|
const labelFor = {
|
||||||
'countries': 'Countries',
|
countries: 'Countries',
|
||||||
'regions': 'Regions',
|
regions: 'Regions',
|
||||||
'cities': 'Cities',
|
cities: 'Cities'
|
||||||
}
|
}
|
||||||
|
|
||||||
class Locations extends React.Component {
|
class Locations extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
this.onCountryFilter = this.onCountryFilter.bind(this)
|
this.onCountryFilter = this.onCountryFilter.bind(this)
|
||||||
this.onRegionFilter = this.onRegionFilter.bind(this)
|
this.onRegionFilter = this.onRegionFilter.bind(this)
|
||||||
this.afterFetchData = this.afterFetchData.bind(this)
|
this.afterFetchData = this.afterFetchData.bind(this)
|
||||||
this.tabKey = `geoTab__${props.site.domain}`
|
this.tabKey = `geoTab__${props.site.domain}`
|
||||||
const storedTab = storage.getItem(this.tabKey)
|
const storedTab = storage.getItem(this.tabKey)
|
||||||
this.state = {
|
this.state = {
|
||||||
mode: storedTab || 'map',
|
mode: storedTab || 'map',
|
||||||
loading: true,
|
loading: true,
|
||||||
skipImportedReason: null
|
skipImportedReason: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
const isRemovingFilter = (filterName) => {
|
const isRemovingFilter = (filterName) => {
|
||||||
return getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 &&
|
return (
|
||||||
getFiltersByKeyPrefix(this.props.query, filterName).length == 0
|
getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 &&
|
||||||
}
|
getFiltersByKeyPrefix(this.props.query, filterName).length == 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.state.mode === 'cities' && isRemovingFilter('region')) {
|
if (this.state.mode === 'cities' && isRemovingFilter('region')) {
|
||||||
this.setMode('regions')()
|
this.setMode('regions')()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.mode === 'regions' && isRemovingFilter('country')) {
|
if (this.state.mode === 'regions' && isRemovingFilter('country')) {
|
||||||
this.setMode(this.countriesRestoreMode || 'countries')()
|
this.setMode(this.countriesRestoreMode || 'countries')()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.query !== prevProps.query || this.state.mode !== prevState.mode) {
|
if (
|
||||||
this.setState({ loading: true })
|
this.props.query !== prevProps.query ||
|
||||||
}
|
this.state.mode !== prevState.mode
|
||||||
}
|
) {
|
||||||
|
this.setState({ loading: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
return () => {
|
return () => {
|
||||||
storage.setItem(this.tabKey, mode)
|
storage.setItem(this.tabKey, mode)
|
||||||
this.setState({ mode })
|
this.setState({ mode })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCountryFilter(mode) {
|
onCountryFilter(mode) {
|
||||||
return () => {
|
return () => {
|
||||||
this.countriesRestoreMode = mode
|
this.countriesRestoreMode = mode
|
||||||
this.setMode('regions')()
|
this.setMode('regions')()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onRegionFilter() {
|
onRegionFilter() {
|
||||||
this.setMode('cities')()
|
this.setMode('cities')()
|
||||||
}
|
}
|
||||||
|
|
||||||
afterFetchData(apiResponse) {
|
afterFetchData(apiResponse) {
|
||||||
this.setState({ loading: false, skipImportedReason: apiResponse.skip_imported_reason })
|
this.setState({
|
||||||
}
|
loading: false,
|
||||||
|
skipImportedReason: apiResponse.skip_imported_reason
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
renderContent() {
|
renderContent() {
|
||||||
switch (this.state.mode) {
|
switch (this.state.mode) {
|
||||||
case "cities":
|
case 'cities':
|
||||||
return <Cities site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
return (
|
||||||
case "regions":
|
<Cities
|
||||||
return <Regions onClick={this.onRegionFilter} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
site={this.props.site}
|
||||||
case "countries":
|
query={this.props.query}
|
||||||
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} />
|
afterFetchData={this.afterFetchData}
|
||||||
case "map":
|
/>
|
||||||
default:
|
)
|
||||||
return <CountriesMap onCountrySelect={this.onCountryFilter('map')} afterFetchData={this.afterFetchData} />
|
case 'regions':
|
||||||
}
|
return (
|
||||||
}
|
<Regions
|
||||||
|
onClick={this.onRegionFilter}
|
||||||
|
site={this.props.site}
|
||||||
|
query={this.props.query}
|
||||||
|
afterFetchData={this.afterFetchData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'countries':
|
||||||
|
return (
|
||||||
|
<Countries
|
||||||
|
onClick={this.onCountryFilter('countries')}
|
||||||
|
site={this.props.site}
|
||||||
|
query={this.props.query}
|
||||||
|
afterFetchData={this.afterFetchData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'map':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<CountriesMap
|
||||||
|
onCountrySelect={this.onCountryFilter('map')}
|
||||||
|
afterFetchData={this.afterFetchData}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderPill(name, mode) {
|
renderPill(name, mode) {
|
||||||
const isActive = this.state.mode === mode
|
const isActive = this.state.mode === mode
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading">
|
||||||
className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading"
|
{name}
|
||||||
>
|
</button>
|
||||||
{name}
|
)
|
||||||
</button>
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="hover:text-indigo-600 cursor-pointer"
|
className="hover:text-indigo-600 cursor-pointer"
|
||||||
onClick={this.setMode(mode)}
|
onClick={this.setMode(mode)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="w-full flex justify-between">
|
<div className="w-full flex justify-between">
|
||||||
<div className="flex gap-x-1">
|
<div className="flex gap-x-1">
|
||||||
<h3 className="font-bold dark:text-gray-100">
|
<h3 className="font-bold dark:text-gray-100">
|
||||||
{labelFor[this.state.mode] || 'Locations'}
|
{labelFor[this.state.mode] || 'Locations'}
|
||||||
</h3>
|
</h3>
|
||||||
<ImportedQueryUnsupportedWarning loading={this.state.loading} skipImportedReason={this.state.skipImportedReason} />
|
<ImportedQueryUnsupportedWarning
|
||||||
</div>
|
loading={this.state.loading}
|
||||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
skipImportedReason={this.state.skipImportedReason}
|
||||||
{this.renderPill('Map', 'map')}
|
/>
|
||||||
{this.renderPill('Countries', 'countries')}
|
</div>
|
||||||
{this.renderPill('Regions', 'regions')}
|
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||||
{this.renderPill('Cities', 'cities')}
|
{this.renderPill('Map', 'map')}
|
||||||
</div>
|
{this.renderPill('Countries', 'countries')}
|
||||||
</div>
|
{this.renderPill('Regions', 'regions')}
|
||||||
{this.renderContent()}
|
{this.renderPill('Cities', 'cities')}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
{this.renderContent()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocationsWithContext() {
|
function LocationsWithContext() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext()
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
return <Locations site={site} query={query} />
|
return <Locations site={site} query={query} />
|
||||||
}
|
}
|
||||||
export default LocationsWithContext
|
export default LocationsWithContext
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* @format */
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { useState, ReactNode, useMemo } from 'react'
|
import React, { useState, ReactNode, useMemo } from 'react'
|
||||||
|
|
||||||
import { useQueryContext } from '../../query-context'
|
import { useQueryContext } from '../../query-context'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/** @format */
|
|
||||||
|
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
|
|
||||||
import { SearchInput } from '../../components/search-input'
|
import { SearchInput } from '../../components/search-input'
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,54 @@
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from 'react'
|
||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import BreakdownModal from "./breakdown-modal";
|
import BreakdownModal from './breakdown-modal'
|
||||||
import * as metrics from "../reports/metrics";
|
import * as metrics from '../reports/metrics'
|
||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url'
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from '../../site-context'
|
||||||
import { addFilter } from "../../query";
|
import { addFilter } from '../../query'
|
||||||
|
|
||||||
/*global BUILD_EXTRA*/
|
/*global BUILD_EXTRA*/
|
||||||
function ConversionsModal() {
|
function ConversionsModal() {
|
||||||
const [showRevenue, setShowRevenue] = useState(false)
|
const [showRevenue, setShowRevenue] = useState(false)
|
||||||
const site = useSiteContext();
|
const site = useSiteContext()
|
||||||
|
|
||||||
const reportInfo = {
|
const reportInfo = {
|
||||||
title: 'Goal Conversions',
|
title: 'Goal Conversions',
|
||||||
dimension: 'goal',
|
dimension: 'goal',
|
||||||
endpoint: url.apiPath(site, '/conversions'),
|
endpoint: url.apiPath(site, '/conversions'),
|
||||||
dimensionLabel: "Goal"
|
dimensionLabel: 'Goal'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback(
|
||||||
return {
|
(listItem) => {
|
||||||
prefix: reportInfo.dimension,
|
return {
|
||||||
filter: ["is", reportInfo.dimension, [listItem.name]]
|
prefix: reportInfo.dimension,
|
||||||
}
|
filter: ['is', reportInfo.dimension, [listItem.name]]
|
||||||
}, [reportInfo.dimension])
|
}
|
||||||
|
},
|
||||||
|
[reportInfo.dimension]
|
||||||
|
)
|
||||||
|
|
||||||
const addSearchFilter = useCallback((query, searchString) => {
|
const addSearchFilter = useCallback(
|
||||||
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
|
(query, searchString) => {
|
||||||
}, [reportInfo.dimension])
|
return addFilter(query, [
|
||||||
|
'contains',
|
||||||
|
reportInfo.dimension,
|
||||||
|
[searchString],
|
||||||
|
{ case_sensitive: false }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
[reportInfo.dimension]
|
||||||
|
)
|
||||||
|
|
||||||
function chooseMetrics() {
|
function chooseMetrics() {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => "Uniques" }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Uniques' }),
|
||||||
metrics.createEvents({ renderLabel: (_query) => "Total" }),
|
metrics.createEvents({ renderLabel: (_query) => 'Total' }),
|
||||||
metrics.createConversionRate(),
|
metrics.createConversionRate(),
|
||||||
showRevenue && metrics.createAverageRevenue(),
|
showRevenue && metrics.createAverageRevenue(),
|
||||||
showRevenue && metrics.createTotalRevenue(),
|
showRevenue && metrics.createTotalRevenue()
|
||||||
].filter(metric => !!metric)
|
].filter((metric) => !!metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a successful API response, we want to scan the rows of the
|
// After a successful API response, we want to scan the rows of the
|
||||||
|
|
@ -49,9 +60,14 @@ function ConversionsModal() {
|
||||||
|
|
||||||
// After fetching the next page, we never want to set `showRevenue` to
|
// After fetching the next page, we never want to set `showRevenue` to
|
||||||
// `false` as revenue metrics might exist in previously loaded data.
|
// `false` as revenue metrics might exist in previously loaded data.
|
||||||
const afterFetchNextPage = useCallback((res) => {
|
const afterFetchNextPage = useCallback(
|
||||||
if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) }
|
(res) => {
|
||||||
}, [showRevenue])
|
if (!showRevenue && revenueInResponse(res)) {
|
||||||
|
setShowRevenue(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showRevenue]
|
||||||
|
)
|
||||||
|
|
||||||
function revenueInResponse(apiResponse) {
|
function revenueInResponse(apiResponse) {
|
||||||
return apiResponse.results.some((item) => item.total_revenue)
|
return apiResponse.results.some((item) => item.total_revenue)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue