|
| 1 | +import React, { useState, useEffect } from 'react' |
| 2 | +import * as api from '../api' |
| 3 | +import * as url from '../util/url' |
| 4 | +import { useDebounce } from '../custom-hooks' |
| 5 | +import { useSiteContext } from '../site-context' |
| 6 | +import { useDashboardStateContext } from '../dashboard-state-context' |
| 7 | +import { numberShortFormatter } from '../util/number-formatter' |
| 8 | + |
| 9 | +const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page'] |
| 10 | + |
| 11 | +function fetchColumnData(site, dashboardState, steps, filter) { |
| 12 | + // Page filters only apply to the first step — strip them for subsequent columns |
| 13 | + const stateToUse = |
| 14 | + steps.length > 0 |
| 15 | + ? { |
| 16 | + ...dashboardState, |
| 17 | + filters: dashboardState.filters.filter( |
| 18 | + ([_op, key]) => !PAGE_FILTER_KEYS.includes(key) |
| 19 | + ) |
| 20 | + } |
| 21 | + : dashboardState |
| 22 | + |
| 23 | + const journey = [] |
| 24 | + if (steps.length > 0) { |
| 25 | + for (const s of steps) { |
| 26 | + journey.push({ name: s.name, pathname: s.pathname }) |
| 27 | + } |
| 28 | + } |
| 29 | + |
| 30 | + return api.get(url.apiPath(site, '/exploration/next'), stateToUse, { |
| 31 | + journey: JSON.stringify(journey), |
| 32 | + search_term: filter |
| 33 | + }) |
| 34 | +} |
| 35 | + |
| 36 | +function ExplorationColumn({ |
| 37 | + header, |
| 38 | + steps, |
| 39 | + selected, |
| 40 | + onSelect, |
| 41 | + dashboardState |
| 42 | +}) { |
| 43 | + const site = useSiteContext() |
| 44 | + const [loading, setLoading] = useState(steps !== null) |
| 45 | + const [results, setResults] = useState([]) |
| 46 | + const [filter, setFilter] = useState('') |
| 47 | + |
| 48 | + const debouncedOnSearchInputChange = useDebounce((event) => |
| 49 | + setFilter(event.target.value) |
| 50 | + ) |
| 51 | + |
| 52 | + useEffect(() => { |
| 53 | + if (steps === null) { |
| 54 | + setFilter('') |
| 55 | + setResults([]) |
| 56 | + setLoading(false) |
| 57 | + return |
| 58 | + } |
| 59 | + |
| 60 | + setLoading(true) |
| 61 | + setResults([]) |
| 62 | + |
| 63 | + fetchColumnData(site, dashboardState, steps, filter) |
| 64 | + .then((response) => { |
| 65 | + setResults(response || []) |
| 66 | + }) |
| 67 | + .catch(() => { |
| 68 | + setResults([]) |
| 69 | + }) |
| 70 | + .finally(() => { |
| 71 | + setLoading(false) |
| 72 | + }) |
| 73 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 74 | + }, [dashboardState, steps, filter]) |
| 75 | + |
| 76 | + const maxVisitors = results.length > 0 ? results[0].visitors : 1 |
| 77 | + |
| 78 | + return ( |
| 79 | + <div className="flex-1 min-w-0 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> |
| 80 | + <div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between"> |
| 81 | + <span className="text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400 uppercase"> |
| 82 | + {header} |
| 83 | + </span> |
| 84 | + {!selected && steps !== null && ( |
| 85 | + <input |
| 86 | + data-testid="search-input" |
| 87 | + type="text" |
| 88 | + defaultValue={filter} |
| 89 | + placeholder="Search" |
| 90 | + onChange={debouncedOnSearchInputChange} |
| 91 | + className="peer w-32 text-sm dark:text-gray-100 block border-gray-300 dark:border-gray-750 rounded-md dark:bg-gray-750 dark:placeholder:text-gray-400 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500" |
| 92 | + /> |
| 93 | + )} |
| 94 | + {selected && ( |
| 95 | + <button |
| 96 | + onClick={() => onSelect(null)} |
| 97 | + className="text-xs text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-200" |
| 98 | + > |
| 99 | + Clear |
| 100 | + </button> |
| 101 | + )} |
| 102 | + </div> |
| 103 | + |
| 104 | + {loading ? ( |
| 105 | + <div className="flex items-center justify-center h-48"> |
| 106 | + <div className="mx-auto loading pt-4"> |
| 107 | + <div></div> |
| 108 | + </div> |
| 109 | + </div> |
| 110 | + ) : results.length === 0 ? ( |
| 111 | + <div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500"> |
| 112 | + {steps === null ? 'Select an event to continue' : 'No data'} |
| 113 | + </div> |
| 114 | + ) : ( |
| 115 | + <ul className="divide-y divide-gray-100 dark:divide-gray-700"> |
| 116 | + {(selected |
| 117 | + ? results.filter( |
| 118 | + ({ step }) => |
| 119 | + step.name === selected.name && |
| 120 | + step.pathname === selected.pathname |
| 121 | + ) |
| 122 | + : results.slice(0, 10) |
| 123 | + ).map(({ step, visitors }) => { |
| 124 | + const label = `${step.name} ${step.pathname}` |
| 125 | + const pct = Math.round((visitors / maxVisitors) * 100) |
| 126 | + const isSelected = |
| 127 | + !!selected && |
| 128 | + step.name === selected.name && |
| 129 | + step.pathname === selected.pathname |
| 130 | + |
| 131 | + return ( |
| 132 | + <li key={label}> |
| 133 | + <button |
| 134 | + className={`w-full text-left px-4 py-2 text-sm transition-colors focus:outline-none ${ |
| 135 | + isSelected |
| 136 | + ? 'bg-indigo-50 dark:bg-indigo-900/30' |
| 137 | + : 'hover:bg-gray-50 dark:hover:bg-gray-800' |
| 138 | + }`} |
| 139 | + onClick={() => onSelect(isSelected ? null : step)} |
| 140 | + > |
| 141 | + <div className="flex items-center justify-between mb-1"> |
| 142 | + <span |
| 143 | + className={`truncate font-medium ${ |
| 144 | + isSelected |
| 145 | + ? 'text-indigo-700 dark:text-indigo-300' |
| 146 | + : 'text-gray-800 dark:text-gray-200' |
| 147 | + }`} |
| 148 | + title={label} |
| 149 | + > |
| 150 | + {label} |
| 151 | + </span> |
| 152 | + <span className="ml-2 shrink-0 text-gray-500 dark:text-gray-400 tabular-nums"> |
| 153 | + {numberShortFormatter(visitors)} |
| 154 | + </span> |
| 155 | + </div> |
| 156 | + <div className="h-1 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden"> |
| 157 | + <div |
| 158 | + className={`h-full rounded-full ${ |
| 159 | + isSelected |
| 160 | + ? 'bg-indigo-500' |
| 161 | + : 'bg-indigo-300 dark:bg-indigo-600' |
| 162 | + }`} |
| 163 | + style={{ width: `${pct}%` }} |
| 164 | + /> |
| 165 | + </div> |
| 166 | + </button> |
| 167 | + </li> |
| 168 | + ) |
| 169 | + })} |
| 170 | + </ul> |
| 171 | + )} |
| 172 | + </div> |
| 173 | + ) |
| 174 | +} |
| 175 | + |
| 176 | +function columnHeader(index) { |
| 177 | + if (index === 0) return 'Start' |
| 178 | + return `${index} step${index === 1 ? '' : 's'} after` |
| 179 | +} |
| 180 | + |
| 181 | +export function FunnelExploration() { |
| 182 | + const { dashboardState } = useDashboardStateContext() |
| 183 | + const [steps, setSteps] = useState([]) |
| 184 | + |
| 185 | + function handleSelect(columnIndex, selected) { |
| 186 | + if (selected === null) { |
| 187 | + setSteps(steps.slice(0, columnIndex)) |
| 188 | + } else { |
| 189 | + setSteps([...steps.slice(0, columnIndex), selected]) |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + const numColumns = Math.max(steps.length + 1, 3) |
| 194 | + |
| 195 | + return ( |
| 196 | + <div className="p-4"> |
| 197 | + <h4 className="mt-2 mb-4 text-base font-semibold dark:text-gray-100"> |
| 198 | + Explore user journeys |
| 199 | + </h4> |
| 200 | + <div className="flex gap-3"> |
| 201 | + {Array.from({ length: numColumns }, (_, i) => ( |
| 202 | + <ExplorationColumn |
| 203 | + key={i} |
| 204 | + header={columnHeader(i)} |
| 205 | + steps={steps.length >= i ? steps.slice(0, i) : null} |
| 206 | + selected={steps[i] || null} |
| 207 | + onSelect={(selected) => handleSelect(i, selected)} |
| 208 | + dashboardState={dashboardState} |
| 209 | + /> |
| 210 | + ))} |
| 211 | + </div> |
| 212 | + </div> |
| 213 | + ) |
| 214 | +} |
| 215 | + |
| 216 | +export default FunnelExploration |
0 commit comments