analytics/assets/js/dashboard/stats/behaviours/index.js

371 lines
12 KiB
JavaScript

import React, { Fragment, useState, useEffect, useCallback } 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 Properties from './props'
import { FeatureSetupNotice } from '../../components/notice'
import { hasGoalFilter } from '../../util/filters'
import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context'
import { useUserContext } from '../../user-context'
/*global BUILD_EXTRA*/
/*global require*/
function maybeRequire() {
if (BUILD_EXTRA) {
return require('../../extra/funnel')
} else {
return { default: null }
}
}
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 DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
export const CONVERSIONS = 'conversions'
export const PROPS = 'props'
export const FUNNELS = 'funnels'
export const sectionTitles = {
[CONVERSIONS]: 'Goal Conversions',
[PROPS]: 'Custom Properties',
[FUNNELS]: 'Funnels'
}
export default function Behaviours({ importedDataInView }) {
const { query } = useQueryContext();
const site = useSiteContext();
const user = useUserContext();
const adminAccess = ['owner', 'admin', 'super_admin'].includes(user.role)
const tabKey = storage.getDomainScopedStorageKey('behavioursTab', 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 [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
const [skipImportedReason, setSkipImportedReason] = useState(null)
const onGoalFilterClick = useCallback((e) => {
const goalName = e.target.innerHTML
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
const isPageviewGoal = goalName.startsWith('Visit ')
if (!isSpecialGoal && !isPageviewGoal && enabledModes.includes(PROPS) && site.hasProps) {
setShowingPropsForGoalFilter(true)
setMode(PROPS)
}
}, [])
useEffect(() => {
const justRemovedGoalFilter = !hasGoalFilter(query)
if (mode === PROPS && justRemovedGoalFilter && showingPropsForGoalFilter) {
setShowingPropsForGoalFilter(false)
setMode(CONVERSIONS)
}
}, [hasGoalFilter(query)])
useEffect(() => {
setMode(defaultMode())
}, [enabledModes])
useEffect(() => setLoading(true), [query, mode])
function disableMode(mode) {
setEnabledModes(enabledModes.filter((m) => { return m !== mode }))
}
function setFunnel(selectedFunnel) {
return () => {
storage.setItem(tabKey, FUNNELS)
storage.setItem(funnelKey, selectedFunnel)
setMode(FUNNELS)
setSelectedFunnel(selectedFunnel)
}
}
function defaultSelectedFunnel() {
const stored = storage.getItem(funnelKey)
const storedExists = stored && site.funnels.some((f) => f.name === stored)
if (storedExists) {
return stored
} else if (site.funnels.length > 0) {
const firstAvailable = site.funnels[0].name
storage.setItem(funnelKey, firstAvailable)
return firstAvailable
}
}
function hasFunnels() {
return site.funnels.length > 0 && site.funnelsAvailable
}
function tabFunnelPicker() {
return <Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button 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>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="text-left origin-top-right absolute right-0 mt-2 w-96 max-h-72 overflow-auto rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="py-1">
{funnelNames.map((funnelName) => {
return (
<Menu.Item key={funnelName}>
{({ active }) => (
<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',
'block px-4 py-2 text-sm',
(mode === FUNNELS && selectedFunnel === funnelName) ? 'font-bold text-gray-500' : ''
)}
>
{funnelName}
</span>
)}
</Menu.Item>
)
})}
</div>
</Menu.Items>
</Transition>
</Menu>
}
function tabSwitcher(toMode, displayName) {
const className = classNames({ [ACTIVE_CLASS]: mode == toMode, [DEFAULT_CLASS]: mode !== toMode })
const setTab = () => {
storage.setItem(tabKey, toMode)
setMode(toMode)
}
return (
<div className={className} onClick={setTab}>
{displayName}
</div>
)
}
function tabs() {
return (
<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'))}
</div>
)
}
function afterFetchData(apiResponse) {
setLoading(false)
setSkipImportedReason(apiResponse.skip_imported_reason)
}
function renderConversions() {
if (site.hasGoals) {
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.'}
callToAction={{
action: "Set up goals",
link: `/${encodeURIComponent(site.domain)}/settings/goals`
}}
onHideAction={onHideAction(CONVERSIONS)}
/>
)
}
else { return noDataYet() }
}
function renderFunnels() {
if (Funnel === null) {
return featureUnavailable()
}
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
return <Funnel funnelName={selectedFunnel} />
}
else if (Funnel && adminAccess) {
let callToAction
if (site.funnelsAvailable) {
callToAction = { action: 'Set up funnels', link: `/${encodeURIComponent(site.domain)}/settings/funnels` }
} else {
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
}
return (
<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.'}
callToAction={callToAction}
onHideAction={onHideAction(FUNNELS)}
/>
)
}
else { return noDataYet() }
}
function renderProps() {
if (site.hasProps && site.propsAvailable) {
return <Properties afterFetchData={afterFetchData} />
} else if (adminAccess) {
let callToAction
if (site.propsAvailable) {
callToAction = { action: 'Set up props', link: `/${encodeURIComponent(site.domain)}/settings/properties` }
} else {
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
}
return (
<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.'}
callToAction={callToAction}
onHideAction={onHideAction(PROPS)}
/>
)
} else { return noDataYet() }
}
function noDataYet() {
return (
<div className="font-medium text-gray-500 dark:text-gray-400 py-12 text-center">
No data yet
</div>
)
}
function featureUnavailable() {
return (
<div className="font-medium text-gray-500 dark:text-gray-400 py-12 text-center">
This feature is unavailable
</div>
)
}
function onHideAction(mode) {
return () => { disableMode(mode) }
}
function renderContent() {
switch (mode) {
case CONVERSIONS:
return renderConversions()
case PROPS:
return renderProps()
case FUNNELS:
return renderFunnels()
}
}
function defaultMode() {
if (enabledModes.length === 0) { return null }
const storedMode = storage.getItem(tabKey)
if (storedMode && enabledModes.includes(storedMode)) { return storedMode }
if (enabledModes.includes(CONVERSIONS)) { return CONVERSIONS }
if (enabledModes.includes(PROPS)) { return PROPS }
return FUNNELS
}
function getEnabledModes() {
let enabledModes = []
for (const feature of Object.keys(sectionTitles)) {
const isOptedOut = site[feature + 'OptedOut']
const isAvailable = site[feature + 'Available'] !== false
// If the feature is not supported by the site owner's subscription,
// it only makes sense to display the feature tab to the owner itself
// as only they can upgrade to make the feature available.
const callToActionIsMissing = !isAvailable && user.role !== 'owner'
if (!isOptedOut && !callToActionIsMissing) {
enabledModes.push(feature)
}
}
return enabledModes
}
function isEnabled(mode) {
return enabledModes.includes(mode)
}
function isRealtime() {
return query.period === 'realtime'
}
function sectionTitle() {
if (mode === CONVERSIONS) {
return specialTitleWhenGoalFilter(query, sectionTitles[mode])
} else {
return sectionTitles[mode]
}
}
function renderImportedQueryUnsupportedWarning() {
if (mode === CONVERSIONS) {
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
} else if (mode === PROPS) {
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" />
}
}
if (mode) {
return (
<div className="items-start justify-between block w-full mt-6 md:flex">
<div className="w-full p-4 bg-white rounded shadow-xl dark:bg-gray-825">
<div className="flex justify-between w-full">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">
{sectionTitle() + (isRealtime() ? ' (last 30min)' : '')}
</h3>
{renderImportedQueryUnsupportedWarning()}
</div>
{tabs()}
</div>
{renderContent()}
</div>
</div>
)
} else {
return null
}
}