import { AnimatePresence, animate, motion, useReducedMotion } from 'motion/react'; import * as React from 'react'; import { __ } from '@wordpress/i18n'; import { usePersistentState } from '../../../data/hooks'; import { Duration, StatGroup } from '../../../utils/admin'; import { AnimatedNumber } from './AnimatedNumber'; import { prettyLabel } from './prettyLabel'; import { StatTile } from './StatTile'; import { useBreakdownStats } from './useBreakdownStats'; import { CompactSelect } from '@/components/CompactSelect'; import { Caption, Overline } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; import type { DataState } from '@/types/component-states'; const LIST_LIMIT = 10; // Brand color (#9332FF) for the rank-up pulse. Hardcoded because motion's // imperative animate sets inline style which overrides the tailwind // `bg-brand/10` class. Keep in sync with `--color-brand`. const PULSE_KEYFRAMES = [ 'rgba(147, 50, 255, 0.1)', 'rgba(147, 50, 255, 0.32)', 'rgba(147, 50, 255, 0.1)', ]; interface Props { period: Duration; realtimeTick?: number; } function topN( group: StatGroup | undefined, n = LIST_LIMIT ): { key: string; value: number }[] { if ( ! group ) { return []; } return Object.entries( group ) .map( ( [ key, value ] ) => ( { key, value: Number( value ) || 0, } ) ) .sort( ( a, b ) => b.value - a.value ) .slice( 0, n ); } // Tweaks via motion: rows re-flow when their rank changes, bars width-tween, // numbers count up/down. Spring tuned for a calm wall-display rhythm — // firm but never wobbly. const ROW_TRANSITION = { layout: { type: 'spring', stiffness: 320, damping: 36, mass: 0.6, }, opacity: { duration: 0.28, ease: [ 0.2, 0, 0, 1 ], }, } as const; type Item = { key: string; value: number }; interface RowProps { item: Item; max: number; improvedAt: number; reduced: boolean | null; barTransition: object; } /** * Row sub-component. Built against the canonical row contract * documented in src/components/CLAUDE.md (h-8 bar IS the row, * bg-brand/15, label overlaid absolute left-2). Layers BreakdownPanel- * specific motion (rank pulse + micro-bounce) on top of the shared * silhouette. */ function Row( { item, max, improvedAt, reduced, barTransition }: RowProps ) { const barRef = React.useRef( null ); const rowRef = React.useRef( null ); const pct = item.value === 0 ? 0 : Math.max( 2, ( item.value / max ) * 100 ); React.useEffect( () => { if ( ! improvedAt || reduced ) { return; } // Bar pulse — brand brightens then settles back over 700ms // ease-out. Imperative because motion's keyframe array on a // prop forces the width animation to compete; isolating the // color animation keeps the width spring clean. if ( barRef.current ) { animate( barRef.current, { backgroundColor: PULSE_KEYFRAMES }, { duration: 0.7, ease: [ 0.2, 0, 0, 1 ], } ); } // Row micro-bounce — 2px lift then settle over 500ms. Paired // with the bar pulse it reads as "this one moved up". if ( rowRef.current ) { animate( rowRef.current, { y: [ 0, -2, 0 ] }, { duration: 0.5, ease: [ 0.2, 0, 0, 1 ], } ); } }, [ improvedAt, reduced ] ); return (

); } function HostList( { items }: { items: Item[] } ) { const reduced = useReducedMotion(); const max = Math.max( 1, ...items.map( i => i.value ) ); const barTransition = reduced ? { duration: 0 } : { type: 'spring' as const, stiffness: 260, damping: 32, mass: 0.7, }; // Track previous rank per key for asymmetric rank-up flourish. // improvedAt is a per-render snapshot used as a dependency in each // Row's useEffect — only rows that climbed get a non-zero value. const prevRanksRef = React.useRef>( new Map() ); const renderTs = Date.now(); const improvedAt = new Map(); items.forEach( ( item, idx ) => { const prev = prevRanksRef.current.get( item.key ); if ( prev !== undefined && prev > idx ) { improvedAt.set( item.key, renderTs ); } } ); // Commit new ranks AFTER render so the next render's comparison is // correct. No mutation during render. React.useEffect( () => { const next = new Map(); items.forEach( ( item, idx ) => next.set( item.key, idx ) ); prevRanksRef.current = next; } ); return ( { items.map( item => ( ) ) } ); } // Skeleton/empty preview that hints at the shape of the content. // Used both for `loading` and `empty` states so the user always sees the // layout they're about to get. function ListPreview( { dimmed = false }: { dimmed?: boolean } ) { // 10 placeholder bars match the 10-row content the card will hold // once data lands. Varied widths so the silhouette doesn't look // like a stack of identical bars. const widths = [ 90, 70, 80, 55, 65, 60, 45, 50, 40, 35 ]; return ( ); } function EmptyHint( { label }: { label: string } ) { return (
{ label }
); } function deriveListState( base: 'loading' | 'loaded' | 'empty' | 'error', count: number ): DataState { if ( base === 'error' || base === 'loading' ) { return base; } return count > 0 ? 'loaded' : 'empty'; } type UtmMode = 'source_campaign' | 'source_medium' | 'campaign'; const UTM_MODES: { value: UtmMode; label: string }[] = [ { value: 'source_campaign', label: __( 'source / campaign', 'altis' ), }, { value: 'source_medium', label: __( 'source / medium', 'altis' ), }, { value: 'campaign', label: __( 'campaign only', 'altis' ), }, ]; function pickUtmGroup( stats: ReturnType[ 'stats' ], mode: UtmMode ): StatGroup | undefined { const g = stats.data?.stats; if ( ! g ) { return undefined; } switch ( mode ) { case 'source_medium': return g.by_source_medium; case 'campaign': return g.by_campaign; case 'source_campaign': default: return g.by_source_campaign; } } export default function BreakdownPanel( { period, realtimeTick }: Props ) { const outboundEnabled = window.AltisAccelerateDashboardData?.outbound_tracking_enabled ?? false; const { stats, refresh } = useBreakdownStats( period, realtimeTick, outboundEnabled ); const [ utmMode, setUtmMode ] = usePersistentState( 'utm-card-mode', 'source_campaign' ); const referrers = topN( stats.data?.stats?.by_referer ); const utm = topN( pickUtmGroup( stats, utmMode ) ); const outbound = topN( stats.data?.stats?.by_outbound_host ); const refState = deriveListState( stats.state, referrers.length ); const utmState = deriveListState( stats.state, utm.length ); const outboundState: DataState = ! outboundEnabled ? 'empty' : deriveListState( stats.state, outbound.length ); const utmAction = ( setUtmMode( v as UtmMode ) } /> ); return (
} empty={ } skeleton={ } state={ refState } title={ __( 'Top referrers', 'altis' ) } onRetry={ refresh } /> } empty={ } skeleton={ } state={ utmState } title={ __( 'UTM', 'altis' ) } onRetry={ refresh } /> } empty={ outboundEnabled ? ( ) : (
{ __( 'Turn on in Settings to start collecting clicks.', 'altis' ) }
) } skeleton={ } state={ outboundState } title={ { __( 'Outbound clicks', 'altis' ) } { ! outboundEnabled && ( { __( 'Off', 'altis' ) } ) } } onRetry={ refresh } />
); }