Remove format pragma, run prettier on /assets (#5237)
This commit is contained in:
parent
9ca23f80ed
commit
ce1df315d3
|
|
@ -4,3 +4,4 @@ static/images/
|
|||
.*rc
|
||||
*.json
|
||||
*.config.js
|
||||
js/types/query-api.d.ts
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"insertPragma": true,
|
||||
"trailingComma": "none",
|
||||
"semi": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/* @format */
|
||||
|
||||
@import 'tailwindcss/base';
|
||||
@import 'flatpickr/dist/flatpickr.min.css';
|
||||
@import './modal.css';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
#chartjs-tooltip {
|
||||
background-color: rgb(25 30 56);
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
/* stylelint-disable media-feature-range-notation */
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
.loading {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
.modal {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/*
|
||||
* Put your component styling within the Tailwind utilities layer.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
[tooltip] {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import "./polyfills/closest"
|
||||
import './polyfills/closest'
|
||||
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
|
||||
import Alpine from 'alpinejs'
|
||||
import "./liveview/live_socket"
|
||||
import comboBox from "./liveview/combo-box"
|
||||
import dropdown from "./liveview/dropdown"
|
||||
import "./liveview/phx_events"
|
||||
import './liveview/live_socket'
|
||||
import comboBox from './liveview/combo-box'
|
||||
import dropdown from './liveview/dropdown'
|
||||
import './liveview/phx_events'
|
||||
|
||||
Alpine.data('dropdown', dropdown)
|
||||
Alpine.data('comboBox', comboBox)
|
||||
|
|
@ -61,7 +61,9 @@ const changelogNotification = document.getElementById('changelog-notification')
|
|||
if (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) => {
|
||||
localStorage.lastChangelogUpdate = new Date(res).getTime()
|
||||
|
|
@ -89,7 +91,9 @@ function showChangelogNotification(el) {
|
|||
const link = el.getElementsByTagName('a')[0]
|
||||
link.addEventListener('click', function () {
|
||||
localStorage.lastChangelogClick = Date.now()
|
||||
setTimeout(() => { link.remove() }, 100)
|
||||
setTimeout(() => {
|
||||
link.remove()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -116,7 +120,8 @@ if (embedButton) {
|
|||
<script async src="${baseUrl}/js/embed.host.js"></script>`
|
||||
} catch (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 { createRoot } from 'react-dom/client'
|
||||
import 'url-search-params-polyfill'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/** @format */
|
||||
import { DashboardQuery } from './query'
|
||||
import { formatISO } from './util/date'
|
||||
import { serializeApiFilters } from './util/filters'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, {
|
||||
Fragment,
|
||||
useState,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
AppNavigationLink,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { Fragment, useRef } from 'react'
|
||||
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useInView } from 'react-intersection-observer'
|
|||
export default function LazyLoader(props) {
|
||||
const [hasBecomeVisibleYet, setHasBecomeVisibleYet] = useState(false)
|
||||
const { ref, inView } = useInView({
|
||||
threshold: 0,
|
||||
threshold: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -15,9 +15,5 @@ export default function LazyLoader(props) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inView])
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
return <div ref={ref}>{props.children}</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,57 @@
|
|||
import React from "react"
|
||||
import { sectionTitles } from "../stats/behaviours"
|
||||
import React from 'react'
|
||||
import { sectionTitles } from '../stats/behaviours'
|
||||
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 sectionTitle = sectionTitles[feature]
|
||||
|
||||
const requestHideSection = () => {
|
||||
if (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 } })
|
||||
if (
|
||||
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())
|
||||
.catch((error) => {if (!(error instanceof api.ApiError)) {throw error}})
|
||||
|
||||
.catch((error) => {
|
||||
if (!(error instanceof api.ApiError)) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function renderCallToAction() {
|
||||
return (
|
||||
<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>
|
||||
<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" />
|
||||
<p className="flex flex-col justify-center text-xs sm:text-sm">
|
||||
{callToAction.action}
|
||||
</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>
|
||||
</a>
|
||||
)
|
||||
|
|
@ -31,7 +61,8 @@ export function FeatureSetupNotice({ feature, title, info, callToAction, onHideA
|
|||
return (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/** @format */
|
||||
import { TransitionClasses } from '@headlessui/react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react'
|
||||
import { isModifierPressed, Keybind } from '../keybinding'
|
||||
import { useDebounce } from '../custom-hooks'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode } from 'react'
|
||||
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by'
|
||||
import classNames from 'classnames'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import classNames from 'classnames'
|
||||
import React, { ReactNode } from 'react'
|
||||
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
|
||||
// the function does not run on the initial render.
|
||||
|
|
@ -22,15 +22,20 @@ export function useDebounce(fn, delay = DEBOUNCE_DELAY) {
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) { clearTimeout(timerRef.current) }
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useCallback((...args) => {
|
||||
return useCallback(
|
||||
(...args) => {
|
||||
clearTimeout(timerRef.current)
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay)
|
||||
}, [fn, delay])
|
||||
},
|
||||
[fn, delay]
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React from 'react'
|
||||
import { NavigateKeybind } from './keybinding'
|
||||
import { useRoutelessModalsContext } from './navigation/routeless-modals-context'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode, ReactElement } from 'react'
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { GoToSites, SomethingWentWrongMessage } from './something-went-wrong'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode } from 'react'
|
||||
import RocketIcon from '../stats/modals/rocket-icon'
|
||||
import { useInRouterContext } from 'react-router-dom'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export default function FunnelTooltip(palette, funnel) {
|
|||
return (context) => {
|
||||
const tooltipModel = context.tooltip
|
||||
const dataIndex = tooltipModel.dataPoints[0].dataIndex
|
||||
const offset = document.getElementById("funnel").getBoundingClientRect()
|
||||
const offset = document.getElementById('funnel').getBoundingClientRect()
|
||||
let tooltipEl = document.getElementById('chartjs-tooltip')
|
||||
|
||||
if (!tooltipEl) {
|
||||
|
|
@ -14,7 +14,8 @@ export default function FunnelTooltip(palette, funnel) {
|
|||
}
|
||||
|
||||
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.right = null
|
||||
tooltipEl.style.opacity = 1
|
||||
|
|
@ -25,15 +26,14 @@ export default function FunnelTooltip(palette, funnel) {
|
|||
return
|
||||
}
|
||||
|
||||
|
||||
if (tooltipModel.body) {
|
||||
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 = `
|
||||
<aside class="text-gray-100 flex flex-col">
|
||||
<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>
|
||||
${tooltipModel.title}
|
||||
</span>
|
||||
|
|
@ -45,7 +45,7 @@ export default function FunnelTooltip(palette, funnel) {
|
|||
<span class="flex items-center mr-4">
|
||||
<div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div>
|
||||
<span>
|
||||
${dataIndex == 0 ? "Entered the funnel" : "Visitors"}
|
||||
${dataIndex == 0 ? 'Entered the funnel' : 'Visitors'}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
|
|
@ -65,7 +65,7 @@ export default function FunnelTooltip(palette, funnel) {
|
|||
<span class="flex items-center">
|
||||
<div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div>
|
||||
<span>
|
||||
${dataIndex == 0 ? "Never entered the funnel" : "Dropoff"}
|
||||
${dataIndex == 0 ? 'Never entered the funnel' : 'Dropoff'}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import FlipMove from 'react-flip-move';
|
||||
import Chart from 'chart.js/auto';
|
||||
import FunnelTooltip from './funnel-tooltip';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import { numberShortFormatter } from '../util/number-formatter';
|
||||
import Bar from '../stats/bar';
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import FlipMove from 'react-flip-move'
|
||||
import Chart from 'chart.js/auto'
|
||||
import FunnelTooltip from './funnel-tooltip'
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
||||
import { numberShortFormatter } from '../util/number-formatter'
|
||||
import Bar from '../stats/bar'
|
||||
|
||||
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 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'
|
||||
|
||||
export default function Funnel({ funnelName, tabs }) {
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [error, setError] = useState(undefined)
|
||||
|
|
@ -60,11 +59,11 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
const mediaQuery = window.matchMedia('(max-width: 768px)')
|
||||
setSmallScreen(mediaQuery.matches)
|
||||
const handleScreenChange = (e) => {
|
||||
setSmallScreen(e.matches);
|
||||
setSmallScreen(e.matches)
|
||||
}
|
||||
mediaQuery.addEventListener("change", handleScreenChange);
|
||||
mediaQuery.addEventListener('change', handleScreenChange)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleScreenChange)
|
||||
mediaQuery.removeEventListener('change', handleScreenChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -110,7 +109,9 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
}
|
||||
|
||||
const calcOffset = (ctx) => {
|
||||
const conversionRate = parseFloat(funnel.steps[ctx.dataIndex].conversion_rate)
|
||||
const conversionRate = parseFloat(
|
||||
funnel.steps[ctx.dataIndex].conversion_rate
|
||||
)
|
||||
if (conversionRate > 90) {
|
||||
return -64
|
||||
} else if (conversionRate > 20) {
|
||||
|
|
@ -129,7 +130,10 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
if (typeof funnelMeta === 'undefined') {
|
||||
throw new Error('Could not fetch the funnel. Perhaps it was deleted?')
|
||||
} 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.strokeStyle = color2
|
||||
c.fillRect(0, 0, shape.width, shape.height);
|
||||
c.fillRect(0, 0, shape.width, shape.height)
|
||||
|
||||
c.beginPath()
|
||||
c.moveTo(2, 0)
|
||||
|
|
@ -168,7 +172,7 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
const stepData = funnel.steps.map((step) => step.visitors)
|
||||
|
||||
const dropOffData = funnel.steps.map((step) => step.dropoff)
|
||||
const ctx = canvasRef.current.getContext("2d")
|
||||
const ctx = canvasRef.current.getContext('2d')
|
||||
|
||||
const calcBarThickness = (ctx) => {
|
||||
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
|
||||
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);
|
||||
gradient.addColorStop(1, palette.dropoffBackground);
|
||||
gradient.addColorStop(0, palette.visitorsBackground);
|
||||
var gradient = ctx.createLinearGradient(900, 0, 900, 900)
|
||||
gradient.addColorStop(1, palette.dropoffBackground)
|
||||
gradient.addColorStop(0, palette.visitorsBackground)
|
||||
|
||||
const data = {
|
||||
labels: labels,
|
||||
|
|
@ -194,17 +199,20 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
backgroundColor: gradient,
|
||||
hoverBackgroundColor: gradient,
|
||||
borderRadius: 4,
|
||||
stack: 'Stack 0',
|
||||
stack: 'Stack 0'
|
||||
},
|
||||
{
|
||||
label: 'Dropoff',
|
||||
data: dropOffData,
|
||||
backgroundColor: createDiagonalPattern(palette.dropoffBackground, palette.dropoffStripes),
|
||||
backgroundColor: createDiagonalPattern(
|
||||
palette.dropoffBackground,
|
||||
palette.dropoffStripes
|
||||
),
|
||||
hoverBackgroundColor: palette.dropoffBackground,
|
||||
borderRadius: 4,
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
],
|
||||
stack: 'Stack 0'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const config = {
|
||||
|
|
@ -216,7 +224,7 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
barThickness: calcBarThickness,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
|
|
@ -234,10 +242,15 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
color: palette.dataLabelTextColor,
|
||||
borderRadius: 4,
|
||||
clip: true,
|
||||
font: { size: 12, weight: 'normal', lineHeight: 1.6, family: fontFamily },
|
||||
textAlign: 'center',
|
||||
padding: { top: 8, bottom: 8, right: 8, left: 8 },
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'normal',
|
||||
lineHeight: 1.6,
|
||||
family: fontFamily
|
||||
},
|
||||
textAlign: 'center',
|
||||
padding: { top: 8, bottom: 8, right: 8, left: 8 }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { display: false },
|
||||
|
|
@ -250,10 +263,10 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
padding: 8,
|
||||
font: { weight: 'bold', family: fontFamily, size: 14 },
|
||||
color: palette.stepNameLegendColor
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chartRef.current = new Chart(ctx, config)
|
||||
|
|
@ -274,7 +287,9 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
return (
|
||||
<>
|
||||
{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 {
|
||||
|
|
@ -284,8 +299,12 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
<div className="text-center text-gray-900 dark:text-gray-100 mt-16">
|
||||
<RocketIcon />
|
||||
<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-xs mt-8">Please try refreshing your browser or selecting the funnel again.</div>
|
||||
<div className="text-lg">
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
|
|
@ -294,16 +313,24 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
|
||||
const renderInner = () => {
|
||||
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) {
|
||||
return renderError()
|
||||
} else if (funnel) {
|
||||
const conversionRate = funnel.steps[funnel.steps.length - 1].conversion_rate
|
||||
const conversionRate =
|
||||
funnel.steps[funnel.steps.length - 1].conversion_rate
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{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>}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -320,16 +347,18 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
count={step.visitors}
|
||||
all={funnel.steps}
|
||||
bg={palette.smallBarClass}
|
||||
maxWidthDeduction={"5rem"}
|
||||
maxWidthDeduction={'5rem'}
|
||||
plot={'visitors'}
|
||||
>
|
||||
|
||||
<span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all">
|
||||
{step.label}
|
||||
</span>
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -346,9 +375,7 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
<span className="inline-block w-20">Visitors</span>
|
||||
</span>
|
||||
</div>
|
||||
<FlipMove>
|
||||
{funnel.steps.map(renderBar)}
|
||||
</FlipMove>
|
||||
<FlipMove>{funnel.steps.map(renderBar)}</FlipMove>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -358,7 +385,9 @@ export default function Funnel({ funnelName, tabs }) {
|
|||
<LazyLoader onVisible={() => setVisible(true)}>
|
||||
{renderInner()}
|
||||
</LazyLoader>
|
||||
{!isSmallScreen && <canvas className="" id="funnel" ref={canvasRef}></canvas>}
|
||||
{!isSmallScreen && (
|
||||
<canvas className="" id="funnel" ref={canvasRef}></canvas>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
export default function FadeIn({ className, show, children }) {
|
||||
return (
|
||||
<div
|
||||
className={`${className || ''} ${show ? "fade-enter-active" : "fade-enter"}`}
|
||||
className={`${className || ''} ${show ? 'fade-enter-active' : 'fade-enter'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { SegmentsContextProvider, useSegmentsContext } from './segments-context'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/** @format */
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { remapToApiFilters } from '../util/filters'
|
||||
import {
|
||||
formatSegmentIdAsLabelKey,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { DashboardQuery, Filter } from '../query'
|
||||
import { cleanLabels, remapFromApiFilters } from '../util/filters'
|
||||
import { plainFilterText } from '../util/filter-text'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
useQueryClient,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { Metric } from '../stats/reports/metrics'
|
||||
import {
|
||||
OrderBy,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Metric } from '../stats/reports/metrics'
|
||||
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 {
|
||||
const ref = useRef<T | undefined>(undefined);
|
||||
const ref = useRef<T | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
// Update the ref with the current value after render
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
ref.current = value
|
||||
}, [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 VisitorGraph from './stats/graph/visitor-graph'
|
||||
import Sources from './stats/sources'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { ReactNode, RefObject, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
AppNavigationTarget,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, {
|
||||
createContext,
|
||||
useEffect,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import {
|
||||
FILTER_MODAL_TO_FILTER_GROUP,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode } from 'react'
|
||||
import {
|
||||
AppNavigationLink,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from 'react'
|
||||
import { useQueryContext } from '../query-context'
|
||||
import { FilterPill, FilterPillProps } from './filter-pill'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '../../../test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'
|
||||
import classNames from 'classnames'
|
||||
import React, { useRef, useState, useLayoutEffect, ReactNode } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const MenuSeparator = () => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useRef } from 'react'
|
||||
import { clearedComparisonSearch } from '../../query'
|
||||
import classNames from 'classnames'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DateRangeCalendar } from './date-range-calendar'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { useLayoutEffect, useRef } from 'react'
|
||||
import DatePicker from 'react-flatpickr'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { useMemo } from 'react'
|
||||
import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query'
|
||||
import classNames from 'classnames'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useRef } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode, RefObject } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { popover } from '../../components/popover'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
render,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode, useRef } from 'react'
|
||||
import SiteSwitcher from '../site-switcher'
|
||||
import { useSiteContext } from '../site-context'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||
import { RoutelessSegmentModal } from '../segments/routeless-segment-modals'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { forwardRef, useCallback } from 'react'
|
||||
import {
|
||||
Link,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useAppNavigate } from './use-app-navigate'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { createContext, useMemo, useContext, ReactNode } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { useMountedEffect } from './custom-hooks'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import {
|
||||
ComparisonMode,
|
||||
getDashboardTimeSettings,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
clearedComparisonSearch,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import {
|
||||
nowForSite,
|
||||
formatISO,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/** @format */
|
||||
import React from 'react'
|
||||
import { createBrowserRouter, Outlet, useRouteError } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
CreateSegmentModal,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SegmentAuthorship } from './segment-authorship'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
|
||||
import { dateForSite, formatDayShort } from '../util/date'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SegmentModal } from './segment-modals'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode, useState } from 'react'
|
||||
import ModalWithRouting from '../stats/modals/modal'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/** @format */
|
||||
import React, { HTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { parseSiteFromDataset, PlausibleSite } from './site-context'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/** @format */
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
|
||||
export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
function barWidth(count, all, plot) {
|
||||
let maxVal = all[0][plot];
|
||||
let maxVal = all[0][plot]
|
||||
|
||||
for (const val of all) {
|
||||
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 style = maxWidthDeduction ? {maxWidth: `calc(100% - ${maxWidthDeduction})`} : {}
|
||||
const style = maxWidthDeduction
|
||||
? { maxWidth: `calc(100% - ${maxWidthDeduction})` }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full relative"
|
||||
style={style}
|
||||
>
|
||||
<div className="w-full h-full relative" style={style}>
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
||||
style={{ width: `${width}%` }}
|
||||
>
|
||||
</div>
|
||||
></div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import React from 'react';
|
||||
import * as api from '../../api';
|
||||
import * as url from '../../util/url';
|
||||
import React from 'react'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../util/url'
|
||||
|
||||
import * as metrics from '../reports/metrics';
|
||||
import ListReport from '../reports/list';
|
||||
import { useSiteContext } from '../../site-context';
|
||||
import { useQueryContext } from '../../query-context';
|
||||
import { conversionsRoute } from '../../router';
|
||||
import * as metrics from '../reports/metrics'
|
||||
import ListReport from '../reports/list'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { conversionsRoute } from '../../router'
|
||||
|
||||
export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
||||
const site = useSiteContext();
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
|
||||
function fetchConversions() {
|
||||
|
|
@ -18,19 +18,27 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
|||
|
||||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: "goal",
|
||||
filter: ["is", "goal", [listItem.name]],
|
||||
prefix: 'goal',
|
||||
filter: ['is', 'goal', [listItem.name]]
|
||||
}
|
||||
}
|
||||
|
||||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: { plot: true } }),
|
||||
metrics.createEvents({ renderLabel: (_query) => "Total", meta: { hiddenOnMobile: true } }),
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Uniques',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
metrics.createEvents({
|
||||
renderLabel: (_query) => 'Total',
|
||||
meta: { hiddenOnMobile: true }
|
||||
}),
|
||||
metrics.createConversionRate(),
|
||||
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
||||
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
||||
].filter(metric => !!metric)
|
||||
BUILD_EXTRA &&
|
||||
metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
||||
BUILD_EXTRA &&
|
||||
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
|
|
@ -42,7 +50,10 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
|
|||
keyLabel="Goal"
|
||||
onClick={onGoalFilterClick}
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: conversionsRoute.path, search: (search) => search }}
|
||||
detailsLinkProps={{
|
||||
path: conversionsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
color="bg-red-50"
|
||||
colMinWidth={90}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import React from "react"
|
||||
import React from 'react'
|
||||
import Conversions from './conversions'
|
||||
import ListReport from "../reports/list"
|
||||
import ListReport from '../reports/list'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from "../../util/url"
|
||||
import * as api from "../../api"
|
||||
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS } from "../../util/filters"
|
||||
import { useSiteContext } from "../../site-context"
|
||||
import { useQueryContext } from "../../query-context"
|
||||
import { customPropsRoute } from "../../router"
|
||||
import * as url from '../../util/url'
|
||||
import * as api from '../../api'
|
||||
import {
|
||||
EVENT_PROPS_PREFIX,
|
||||
getGoalFilter,
|
||||
FILTER_OPERATIONS
|
||||
} from '../../util/filters'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { customPropsRoute } from '../../router'
|
||||
|
||||
export const SPECIAL_GOALS = {
|
||||
'404': { title: '404 Pages', prop: 'path' },
|
||||
404: { title: '404 Pages', prop: 'path' },
|
||||
'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
|
||||
'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
|
||||
'File Download': { title: 'File Downloads', prop: 'url' },
|
||||
'WP Search Queries': { title: 'WordPress Search Queries', prop: 'search_query' },
|
||||
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' },
|
||||
'WP Search Queries': {
|
||||
title: 'WordPress Search Queries',
|
||||
prop: 'search_query'
|
||||
},
|
||||
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }
|
||||
}
|
||||
|
||||
function getSpecialGoal(query) {
|
||||
|
|
@ -28,7 +35,6 @@ function getSpecialGoal(query) {
|
|||
return SPECIAL_GOALS[clauses[0]] || null
|
||||
}
|
||||
return null
|
||||
|
||||
}
|
||||
|
||||
export function specialTitleWhenGoalFilter(query, defaultTitle) {
|
||||
|
|
@ -36,8 +42,8 @@ export function specialTitleWhenGoalFilter(query, defaultTitle) {
|
|||
}
|
||||
|
||||
function SpecialPropBreakdown({ prop, afterFetchData }) {
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query)
|
||||
|
|
@ -54,16 +60,22 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
|||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: EVENT_PROPS_PREFIX,
|
||||
filter: ["is", `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
|
||||
filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
|
||||
}
|
||||
}
|
||||
|
||||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }),
|
||||
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }),
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Visitors',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
metrics.createEvents({
|
||||
renderLabel: (_query) => 'Events',
|
||||
meta: { hiddenOnMobile: true }
|
||||
}),
|
||||
metrics.createConversionRate()
|
||||
].filter(metric => !!metric)
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -73,7 +85,11 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
|
|||
getFilterInfo={getFilterInfo}
|
||||
keyLabel={prop}
|
||||
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()}
|
||||
maybeHideDetails={true}
|
||||
color="bg-red-50"
|
||||
|
|
@ -87,8 +103,18 @@ export default function GoalConversions({ afterFetchData, onGoalFilterClick }) {
|
|||
|
||||
const specialGoal = getSpecialGoal(query)
|
||||
if (specialGoal) {
|
||||
return <SpecialPropBreakdown prop={specialGoal.prop} afterFetchData={afterFetchData} />
|
||||
return (
|
||||
<SpecialPropBreakdown
|
||||
prop={specialGoal.prop}
|
||||
afterFetchData={afterFetchData}
|
||||
/>
|
||||
)
|
||||
} 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 { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import classNames from 'classnames'
|
||||
import * as storage from '../../util/storage'
|
||||
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 { FeatureSetupNotice } from '../../components/notice'
|
||||
import { hasConversionGoalFilter } from '../../util/filters'
|
||||
|
|
@ -26,7 +35,8 @@ function maybeRequire() {
|
|||
|
||||
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'
|
||||
|
||||
export const CONVERSIONS = 'conversions'
|
||||
|
|
@ -40,21 +50,29 @@ export const sectionTitles = {
|
|||
}
|
||||
|
||||
export default function Behaviours({ importedDataInView }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const user = useUserContext();
|
||||
const buttonRef = useRef();
|
||||
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(user.role)
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
const user = useUserContext()
|
||||
const buttonRef = useRef()
|
||||
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(
|
||||
user.role
|
||||
)
|
||||
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 [mode, setMode] = useState(defaultMode())
|
||||
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 [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
|
||||
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] =
|
||||
useState(false)
|
||||
|
||||
const [skipImportedReason, setSkipImportedReason] = useState(null)
|
||||
|
||||
|
|
@ -63,7 +81,12 @@ export default function Behaviours({ importedDataInView }) {
|
|||
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
|
||||
const isPageviewGoal = goalName.startsWith('Visit ')
|
||||
|
||||
if (!isSpecialGoal && !isPageviewGoal && enabledModes.includes(PROPS) && site.hasProps) {
|
||||
if (
|
||||
!isSpecialGoal &&
|
||||
!isPageviewGoal &&
|
||||
enabledModes.includes(PROPS) &&
|
||||
site.hasProps
|
||||
) {
|
||||
setShowingPropsForGoalFilter(true)
|
||||
setMode(PROPS)
|
||||
}
|
||||
|
|
@ -87,7 +110,11 @@ export default function Behaviours({ importedDataInView }) {
|
|||
useEffect(() => setLoading(true), [query, mode])
|
||||
|
||||
function disableMode(mode) {
|
||||
setEnabledModes(enabledModes.filter((m) => { return m !== mode }))
|
||||
setEnabledModes(
|
||||
enabledModes.filter((m) => {
|
||||
return m !== mode
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function setFunnel(selectedFunnel) {
|
||||
|
|
@ -118,12 +145,21 @@ export default function Behaviours({ importedDataInView }) {
|
|||
}
|
||||
|
||||
function tabFunnelPicker() {
|
||||
return <Menu as="div" className="relative inline-block text-left">
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<BlurMenuButtonOnEscape targetRef={buttonRef} />
|
||||
<div>
|
||||
<Menu.Button ref={buttonRef} className="inline-flex justify-between focus:outline-none">
|
||||
<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
|
||||
ref={buttonRef}
|
||||
className="inline-flex justify-between focus:outline-none"
|
||||
>
|
||||
<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>
|
||||
|
||||
|
|
@ -145,9 +181,13 @@ export default function Behaviours({ importedDataInView }) {
|
|||
<span
|
||||
onClick={setFunnel(funnelName)}
|
||||
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
|
||||
? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer'
|
||||
: 'text-gray-700 dark:text-gray-200',
|
||||
'block px-4 py-2 text-sm',
|
||||
(mode === FUNNELS && selectedFunnel === funnelName) ? 'font-bold text-gray-500' : ''
|
||||
mode === FUNNELS && selectedFunnel === funnelName
|
||||
? 'font-bold text-gray-500'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{funnelName}
|
||||
|
|
@ -160,10 +200,14 @@ export default function Behaviours({ importedDataInView }) {
|
|||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
storage.setItem(tabKey, 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">
|
||||
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
|
||||
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
|
||||
{isEnabled(FUNNELS) && Funnel && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
|
||||
{isEnabled(FUNNELS) &&
|
||||
Funnel &&
|
||||
(hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -193,37 +239,45 @@ export default function Behaviours({ importedDataInView }) {
|
|||
|
||||
function renderConversions() {
|
||||
if (site.hasGoals) {
|
||||
return <GoalConversions onGoalFilterClick={onGoalFilterClick} afterFetchData={afterFetchData} />
|
||||
}
|
||||
else if (adminAccess) {
|
||||
return (
|
||||
<GoalConversions
|
||||
onGoalFilterClick={onGoalFilterClick}
|
||||
afterFetchData={afterFetchData}
|
||||
/>
|
||||
)
|
||||
} else if (adminAccess) {
|
||||
return (
|
||||
<FeatureSetupNotice
|
||||
feature={CONVERSIONS}
|
||||
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={{
|
||||
action: "Set up goals",
|
||||
action: 'Set up goals',
|
||||
link: `/${encodeURIComponent(site.domain)}/settings/goals`
|
||||
}}
|
||||
onHideAction={onHideAction(CONVERSIONS)}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return noDataYet()
|
||||
}
|
||||
else { return noDataYet() }
|
||||
}
|
||||
|
||||
function renderFunnels() {
|
||||
if (Funnel === null) {
|
||||
return featureUnavailable()
|
||||
}
|
||||
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
|
||||
} else if (Funnel && selectedFunnel && site.funnelsAvailable) {
|
||||
return <Funnel funnelName={selectedFunnel} />
|
||||
}
|
||||
else if (Funnel && adminAccess) {
|
||||
} else if (Funnel && adminAccess) {
|
||||
let callToAction
|
||||
|
||||
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 {
|
||||
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
|
||||
}
|
||||
|
|
@ -232,13 +286,16 @@ export default function Behaviours({ importedDataInView }) {
|
|||
<FeatureSetupNotice
|
||||
feature={FUNNELS}
|
||||
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}
|
||||
onHideAction={onHideAction(FUNNELS)}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return noDataYet()
|
||||
}
|
||||
else { return noDataYet() }
|
||||
}
|
||||
|
||||
function renderProps() {
|
||||
|
|
@ -248,7 +305,10 @@ export default function Behaviours({ importedDataInView }) {
|
|||
let callToAction
|
||||
|
||||
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 {
|
||||
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
|
||||
}
|
||||
|
|
@ -257,12 +317,16 @@ export default function Behaviours({ importedDataInView }) {
|
|||
<FeatureSetupNotice
|
||||
feature={PROPS}
|
||||
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}
|
||||
onHideAction={onHideAction(PROPS)}
|
||||
/>
|
||||
)
|
||||
} else { return noDataYet() }
|
||||
} else {
|
||||
return noDataYet()
|
||||
}
|
||||
}
|
||||
|
||||
function noDataYet() {
|
||||
|
|
@ -282,7 +346,9 @@ export default function Behaviours({ importedDataInView }) {
|
|||
}
|
||||
|
||||
function onHideAction(mode) {
|
||||
return () => { disableMode(mode) }
|
||||
return () => {
|
||||
disableMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
|
|
@ -297,13 +363,21 @@ export default function Behaviours({ importedDataInView }) {
|
|||
}
|
||||
|
||||
function defaultMode() {
|
||||
if (enabledModes.length === 0) { return null }
|
||||
if (enabledModes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
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(PROPS)) { return PROPS }
|
||||
if (enabledModes.includes(CONVERSIONS)) {
|
||||
return CONVERSIONS
|
||||
}
|
||||
if (enabledModes.includes(PROPS)) {
|
||||
return PROPS
|
||||
}
|
||||
return FUNNELS
|
||||
}
|
||||
|
||||
|
|
@ -345,11 +419,27 @@ export default function Behaviours({ importedDataInView }) {
|
|||
|
||||
function renderImportedQueryUnsupportedWarning() {
|
||||
if (mode === CONVERSIONS) {
|
||||
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
|
||||
return (
|
||||
<ImportedQueryUnsupportedWarning
|
||||
loading={loading}
|
||||
skipImportedReason={skipImportedReason}
|
||||
/>
|
||||
)
|
||||
} 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 {
|
||||
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 ListReport, { MIN_HEIGHT } from "../reports/list";
|
||||
import Combobox from '../../components/combobox';
|
||||
import * as metrics from '../reports/metrics';
|
||||
import * as api from '../../api';
|
||||
import * as url from '../../util/url';
|
||||
import * as storage from "../../util/storage";
|
||||
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasConversionGoalFilter } from "../../util/filters";
|
||||
import classNames from "classnames";
|
||||
import { useQueryContext } from "../../query-context";
|
||||
import { useSiteContext } from "../../site-context";
|
||||
import { customPropsRoute } from "../../router";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import ListReport, { MIN_HEIGHT } from '../reports/list'
|
||||
import Combobox from '../../components/combobox'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../util/url'
|
||||
import * as storage from '../../util/storage'
|
||||
import {
|
||||
EVENT_PROPS_PREFIX,
|
||||
getGoalFilter,
|
||||
FILTER_OPERATIONS,
|
||||
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 }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
const propKeyStorageName = `prop_key__${site.domain}`
|
||||
const propKeyStorageNameForGoal = () => {
|
||||
|
|
@ -39,8 +43,8 @@ export default function Properties({ afterFetchData }) {
|
|||
setPropKeyLoading(true)
|
||||
setPropKey(null)
|
||||
|
||||
fetchPropKeyOptions()("").then((propKeys) => {
|
||||
const propKeyValues = propKeys.map(entry => entry.value)
|
||||
fetchPropKeyOptions()('').then((propKeys) => {
|
||||
const propKeyValues = propKeys.map((entry) => entry.value)
|
||||
|
||||
if (propKeyValues.length > 0) {
|
||||
const storedPropKey = getPropKeyFromStorage()
|
||||
|
|
@ -60,29 +64,39 @@ export default function Properties({ afterFetchData }) {
|
|||
function getPropKeyFromStorage() {
|
||||
if (singleGoalFilterApplied()) {
|
||||
const storedForGoal = storage.getItem(propKeyStorageNameForGoal())
|
||||
if (storedForGoal) { return storedForGoal }
|
||||
if (storedForGoal) {
|
||||
return storedForGoal
|
||||
}
|
||||
}
|
||||
|
||||
return storage.getItem(propKeyStorageName)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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
|
||||
}, [query])
|
||||
|
||||
function onPropKeySelect() {
|
||||
return (selectedOptions) => {
|
||||
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0].value
|
||||
const newPropKey =
|
||||
selectedOptions.length === 0 ? null : selectedOptions[0].value
|
||||
|
||||
if (newPropKey) {
|
||||
const storageName = singleGoalFilterApplied() ? propKeyStorageNameForGoal() : propKeyStorageName
|
||||
const storageName = singleGoalFilterApplied()
|
||||
? propKeyStorageNameForGoal()
|
||||
: propKeyStorageName
|
||||
storage.setItem(storageName, newPropKey)
|
||||
}
|
||||
|
||||
|
|
@ -93,13 +107,21 @@ export default function Properties({ afterFetchData }) {
|
|||
/*global BUILD_EXTRA*/
|
||||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }),
|
||||
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }),
|
||||
metrics.createVisitors({
|
||||
renderLabel: (_query) => 'Visitors',
|
||||
meta: { plot: true }
|
||||
}),
|
||||
metrics.createEvents({
|
||||
renderLabel: (_query) => 'Events',
|
||||
meta: { hiddenOnMobile: true }
|
||||
}),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage(),
|
||||
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
||||
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
||||
].filter(metric => !!metric)
|
||||
BUILD_EXTRA &&
|
||||
metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
|
||||
BUILD_EXTRA &&
|
||||
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
function renderBreakdown() {
|
||||
|
|
@ -110,7 +132,11 @@ export default function Properties({ afterFetchData }) {
|
|||
getFilterInfo={getFilterInfo}
|
||||
keyLabel={propKey}
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: customPropsRoute.path, params: { propKey }, search: (search) => search }}
|
||||
detailsLinkProps={{
|
||||
path: customPropsRoute.path,
|
||||
params: { propKey },
|
||||
search: (search) => search
|
||||
}}
|
||||
maybeHideDetails={true}
|
||||
color="bg-red-50"
|
||||
colMinWidth={90}
|
||||
|
|
@ -120,22 +146,38 @@ export default function Properties({ afterFetchData }) {
|
|||
|
||||
const getFilterInfo = (listItem) => ({
|
||||
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 comboboxPlaceholder = comboboxDisabled ? 'No custom properties found' : ''
|
||||
const comboboxPlaceholder = comboboxDisabled
|
||||
? 'No custom properties found'
|
||||
: ''
|
||||
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(
|
||||
'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
|
||||
|
||||
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` }}>
|
||||
<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>
|
||||
{propKey && renderBreakdown()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { AppNavigationLink } from '../navigation/use-app-navigate'
|
||||
import * as api from '../api'
|
||||
|
|
|
|||
|
|
@ -1,32 +1,36 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import * as storage from '../../util/storage';
|
||||
import { getFiltersByKeyPrefix, hasConversionGoalFilter, isFilteringOnFixedValue } from '../../util/filters';
|
||||
import ListReport from '../reports/list';
|
||||
import * as metrics from '../reports/metrics';
|
||||
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 React, { useEffect, useState } from 'react'
|
||||
import * as storage from '../../util/storage'
|
||||
import {
|
||||
getFiltersByKeyPrefix,
|
||||
hasConversionGoalFilter,
|
||||
isFilteringOnFixedValue
|
||||
} from '../../util/filters'
|
||||
import ListReport from '../reports/list'
|
||||
import * as metrics from '../reports/metrics'
|
||||
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 {
|
||||
browsersRoute,
|
||||
browserVersionsRoute,
|
||||
operatingSystemsRoute,
|
||||
operatingSystemVersionsRoute,
|
||||
screenSizesRoute
|
||||
} from '../../router';
|
||||
} from '../../router'
|
||||
|
||||
// Icons copied from https://github.com/alrra/browser-logos
|
||||
const BROWSER_ICONS = {
|
||||
'Chrome': 'chrome.svg',
|
||||
'curl': 'curl.svg',
|
||||
'Safari': 'safari.png',
|
||||
'Firefox': 'firefox.svg',
|
||||
Chrome: 'chrome.svg',
|
||||
curl: 'curl.svg',
|
||||
Safari: 'safari.png',
|
||||
Firefox: 'firefox.svg',
|
||||
'Microsoft Edge': 'edge.svg',
|
||||
'Vivaldi': 'vivaldi.svg',
|
||||
'Opera': 'opera.svg',
|
||||
Vivaldi: 'vivaldi.svg',
|
||||
Opera: 'opera.svg',
|
||||
'Samsung Browser': 'samsung-internet.svg',
|
||||
'Chromium': 'chromium.svg',
|
||||
Chromium: 'chromium.svg',
|
||||
'UC Browser': 'uc.svg',
|
||||
'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.
|
||||
|
|
@ -34,7 +38,7 @@ const BROWSER_ICONS = {
|
|||
'MIUI Browser': 'miui.webp',
|
||||
'Huawei Browser Mobile': 'huawei.png',
|
||||
'QQ Browser': 'qq.png',
|
||||
'Ecosia': 'ecosia.png',
|
||||
Ecosia: 'ecosia.png',
|
||||
'vivo Browser': 'vivo.png'
|
||||
}
|
||||
|
||||
|
|
@ -51,8 +55,8 @@ export function browserIconFor(browser) {
|
|||
}
|
||||
|
||||
function Browsers({ afterFetchData }) {
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/browsers'), query)
|
||||
}
|
||||
|
|
@ -60,7 +64,7 @@ function Browsers({ afterFetchData }) {
|
|||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: 'browser',
|
||||
filter: ["is", "browser", [listItem['name']]]
|
||||
filter: ['is', 'browser', [listItem['name']]]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +77,7 @@ function Browsers({ afterFetchData }) {
|
|||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
].filter(metric => !!metric)
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -84,14 +88,17 @@ function Browsers({ afterFetchData }) {
|
|||
keyLabel="Browser"
|
||||
metrics={chooseMetrics()}
|
||||
renderIcon={renderIcon}
|
||||
detailsLinkProps={{ path: browsersRoute.path, search: (search) => search }}
|
||||
detailsLinkProps={{
|
||||
path: browsersRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserVersions({ afterFetchData }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/browser-versions'), query)
|
||||
}
|
||||
|
|
@ -101,12 +108,12 @@ function BrowserVersions({ afterFetchData }) {
|
|||
}
|
||||
|
||||
function getFilterInfo(listItem) {
|
||||
if (getSingleFilter(query, "browser") == '(not set)') {
|
||||
if (getSingleFilter(query, 'browser') == '(not set)') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
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 } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
].filter(metric => !!metric)
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -126,46 +133,45 @@ function BrowserVersions({ afterFetchData }) {
|
|||
keyLabel="Browser version"
|
||||
metrics={chooseMetrics()}
|
||||
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
|
||||
const OS_ICONS = {
|
||||
'iOS': 'ios.png',
|
||||
'Mac': 'mac.png',
|
||||
'Windows': 'windows.png',
|
||||
iOS: 'ios.png',
|
||||
Mac: 'mac.png',
|
||||
Windows: 'windows.png',
|
||||
'Windows Phone': 'windows.png',
|
||||
'Android': 'android.png',
|
||||
Android: 'android.png',
|
||||
'GNU/Linux': 'gnu_linux.png',
|
||||
'Ubuntu': 'ubuntu.png',
|
||||
Ubuntu: 'ubuntu.png',
|
||||
'Chrome OS': 'chrome_os.png',
|
||||
'iPadOS': 'ipad_os.png',
|
||||
iPadOS: 'ipad_os.png',
|
||||
'Fire OS': 'fire_os.png',
|
||||
'HarmonyOS': 'harmony_os.png',
|
||||
'Tizen': 'tizen.png',
|
||||
'PlayStation': 'playstation.png',
|
||||
'KaiOS': 'kai_os.png',
|
||||
'Fedora': 'fedora.png',
|
||||
'FreeBSD': 'freebsd.png',
|
||||
HarmonyOS: 'harmony_os.png',
|
||||
Tizen: 'tizen.png',
|
||||
PlayStation: 'playstation.png',
|
||||
KaiOS: 'kai_os.png',
|
||||
Fedora: 'fedora.png',
|
||||
FreeBSD: 'freebsd.png'
|
||||
}
|
||||
|
||||
export function osIconFor(os) {
|
||||
const filename = OS_ICONS[os] || 'fallback.svg'
|
||||
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
src={`/images/icon/os/${filename}`}
|
||||
className="w-4 h-4 mr-2"
|
||||
/>
|
||||
<img alt="" src={`/images/icon/os/${filename}`} className="w-4 h-4 mr-2" />
|
||||
)
|
||||
}
|
||||
|
||||
function OperatingSystems({ afterFetchData }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/operating-systems'), query)
|
||||
}
|
||||
|
|
@ -173,7 +179,7 @@ function OperatingSystems({ afterFetchData }) {
|
|||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: 'os',
|
||||
filter: ["is", "os", [listItem['name']]]
|
||||
filter: ['is', 'os', [listItem['name']]]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,8 +187,9 @@ function OperatingSystems({ afterFetchData }) {
|
|||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage({ meta: { hiddenonMobile: true } })
|
||||
].filter(metric => !!metric)
|
||||
!hasConversionGoalFilter(query) &&
|
||||
metrics.createPercentage({ meta: { hiddenonMobile: true } })
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
function renderIcon(listItem) {
|
||||
|
|
@ -197,14 +204,17 @@ function OperatingSystems({ afterFetchData }) {
|
|||
renderIcon={renderIcon}
|
||||
keyLabel="Operating system"
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: operatingSystemsRoute.path, search: (search) => search }}
|
||||
detailsLinkProps={{
|
||||
path: operatingSystemsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OperatingSystemVersions({ afterFetchData }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/operating-system-versions'), query)
|
||||
|
|
@ -215,12 +225,12 @@ function OperatingSystemVersions({ afterFetchData }) {
|
|||
}
|
||||
|
||||
function getFilterInfo(listItem) {
|
||||
if (getSingleFilter(query, "os") == '(not set)') {
|
||||
if (getSingleFilter(query, 'os') == '(not set)') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
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 } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
].filter(metric => !!metric)
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -240,15 +250,17 @@ function OperatingSystemVersions({ afterFetchData }) {
|
|||
getFilterInfo={getFilterInfo}
|
||||
keyLabel="Operating System Version"
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: operatingSystemVersionsRoute.path, search: (search) => search }}
|
||||
detailsLinkProps={{
|
||||
path: operatingSystemVersionsRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function ScreenSizes({ afterFetchData }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/screen-sizes'), query)
|
||||
|
|
@ -261,7 +273,7 @@ function ScreenSizes({ afterFetchData }) {
|
|||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: 'screen',
|
||||
filter: ["is", "screen", [listItem['name']]]
|
||||
filter: ['is', 'screen', [listItem['name']]]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +282,7 @@ function ScreenSizes({ afterFetchData }) {
|
|||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
!hasConversionGoalFilter(query) && metrics.createPercentage()
|
||||
].filter(metric => !!metric)
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -281,7 +293,10 @@ function ScreenSizes({ afterFetchData }) {
|
|||
keyLabel="Screen size"
|
||||
metrics={chooseMetrics()}
|
||||
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
|
||||
|
||||
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') {
|
||||
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') {
|
||||
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') {
|
||||
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>
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
const tabKey = `deviceTab__${site.domain}`
|
||||
const storedTab = storage.getItem(tabKey)
|
||||
|
|
@ -347,9 +435,7 @@ export default function Devices() {
|
|||
|
||||
if (isActive) {
|
||||
return (
|
||||
<button
|
||||
className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500"
|
||||
>
|
||||
<button className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500">
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
|
|
@ -370,7 +456,10 @@ export default function Devices() {
|
|||
<div className="flex justify-between w-full">
|
||||
<div className="flex gap-x-1">
|
||||
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
||||
<ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
|
||||
<ImportedQueryUnsupportedWarning
|
||||
loading={loading}
|
||||
skipImportedReason={skipImportedReason}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||
{renderPill('Browser', 'browser')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
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 () {
|
||||
return browserDateFormat.resolvedOptions().hour12
|
||||
|
|
@ -19,7 +21,9 @@ const monthIntervalFormatter = {
|
|||
const weekIntervalFormatter = {
|
||||
long(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) {
|
||||
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
|
||||
|
|
@ -98,9 +102,20 @@ const factory = {
|
|||
* @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.
|
||||
*/
|
||||
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 options = { period: period, interval: interval, isBucketPartial: !isPeriodFull, shouldShowYear }
|
||||
const options = {
|
||||
period: period,
|
||||
interval: interval,
|
||||
isBucketPartial: !isPeriodFull,
|
||||
shouldShowYear
|
||||
}
|
||||
return function (isoDate, _index, _ticks) {
|
||||
return factory[interval][displayMode](isoDate, options)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,22 +5,36 @@ import { METRIC_LABELS } from './graph-util'
|
|||
import { MetricFormatterShort } from '../reports/metric-formatter'
|
||||
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]
|
||||
if (comparison) isPeriodFull = true
|
||||
|
||||
const formattedLabel = dateFormatter({
|
||||
interval: graphData.interval, longForm: true, period: query.period, isPeriodFull,
|
||||
interval: graphData.interval,
|
||||
longForm: true,
|
||||
period: query.period,
|
||||
isPeriodFull
|
||||
})(label)
|
||||
|
||||
if (query.period === 'realtime') {
|
||||
return dateFormatter({
|
||||
interval: graphData.interval, longForm: true, period: query.period,
|
||||
interval: graphData.interval,
|
||||
longForm: true,
|
||||
period: query.period
|
||||
})(label)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
|
|
@ -33,36 +47,59 @@ const calculatePercentageDifference = function(oldValue, newValue) {
|
|||
} else if (oldValue == 0 && newValue == 0) {
|
||||
return 0
|
||||
} else {
|
||||
return Math.round((newValue - oldValue) / oldValue * 100)
|
||||
return Math.round(((newValue - oldValue) / oldValue) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
const buildTooltipData = function (query, graphData, metric, tooltipModel) {
|
||||
const data = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "y")
|
||||
const comparisonData = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "yComparison")
|
||||
const data = tooltipModel.dataPoints.find(
|
||||
(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 comparisonLabel = comparisonData && renderBucketLabel(query, graphData, graphData.comparison_labels[comparisonData.dataIndex], true)
|
||||
const label =
|
||||
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 formatter = MetricFormatterShort[metric]
|
||||
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 formattedComparisonValue = comparisonData && formatter(comparisonValue)
|
||||
|
||||
return { label, formattedValue, comparisonLabel, formattedComparisonValue, comparisonDifference }
|
||||
return {
|
||||
label,
|
||||
formattedValue,
|
||||
comparisonLabel,
|
||||
formattedComparisonValue,
|
||||
comparisonDifference
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let tooltipRoot
|
||||
|
||||
export default function GraphTooltip(graphData, metric, query) {
|
||||
return (context) => {
|
||||
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')
|
||||
|
||||
if (!tooltipEl) {
|
||||
|
|
@ -75,7 +112,8 @@ export default function GraphTooltip(graphData, metric, query) {
|
|||
}
|
||||
|
||||
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.right = null
|
||||
tooltipEl.style.opacity = 1
|
||||
|
|
@ -87,26 +125,42 @@ export default function GraphTooltip(graphData, metric, query) {
|
|||
}
|
||||
|
||||
if (tooltipModel.body) {
|
||||
const tooltipData = buildTooltipData(query, graphData, metric, tooltipModel)
|
||||
const tooltipData = buildTooltipData(
|
||||
query,
|
||||
graphData,
|
||||
metric,
|
||||
tooltipModel
|
||||
)
|
||||
|
||||
tooltipRoot.render(
|
||||
<aside className="text-gray-100 flex flex-col">
|
||||
<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 ? (
|
||||
<div className="inline-flex items-center space-x-1">
|
||||
<ChangeArrow metric={metric} change={tooltipData.comparisonDifference} />
|
||||
</div>) : null}
|
||||
<ChangeArrow
|
||||
metric={metric}
|
||||
change={tooltipData.comparisonDifference}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{tooltipData.label ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<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
|
||||
className="w-3 h-3 mr-1 rounded-full"
|
||||
style={{ backgroundColor: 'rgba(101,116,205)' }}
|
||||
></div>
|
||||
<span>{tooltipData.label}</span>
|
||||
</span>
|
||||
<span className="text-base font-bold">{tooltipData.formattedValue}</span>
|
||||
<span className="text-base font-bold">
|
||||
{tooltipData.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tooltipData.comparisonLabel ? (
|
||||
|
|
@ -115,13 +169,20 @@ export default function GraphTooltip(graphData, metric, query) {
|
|||
<div className="w-3 h-3 mr-1 rounded-full bg-gray-500"></div>
|
||||
<span>{tooltipData.comparisonLabel}</span>
|
||||
</span>
|
||||
<span className="text-base font-bold">{tooltipData.formattedComparisonValue}</span>
|
||||
</div>) : null}
|
||||
<span className="text-base font-bold">
|
||||
{tooltipData.formattedComparisonValue}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{graphData.interval === "month" ? (<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}
|
||||
{graphData.interval === 'month' ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
export const METRIC_LABELS = {
|
||||
'visitors': 'Visitors',
|
||||
'pageviews': 'Pageviews',
|
||||
'events': 'Total Conversions',
|
||||
'views_per_visit': 'Views per Visit',
|
||||
'visits': 'Visits',
|
||||
'bounce_rate': 'Bounce Rate',
|
||||
'visit_duration': 'Visit Duration',
|
||||
'conversions': 'Converted Visitors',
|
||||
'conversion_rate': 'Conversion Rate',
|
||||
'average_revenue': 'Average Revenue',
|
||||
'total_revenue': 'Total Revenue',
|
||||
'scroll_depth': 'Scroll Depth',
|
||||
'time_on_page': 'Time on Page',
|
||||
visitors: 'Visitors',
|
||||
pageviews: 'Pageviews',
|
||||
events: 'Total Conversions',
|
||||
views_per_visit: 'Views per Visit',
|
||||
visits: 'Visits',
|
||||
bounce_rate: 'Bounce Rate',
|
||||
visit_duration: 'Visit Duration',
|
||||
conversions: 'Converted Visitors',
|
||||
conversion_rate: 'Conversion Rate',
|
||||
average_revenue: 'Average Revenue',
|
||||
total_revenue: 'Total Revenue',
|
||||
scroll_depth: 'Scroll Depth',
|
||||
time_on_page: 'Time on Page'
|
||||
}
|
||||
|
||||
function plottable(dataArray) {
|
||||
|
|
@ -28,51 +28,70 @@ function plottable(dataArray) {
|
|||
const buildComparisonDataset = function (comparisonPlot) {
|
||||
if (!comparisonPlot) return []
|
||||
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
data: plottable(comparisonPlot),
|
||||
borderColor: 'rgba(60,70,110,0.2)',
|
||||
pointBackgroundColor: 'rgba(60,70,110,0.2)',
|
||||
pointHoverBackgroundColor: 'rgba(60, 70, 110)',
|
||||
yAxisID: 'yComparison',
|
||||
}]
|
||||
yAxisID: 'yComparison'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const buildDashedDataset = function (plot, presentIndex) {
|
||||
if (!presentIndex) return []
|
||||
|
||||
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1);
|
||||
const dashedPlot = (new Array(presentIndex - 1)).concat(dashedPart)
|
||||
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1)
|
||||
const dashedPlot = new Array(presentIndex - 1).concat(dashedPart)
|
||||
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
data: plottable(dashedPlot),
|
||||
borderDash: [3, 3],
|
||||
borderColor: 'rgba(101,116,205)',
|
||||
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||
yAxisID: 'y',
|
||||
}]
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const buildMainPlotDataset = function (plot, presentIndex) {
|
||||
const data = presentIndex ? plot.slice(0, presentIndex) : plot
|
||||
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
data: plottable(data),
|
||||
borderColor: 'rgba(101,116,205)',
|
||||
pointBackgroundColor: 'rgba(101,116,205)',
|
||||
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||
yAxisID: 'y',
|
||||
}]
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const buildDataSet = (plot, comparisonPlot, present_index, ctx, label) => {
|
||||
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)');
|
||||
export const buildDataSet = (
|
||||
plot,
|
||||
comparisonPlot,
|
||||
present_index,
|
||||
ctx,
|
||||
label
|
||||
) => {
|
||||
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 = [
|
||||
...buildMainPlotDataset(plot, present_index),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
import React, { Fragment, useRef } from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||
import classNames from 'classnames';
|
||||
import * as storage from '../../util/storage';
|
||||
import { BlurMenuButtonOnEscape, isModifierPressed, isTyping, Keybind } 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';
|
||||
import React, { Fragment, useRef } from 'react'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import classNames from 'classnames'
|
||||
import * as storage from '../../util/storage'
|
||||
import {
|
||||
BlurMenuButtonOnEscape,
|
||||
isModifierPressed,
|
||||
isTyping,
|
||||
Keybind
|
||||
} 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 = {
|
||||
'minute': 'Minutes',
|
||||
'hour': 'Hours',
|
||||
'day': 'Days',
|
||||
'week': 'Weeks',
|
||||
'month': 'Months'
|
||||
minute: 'Minutes',
|
||||
hour: 'Hours',
|
||||
day: 'Days',
|
||||
week: 'Weeks',
|
||||
month: 'Months'
|
||||
}
|
||||
|
||||
function validIntervals(site, query) {
|
||||
|
|
@ -36,11 +41,11 @@ function validIntervals(site, query) {
|
|||
|
||||
function getDefaultInterval(query, validIntervals) {
|
||||
const defaultByPeriod = {
|
||||
'day': 'hour',
|
||||
day: 'hour',
|
||||
'7d': 'day',
|
||||
'6mo': 'month',
|
||||
'12mo': 'month',
|
||||
'year': 'month'
|
||||
year: 'month'
|
||||
}
|
||||
|
||||
if (query.period === 'custom') {
|
||||
|
|
@ -89,8 +94,8 @@ export const getCurrentInterval = function(site, query) {
|
|||
|
||||
export function IntervalPicker({ onIntervalUpdate }) {
|
||||
const menuElement = useRef(null)
|
||||
const {query} = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
const dashboardRouteMatch = useMatch(rootRoute.path)
|
||||
|
||||
if (query.period == 'realtime') return null
|
||||
|
|
@ -98,7 +103,6 @@ export function IntervalPicker({ onIntervalUpdate }) {
|
|||
const options = validIntervals(site, query)
|
||||
const currentInterval = getCurrentInterval(site, query)
|
||||
|
||||
|
||||
function updateInterval(interval) {
|
||||
storeInterval(query.period, site.domain, interval)
|
||||
onIntervalUpdate(interval)
|
||||
|
|
@ -106,13 +110,23 @@ export function IntervalPicker({ onIntervalUpdate }) {
|
|||
|
||||
function renderDropdownItem(option) {
|
||||
return (
|
||||
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}>
|
||||
<Menu.Item
|
||||
onClick={() => updateInterval(option)}
|
||||
key={option}
|
||||
disabled={option == currentInterval}
|
||||
>
|
||||
{({ active }) => (
|
||||
<span className={classNames({
|
||||
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer': active,
|
||||
<span
|
||||
className={classNames(
|
||||
{
|
||||
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer':
|
||||
active,
|
||||
'text-gray-700 dark:text-gray-200': !active,
|
||||
'font-bold cursor-none select-none': option == currentInterval,
|
||||
}, 'block px-4 py-2 text-sm')}>
|
||||
'font-bold cursor-none select-none': option == currentInterval
|
||||
},
|
||||
'block px-4 py-2 text-sm'
|
||||
)}
|
||||
>
|
||||
{INTERVAL_LABELS[option]}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -144,11 +158,7 @@ export function IntervalPicker({ onIntervalUpdate }) {
|
|||
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={open}
|
||||
{...popover.transition.props}
|
||||
>
|
||||
<Transition as={Fragment} show={open} {...popover.transition.props}>
|
||||
<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"
|
||||
static
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React from 'react';
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate';
|
||||
import { useQueryContext } from '../../query-context';
|
||||
import Chart from 'chart.js/auto';
|
||||
import React from 'react'
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import Chart from 'chart.js/auto'
|
||||
import GraphTooltip from './graph-tooltip'
|
||||
import { buildDataSet, METRIC_LABELS } from './graph-util'
|
||||
import dateFormatter from './date-formatter';
|
||||
import FadeIn from '../../fade-in';
|
||||
import classNames from 'classnames';
|
||||
import { hasConversionGoalFilter } from '../../util/filters';
|
||||
import dateFormatter from './date-formatter'
|
||||
import FadeIn from '../../fade-in'
|
||||
import classNames from 'classnames'
|
||||
import { hasConversionGoalFilter } from '../../util/filters'
|
||||
import { MetricFormatterShort } from '../reports/metric-formatter'
|
||||
|
||||
const calculateMaximumY = function (dataset) {
|
||||
|
|
@ -24,9 +24,9 @@ const calculateMaximumY = function(dataset) {
|
|||
|
||||
class LineGraph extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.regenerateChart = this.regenerateChart.bind(this);
|
||||
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
||||
super(props)
|
||||
this.regenerateChart = this.regenerateChart.bind(this)
|
||||
this.updateWindowDimensions = this.updateWindowDimensions.bind(this)
|
||||
}
|
||||
|
||||
getGraphMetric() {
|
||||
|
|
@ -42,9 +42,15 @@ class LineGraph extends React.Component {
|
|||
regenerateChart() {
|
||||
const { graphData, query } = this.props
|
||||
const metric = this.getGraphMetric()
|
||||
const graphEl = document.getElementById("main-graph-canvas")
|
||||
this.ctx = graphEl.getContext('2d');
|
||||
const dataSet = buildDataSet(graphData.plot, graphData.comparison_plot, graphData.present_index, this.ctx, METRIC_LABELS[metric])
|
||||
const graphEl = document.getElementById('main-graph-canvas')
|
||||
this.ctx = graphEl.getContext('2d')
|
||||
const dataSet = buildDataSet(
|
||||
graphData.plot,
|
||||
graphData.comparison_plot,
|
||||
graphData.present_index,
|
||||
this.ctx,
|
||||
METRIC_LABELS[metric]
|
||||
)
|
||||
|
||||
return new Chart(this.ctx, {
|
||||
type: 'line',
|
||||
|
|
@ -62,7 +68,7 @@ class LineGraph extends React.Component {
|
|||
intersect: false,
|
||||
position: 'average',
|
||||
external: GraphTooltip(graphData, metric, query)
|
||||
},
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
|
|
@ -82,41 +88,42 @@ class LineGraph extends React.Component {
|
|||
},
|
||||
grid: {
|
||||
zeroLineColor: 'transparent',
|
||||
drawBorder: false,
|
||||
drawBorder: false
|
||||
}
|
||||
},
|
||||
yComparison: {
|
||||
min: 0,
|
||||
suggestedMax: calculateMaximumY(dataSet),
|
||||
display: false,
|
||||
grid: { display: false },
|
||||
grid: { display: false }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
callback: function (val, _index, _ticks) {
|
||||
if (this.getLabelForValue(val) == "__blank__") return ""
|
||||
if (this.getLabelForValue(val) == '__blank__') return ''
|
||||
|
||||
const hasMultipleYears =
|
||||
graphData.labels
|
||||
.filter((date) => typeof date === 'string')
|
||||
.map(date => date.split('-')[0])
|
||||
.filter((value, index, list) => list.indexOf(value) === index)
|
||||
.length > 1
|
||||
.map((date) => date.split('-')[0])
|
||||
.filter(
|
||||
(value, index, list) => list.indexOf(value) === index
|
||||
).length > 1
|
||||
|
||||
if (graphData.interval === 'hour' && query.period !== 'day') {
|
||||
const date = dateFormatter({
|
||||
interval: "day",
|
||||
interval: 'day',
|
||||
longForm: false,
|
||||
period: query.period,
|
||||
shouldShowYear: hasMultipleYears,
|
||||
shouldShowYear: hasMultipleYears
|
||||
})(this.getLabelForValue(val))
|
||||
|
||||
const hour = dateFormatter({
|
||||
interval: graphData.interval,
|
||||
longForm: false,
|
||||
period: query.period,
|
||||
shouldShowYear: hasMultipleYears,
|
||||
shouldShowYear: hasMultipleYears
|
||||
})(this.getLabelForValue(val))
|
||||
|
||||
// Returns a combination of date and hour. This is because
|
||||
|
|
@ -125,14 +132,22 @@ class LineGraph extends React.Component {
|
|||
return `${date}, ${hour}`
|
||||
}
|
||||
|
||||
if (graphData.interval === 'minute' && query.period !== 'realtime') {
|
||||
if (
|
||||
graphData.interval === 'minute' &&
|
||||
query.period !== 'realtime'
|
||||
) {
|
||||
return dateFormatter({
|
||||
interval: "hour", longForm: false, period: query.period,
|
||||
interval: 'hour',
|
||||
longForm: false,
|
||||
period: query.period
|
||||
})(this.getLabelForValue(val))
|
||||
}
|
||||
|
||||
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))
|
||||
},
|
||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
||||
|
|
@ -141,73 +156,73 @@ class LineGraph extends React.Component {
|
|||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
repositionTooltip(e) {
|
||||
const tooltipEl = document.getElementById('chartjs-tooltip');
|
||||
const tooltipEl = document.getElementById('chartjs-tooltip')
|
||||
if (tooltipEl && window.innerWidth >= 768) {
|
||||
if (e.clientX > 0.66 * window.innerWidth) {
|
||||
tooltipEl.style.right = (window.innerWidth - e.clientX) + window.pageXOffset + 'px'
|
||||
tooltipEl.style.left = null;
|
||||
tooltipEl.style.right =
|
||||
window.innerWidth - e.clientX + window.pageXOffset + 'px'
|
||||
tooltipEl.style.left = null
|
||||
} else {
|
||||
tooltipEl.style.right = null;
|
||||
tooltipEl.style.right = null
|
||||
tooltipEl.style.left = e.clientX + window.pageXOffset + 'px'
|
||||
}
|
||||
tooltipEl.style.top = e.clientY + window.pageYOffset + 'px'
|
||||
tooltipEl.style.opacity = 1;
|
||||
tooltipEl.style.opacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.graphData) {
|
||||
this.chart = this.regenerateChart();
|
||||
this.chart = this.regenerateChart()
|
||||
}
|
||||
window.addEventListener('mousemove', this.repositionTooltip);
|
||||
window.addEventListener('mousemove', this.repositionTooltip)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { graphData, darkTheme } = this.props;
|
||||
const tooltip = document.getElementById('chartjs-tooltip');
|
||||
const { graphData, darkTheme } = this.props
|
||||
const tooltip = document.getElementById('chartjs-tooltip')
|
||||
|
||||
if (
|
||||
graphData !== prevProps.graphData ||
|
||||
darkTheme !== prevProps.darkTheme
|
||||
) {
|
||||
|
||||
if (graphData) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart.destroy()
|
||||
}
|
||||
this.chart = this.regenerateChart();
|
||||
this.chart.update();
|
||||
this.chart = this.regenerateChart()
|
||||
this.chart.update()
|
||||
}
|
||||
|
||||
if (tooltip) {
|
||||
tooltip.style.display = 'none';
|
||||
tooltip.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (!graphData) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart.destroy()
|
||||
}
|
||||
|
||||
if (tooltip) {
|
||||
tooltip.style.display = 'none';
|
||||
tooltip.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// 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) {
|
||||
tooltip.style.opacity = 0;
|
||||
tooltip.style.display = 'none';
|
||||
tooltip.style.opacity = 0
|
||||
tooltip.style.display = 'none'
|
||||
}
|
||||
window.removeEventListener('mousemove', this.repositionTooltip)
|
||||
}
|
||||
|
|
@ -222,8 +237,12 @@ class LineGraph extends React.Component {
|
|||
}
|
||||
|
||||
maybeHopToHoveredPeriod(e) {
|
||||
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
|
||||
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
|
||||
const element = this.chart.getElementsAtEventForMode(e, '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') {
|
||||
this.props.navigate({
|
||||
|
|
@ -238,7 +257,9 @@ class LineGraph extends React.Component {
|
|||
|
||||
render() {
|
||||
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 (
|
||||
<FadeIn show={graphData}>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
import React from "react"
|
||||
import React from 'react'
|
||||
|
||||
export default function SamplingNotice({ topStatData }) {
|
||||
const samplePercent = topStatData?.samplePercent
|
||||
|
||||
if (samplePercent && samplePercent < 100) {
|
||||
return (
|
||||
<div tooltip={`Stats based on a ${samplePercent}% sample of all visitors`} 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" />
|
||||
<div
|
||||
tooltip={`Stats based on a ${samplePercent}% sample of all visitors`}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import React, { useState } from "react";
|
||||
import * as api from '../../api';
|
||||
import { getCurrentInterval } from "./interval-picker";
|
||||
import { useSiteContext } from "../../site-context";
|
||||
import { useQueryContext } from "../../query-context";
|
||||
import React, { useState } from 'react'
|
||||
import * as api from '../../api'
|
||||
import { getCurrentInterval } from './interval-picker'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
|
||||
export default function StatsExport() {
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
function startExport() {
|
||||
setExporting(true)
|
||||
document.cookie = "exporting="
|
||||
document.cookie = 'exporting='
|
||||
pollExportReady()
|
||||
}
|
||||
|
||||
|
|
@ -25,21 +25,48 @@ export default function StatsExport() {
|
|||
|
||||
function renderLoading() {
|
||||
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">
|
||||
<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
|
||||
className="animate-spin h-4 w-4 text-indigo-500"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function renderExportLink() {
|
||||
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}`
|
||||
|
||||
return (
|
||||
<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>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { Tooltip } from '../../util/tooltip'
|
||||
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as api from '../../api';
|
||||
import * as storage from '../../util/storage';
|
||||
import TopStats from './top-stats';
|
||||
import { IntervalPicker, getCurrentInterval } from './interval-picker';
|
||||
import StatsExport from './stats-export';
|
||||
import WithImportedSwitch from './with-imported-switch';
|
||||
import SamplingNotice from './sampling-notice';
|
||||
import FadeIn from '../../fade-in';
|
||||
import * as url from '../../util/url';
|
||||
import { isComparisonEnabled } from '../../query-time-periods';
|
||||
import LineGraphWithRouter from './line-graph';
|
||||
import { useQueryContext } from '../../query-context';
|
||||
import { useSiteContext } from '../../site-context';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import * as api from '../../api'
|
||||
import * as storage from '../../util/storage'
|
||||
import TopStats from './top-stats'
|
||||
import { IntervalPicker, getCurrentInterval } from './interval-picker'
|
||||
import StatsExport from './stats-export'
|
||||
import WithImportedSwitch from './with-imported-switch'
|
||||
import SamplingNotice from './sampling-notice'
|
||||
import FadeIn from '../../fade-in'
|
||||
import * as url from '../../util/url'
|
||||
import { isComparisonEnabled } from '../../query-time-periods'
|
||||
import LineGraphWithRouter from './line-graph'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
function fetchTopStats(site, query) {
|
||||
|
|
@ -31,11 +31,12 @@ function fetchMainGraph(site, query, metric, interval) {
|
|||
}
|
||||
|
||||
export default function VisitorGraph({ updateImportedDataInView }) {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -50,18 +51,23 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
// long as new graph data is fetched.
|
||||
const [graphRefreshing, setGraphRefreshing] = useState(false)
|
||||
|
||||
|
||||
const onIntervalUpdate = useCallback((newInterval) => {
|
||||
const onIntervalUpdate = useCallback(
|
||||
(newInterval) => {
|
||||
setGraphData(null)
|
||||
setGraphRefreshing(true)
|
||||
fetchGraphData(getStoredMetric(), newInterval)
|
||||
}, [query])
|
||||
},
|
||||
[query]
|
||||
)
|
||||
|
||||
const onMetricUpdate = useCallback((newMetric) => {
|
||||
const onMetricUpdate = useCallback(
|
||||
(newMetric) => {
|
||||
setGraphData(null)
|
||||
setGraphRefreshing(true)
|
||||
fetchGraphData(newMetric, getCurrentInterval(site, query))
|
||||
}, [query])
|
||||
},
|
||||
[query]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setTopStatData(null)
|
||||
|
|
@ -80,7 +86,9 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
if (topStatData) { storeTopStatsContainerHeight() }
|
||||
if (topStatData) {
|
||||
storeTopStatsContainerHeight()
|
||||
}
|
||||
}, [topStatData])
|
||||
|
||||
async function fetchTopStatsAndGraphData() {
|
||||
|
|
@ -107,8 +115,7 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
}
|
||||
|
||||
function fetchGraphData(metric, interval) {
|
||||
fetchMainGraph(site, query, metric, interval)
|
||||
.then((res) => {
|
||||
fetchMainGraph(site, query, metric, interval).then((res) => {
|
||||
setGraphData(res)
|
||||
setGraphLoading(false)
|
||||
setGraphRefreshing(false)
|
||||
|
|
@ -120,7 +127,10 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
}
|
||||
|
||||
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
|
||||
|
|
@ -136,16 +146,25 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
}
|
||||
|
||||
function importedSwitchVisible() {
|
||||
return !!topStatData?.with_imported_switch && topStatData?.with_imported_switch.visible
|
||||
return (
|
||||
!!topStatData?.with_imported_switch &&
|
||||
topStatData?.with_imported_switch.visible
|
||||
)
|
||||
}
|
||||
|
||||
function renderImportedIntervalUnsupportedWarning() {
|
||||
const unsupportedInterval = ['hour', 'minute'].includes(getCurrentInterval(site, query))
|
||||
const showingImported = importedSwitchVisible() && query.with_imported === true
|
||||
const unsupportedInterval = ['hour', 'minute'].includes(
|
||||
getCurrentInterval(site, query)
|
||||
)
|
||||
const showingImported =
|
||||
importedSwitchVisible() && query.with_imported === true
|
||||
|
||||
return (
|
||||
<FadeIn show={showingImported && unsupportedInterval} className="h-6 mr-1">
|
||||
<span tooltip={"Interval is too short to graph imported data"}>
|
||||
<FadeIn
|
||||
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" />
|
||||
</span>
|
||||
</FadeIn>
|
||||
|
|
@ -153,10 +172,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
}
|
||||
|
||||
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()}
|
||||
<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
|
||||
graphableMetrics={topStatData?.graphable_metrics || []}
|
||||
data={topStatData}
|
||||
|
|
@ -170,16 +198,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
|
|||
{renderImportedIntervalUnsupportedWarning()}
|
||||
{!isRealtime && <StatsExport />}
|
||||
<SamplingNotice samplePercent={topStatData} />
|
||||
{importedSwitchVisible() &&
|
||||
{importedSwitchVisible() && (
|
||||
<WithImportedSwitch
|
||||
tooltipMessage={topStatData.with_imported_switch.tooltip_msg}
|
||||
disabled={!topStatData.with_imported_switch.togglable}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
<IntervalPicker onIntervalUpdate={onIntervalUpdate} />
|
||||
</div>
|
||||
<LineGraphWithRouter
|
||||
graphData={{...graphData, interval: getCurrentInterval(site, query)}}
|
||||
graphData={{
|
||||
...graphData,
|
||||
interval: getCurrentInterval(site, query)
|
||||
}}
|
||||
darkTheme={isDarkTheme}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
import React from "react"
|
||||
import React from 'react'
|
||||
import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
|
||||
import classNames from "classnames"
|
||||
import { useQueryContext } from "../../query-context"
|
||||
import { AppNavigationLink } from "../../navigation/use-app-navigate"
|
||||
import classNames from 'classnames'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { AppNavigationLink } from '../../navigation/use-app-navigate'
|
||||
|
||||
export default function WithImportedSwitch({ tooltipMessage, disabled }) {
|
||||
const { query } = useQueryContext();
|
||||
const importsSwitchedOn = query.with_imported;
|
||||
const { query } = useQueryContext()
|
||||
const importsSwitchedOn = query.with_imported
|
||||
|
||||
const iconClass = classNames("mt-0.5", {
|
||||
"dark:text-gray-300 text-gray-700": importsSwitchedOn,
|
||||
"dark:text-gray-500 text-gray-400": !importsSwitchedOn,
|
||||
const iconClass = classNames('mt-0.5', {
|
||||
'dark:text-gray-300 text-gray-700': importsSwitchedOn,
|
||||
'dark:text-gray-500 text-gray-400': !importsSwitchedOn
|
||||
})
|
||||
|
||||
return (
|
||||
<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} />
|
||||
</AppNavigationLink>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import React from "react";
|
||||
import React from 'react'
|
||||
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
||||
import FadeIn from "../fade-in";
|
||||
import { useQueryContext } from "../query-context";
|
||||
import FadeIn from '../fade-in'
|
||||
import { useQueryContext } from '../query-context'
|
||||
|
||||
export default function ImportedQueryUnsupportedWarning({ loading, skipImportedReason, 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'
|
||||
export default function ImportedQueryUnsupportedWarning({
|
||||
loading,
|
||||
skipImportedReason,
|
||||
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) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React from 'react'
|
||||
|
||||
export const GeolocationNotice = () => {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
import * as storage from '../../util/storage';
|
||||
import CountriesMap from './map';
|
||||
import * as storage from '../../util/storage'
|
||||
import CountriesMap from './map'
|
||||
|
||||
import * as api from '../../api';
|
||||
import { apiPath } from '../../util/url';
|
||||
import ListReport from '../reports/list';
|
||||
import * as metrics from '../reports/metrics';
|
||||
import { hasConversionGoalFilter, getFiltersByKeyPrefix } from '../../util/filters';
|
||||
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning';
|
||||
import { citiesRoute, countriesRoute, regionsRoute } from '../../router';
|
||||
import { useQueryContext } from '../../query-context';
|
||||
import { useSiteContext } from '../../site-context';
|
||||
import * as api from '../../api'
|
||||
import { apiPath } from '../../util/url'
|
||||
import ListReport from '../reports/list'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import {
|
||||
hasConversionGoalFilter,
|
||||
getFiltersByKeyPrefix
|
||||
} from '../../util/filters'
|
||||
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 fetchData() {
|
||||
|
|
@ -24,8 +27,8 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
|
||||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: "country",
|
||||
filter: ["is", "country", [listItem['code']]],
|
||||
prefix: 'country',
|
||||
filter: ['is', 'country', [listItem['code']]],
|
||||
labels: { [listItem['code']]: listItem['name'] }
|
||||
}
|
||||
}
|
||||
|
|
@ -33,8 +36,8 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
].filter(metric => !!metric)
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -45,7 +48,10 @@ function Countries({ query, site, onClick, afterFetchData }) {
|
|||
onClick={onClick}
|
||||
keyLabel="Country"
|
||||
metrics={chooseMetrics()}
|
||||
detailsLinkProps={{ path: countriesRoute.path, search: (search) => search }}
|
||||
detailsLinkProps={{
|
||||
path: countriesRoute.path,
|
||||
search: (search) => search
|
||||
}}
|
||||
renderIcon={renderIcon}
|
||||
color="bg-orange-50"
|
||||
/>
|
||||
|
|
@ -63,8 +69,8 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||
|
||||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: "region",
|
||||
filter: ["is", "region", [listItem['code']]],
|
||||
prefix: 'region',
|
||||
filter: ['is', 'region', [listItem['code']]],
|
||||
labels: { [listItem['code']]: listItem['name'] }
|
||||
}
|
||||
}
|
||||
|
|
@ -72,8 +78,8 @@ function Regions({ query, site, onClick, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
].filter(metric => !!metric)
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -102,8 +108,8 @@ function Cities({ query, site, afterFetchData }) {
|
|||
|
||||
function getFilterInfo(listItem) {
|
||||
return {
|
||||
prefix: "city",
|
||||
filter: ["is", "city", [listItem['code']]],
|
||||
prefix: 'city',
|
||||
filter: ['is', 'city', [listItem['code']]],
|
||||
labels: { [listItem['code']]: listItem['name'] }
|
||||
}
|
||||
}
|
||||
|
|
@ -111,8 +117,8 @@ function Cities({ query, site, afterFetchData }) {
|
|||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ meta: { plot: true } }),
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate(),
|
||||
].filter(metric => !!metric)
|
||||
hasConversionGoalFilter(query) && metrics.createConversionRate()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -129,11 +135,10 @@ function Cities({ query, site, afterFetchData }) {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
const labelFor = {
|
||||
'countries': 'Countries',
|
||||
'regions': 'Regions',
|
||||
'cities': 'Cities',
|
||||
countries: 'Countries',
|
||||
regions: 'Regions',
|
||||
cities: 'Cities'
|
||||
}
|
||||
|
||||
class Locations extends React.Component {
|
||||
|
|
@ -153,8 +158,10 @@ class Locations extends React.Component {
|
|||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const isRemovingFilter = (filterName) => {
|
||||
return getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 &&
|
||||
return (
|
||||
getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 &&
|
||||
getFiltersByKeyPrefix(this.props.query, filterName).length == 0
|
||||
)
|
||||
}
|
||||
|
||||
if (this.state.mode === 'cities' && isRemovingFilter('region')) {
|
||||
|
|
@ -165,7 +172,10 @@ class Locations extends React.Component {
|
|||
this.setMode(this.countriesRestoreMode || 'countries')()
|
||||
}
|
||||
|
||||
if (this.props.query !== prevProps.query || this.state.mode !== prevState.mode) {
|
||||
if (
|
||||
this.props.query !== prevProps.query ||
|
||||
this.state.mode !== prevState.mode
|
||||
) {
|
||||
this.setState({ loading: true })
|
||||
}
|
||||
}
|
||||
|
|
@ -189,20 +199,48 @@ class Locations extends React.Component {
|
|||
}
|
||||
|
||||
afterFetchData(apiResponse) {
|
||||
this.setState({ loading: false, skipImportedReason: apiResponse.skip_imported_reason })
|
||||
this.setState({
|
||||
loading: false,
|
||||
skipImportedReason: apiResponse.skip_imported_reason
|
||||
})
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
switch (this.state.mode) {
|
||||
case "cities":
|
||||
return <Cities site={this.props.site} query={this.props.query} 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":
|
||||
case 'cities':
|
||||
return (
|
||||
<Cities
|
||||
site={this.props.site}
|
||||
query={this.props.query}
|
||||
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} />
|
||||
return (
|
||||
<CountriesMap
|
||||
onCountrySelect={this.onCountryFilter('map')}
|
||||
afterFetchData={this.afterFetchData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,9 +249,7 @@ class Locations extends React.Component {
|
|||
|
||||
if (isActive) {
|
||||
return (
|
||||
<button
|
||||
className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading"
|
||||
>
|
||||
<button className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading">
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
|
|
@ -237,7 +273,10 @@ class Locations extends React.Component {
|
|||
<h3 className="font-bold dark:text-gray-100">
|
||||
{labelFor[this.state.mode] || 'Locations'}
|
||||
</h3>
|
||||
<ImportedQueryUnsupportedWarning loading={this.state.loading} skipImportedReason={this.state.skipImportedReason} />
|
||||
<ImportedQueryUnsupportedWarning
|
||||
loading={this.state.loading}
|
||||
skipImportedReason={this.state.skipImportedReason}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||
{this.renderPill('Map', 'map')}
|
||||
|
|
@ -253,8 +292,8 @@ class Locations extends React.Component {
|
|||
}
|
||||
|
||||
function LocationsWithContext() {
|
||||
const { query } = useQueryContext();
|
||||
const site = useSiteContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
return <Locations site={site} query={query} />
|
||||
}
|
||||
export default LocationsWithContext
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import classNames from 'classnames'
|
||||
import React from 'react'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* @format */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import classNames from 'classnames'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { useState, ReactNode, useMemo } from 'react'
|
||||
|
||||
import { useQueryContext } from '../../query-context'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/** @format */
|
||||
|
||||
import React, { ReactNode } from 'react'
|
||||
|
||||
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 BreakdownModal from "./breakdown-modal";
|
||||
import * as metrics from "../reports/metrics";
|
||||
import * as url from '../../util/url';
|
||||
import { useSiteContext } from "../../site-context";
|
||||
import { addFilter } from "../../query";
|
||||
import BreakdownModal from './breakdown-modal'
|
||||
import * as metrics from '../reports/metrics'
|
||||
import * as url from '../../util/url'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import { addFilter } from '../../query'
|
||||
|
||||
/*global BUILD_EXTRA*/
|
||||
function ConversionsModal() {
|
||||
const [showRevenue, setShowRevenue] = useState(false)
|
||||
const site = useSiteContext();
|
||||
const site = useSiteContext()
|
||||
|
||||
const reportInfo = {
|
||||
title: 'Goal Conversions',
|
||||
dimension: 'goal',
|
||||
endpoint: url.apiPath(site, '/conversions'),
|
||||
dimensionLabel: "Goal"
|
||||
dimensionLabel: 'Goal'
|
||||
}
|
||||
|
||||
const getFilterInfo = useCallback((listItem) => {
|
||||
const getFilterInfo = useCallback(
|
||||
(listItem) => {
|
||||
return {
|
||||
prefix: reportInfo.dimension,
|
||||
filter: ["is", reportInfo.dimension, [listItem.name]]
|
||||
filter: ['is', reportInfo.dimension, [listItem.name]]
|
||||
}
|
||||
}, [reportInfo.dimension])
|
||||
},
|
||||
[reportInfo.dimension]
|
||||
)
|
||||
|
||||
const addSearchFilter = useCallback((query, searchString) => {
|
||||
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
|
||||
}, [reportInfo.dimension])
|
||||
const addSearchFilter = useCallback(
|
||||
(query, searchString) => {
|
||||
return addFilter(query, [
|
||||
'contains',
|
||||
reportInfo.dimension,
|
||||
[searchString],
|
||||
{ case_sensitive: false }
|
||||
])
|
||||
},
|
||||
[reportInfo.dimension]
|
||||
)
|
||||
|
||||
function chooseMetrics() {
|
||||
return [
|
||||
metrics.createVisitors({ renderLabel: (_query) => "Uniques" }),
|
||||
metrics.createEvents({ renderLabel: (_query) => "Total" }),
|
||||
metrics.createVisitors({ renderLabel: (_query) => 'Uniques' }),
|
||||
metrics.createEvents({ renderLabel: (_query) => 'Total' }),
|
||||
metrics.createConversionRate(),
|
||||
showRevenue && metrics.createAverageRevenue(),
|
||||
showRevenue && metrics.createTotalRevenue(),
|
||||
].filter(metric => !!metric)
|
||||
showRevenue && metrics.createTotalRevenue()
|
||||
].filter((metric) => !!metric)
|
||||
}
|
||||
|
||||
// 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
|
||||
// `false` as revenue metrics might exist in previously loaded data.
|
||||
const afterFetchNextPage = useCallback((res) => {
|
||||
if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) }
|
||||
}, [showRevenue])
|
||||
const afterFetchNextPage = useCallback(
|
||||
(res) => {
|
||||
if (!showRevenue && revenueInResponse(res)) {
|
||||
setShowRevenue(true)
|
||||
}
|
||||
},
|
||||
[showRevenue]
|
||||
)
|
||||
|
||||
function revenueInResponse(apiResponse) {
|
||||
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