import {useState, useEffect, useCallback, createContext, useContext, useRef, useMemo} from '@wordpress/element'; import {Spinner} from '@wordpress/components'; import ChartRenderer from './ChartRenderer'; import {addQueryArgs, getQueryArgs} from '@wordpress/url'; import FilterBar from './FilterBar'; import DataTable from './DataTable'; import './StatisticsApp.scss'; import {__} from '@wordpress/i18n'; import DrilldownStatus from './DrilldownStatus'; import {applyFilters} from '@wordpress/hooks'; import AppHeader from '../../../../admin/assets/header/src/AppHeader'; import TokenSelectField from '../../../../admin/assets/design/form-fields/TokenSelectField'; import MetricToggles from './MetricToggles'; import Button from '../../../../admin/assets/design/components/Button'; import CloseIcon from '../../../../admin/assets/design/icons/CloseIcon'; import TabNav from '../../../../admin/assets/design/components/TabNav'; const AnalyticsContext = createContext( null ); export const useAnalyticsContext = () => useContext( AnalyticsContext ); if ( typeof window !== 'undefined' ) { window.adpresso = window.adpresso || {}; window.adpresso.useAnalyticsContext = useAnalyticsContext; } const rawData = window.adpressoAnalyticsData || {}; const isPro = rawData.isPro === '1'; const {restUrl, apiNonce, defaultView, logoUrl} = rawData; // Compare modes of the Pro compare toggle; the `compare` URL/default-view param is ignored on Free. const COMPARE_MODES = ['previous_period', 'yoy']; // The saved default view as this tier sees it (Free ignores a saved Pro compare mode). const tierView = ( view ) => { if ( isPro || !view || !('compare' in view) ) { return view; } const {compare, ...rest} = view; return rest; }; /** * Wraps setters so a superseded request (filters changed while it ran) cannot overwrite newer data. * * @param {object} setters * @param {object} request - {current: bool}, set to false by the effect cleanup. * @returns {object} */ const guardSetters = ( setters, request ) => Object.fromEntries( Object.entries( setters ).map( ( [name, setter] ) => [name, ( value ) => request.current && setter( value )] ) ); /** * Compares to flat objects with each other. * * @param {object} objA * @param {object} objB * @returns {boolean} */ const areObjectsEqual = ( objA, objB ) => { // Check the case, that one of the objects is null (e.g. no default view set) if ( objA === null || objB === null ) { return objA === objB; } const keysA = Object.keys( objA ); const keysB = Object.keys( objB ); // They need to have the same number of keys. if ( keysA.length !== keysB.length ) { return false; } // They need to have the same values for their keys. for ( const key of keysA ) { if ( objA[key] !== objB[key] ) { return false; } } return true; }; // A real Y-m-d calendar date, or null. const parseYmd = ( value ) => { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec( typeof value === 'string' ? value : '' ); if ( !match ) { return null; } const date = new Date( Date.UTC( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ) ) ); return date.getUTCMonth() === Number( match[2] ) - 1 && date.getUTCDate() === Number( match[3] ) ? value : null; }; /** * A valid custom range ({start, end}, swapped if reversed), or null. */ export const normalizeCustomRange = ( start, end ) => { const from = parseYmd( start ); const to = parseYmd( end ); if ( !from || !to ) { return null; } return from <= to ? {start: from, end: to} : {start: to, end: from}; }; const buildCurrentParams = ( dateRange, grouping, viewMode, sorting, filters, compareMode = null, customStartDate = null, customEndDate = null ) => { const isCustom = dateRange === 'custom'; const currentParams = { range: dateRange, start_date: isCustom ? customStartDate : null, end_date: isCustom ? customEndDate : null, group_by: grouping, view_mode: viewMode, orderby: sorting.orderBy, order: sorting.order, filter_ads: filters.ads.join( ',' ), filter_groups: filters.groups.join( ',' ), filter_placements: filters.placements.join( ',' ), compare: isPro ? compareMode : null }; // Remove empty parameters for clean comparing. Object.keys( currentParams ).forEach( key => { if ( !currentParams[key] ) { delete currentParams[key]; } } ); return currentParams; }; /** * Keeps the sort column valid for a view: the date view sorts by date instead of name, entity views * by impressions instead of date. Metric sorts carry over. */ export const sortingForView = ( mode, sorting ) => { if ( mode === 'date' ) { return sorting.orderBy === 'name' ? {orderBy: 'date', order: 'asc'} : sorting; } return sorting.orderBy === 'date' ? {orderBy: 'impressions', order: 'desc'} : sorting; }; const getInitialStateFromURL = () => { const urlParams = getQueryArgs( window.location.search ); const isDateView = ( urlParams.view_mode || 'date' ) === 'date'; const parseArrayParam = ( param ) => (param ? param.split( ',' ) : []); let initRange = urlParams.range; if ( typeof initRange === 'object' ) { initRange = 'last_30_days'; } // A custom range only with valid dates; otherwise the server would show the last 30 days anyway. const customRange = initRange === 'custom' ? normalizeCustomRange( urlParams.start_date, urlParams.end_date ) : null; if ( initRange === 'custom' && !customRange ) { initRange = 'last_30_days'; } return { dateRange: initRange || 'last_30_days', customStartDate: customRange ? customRange.start : null, customEndDate: customRange ? customRange.end : null, grouping: urlParams.group_by || 'day', viewMode: urlParams.view_mode || 'date', sorting: { // The date view starts in date order, entity views by impressions. orderBy: urlParams.orderby || ( isDateView ? 'date' : 'impressions' ), order: urlParams.order || ( isDateView ? 'asc' : 'desc' ) }, filters: { ads: parseArrayParam( urlParams.filter_ads ), groups: parseArrayParam( urlParams.filter_groups ), placements: parseArrayParam( urlParams.filter_placements ) }, compareMode: isPro && COMPARE_MODES.includes( urlParams.compare ) ? urlParams.compare : null // visibleMetrics is local for the moment, but could be added here too. }; }; const StatisticsApp = () => { const [previousState, setPreviousState] = useState( null ); const [chartData, setChartData] = useState( [] ); const [tableData, setTableData] = useState( [] ); // Chart (date timeline) and table (current view mode) each get their own compare rows. const [chartCompareData, setChartCompareData] = useState( null ); const [tableCompareData, setTableCompareData] = useState( null ); const [isChartLoading, setIsChartLoading] = useState( true ); const [isTableLoading, setIsTableLoading] = useState( true ); const [visibleMetrics, setVisibleMetrics] = useState( ['impressions', 'clicks'] ); const [filterOptions, setFilterOptions] = useState( {ads: [], groups: [], placements: []} ); const initialState = getInitialStateFromURL(); const [dateRange, setDateRange] = useState( initialState.dateRange ); const [customStartDate, setCustomStartDate] = useState( initialState.customStartDate ); const [customEndDate, setCustomEndDate] = useState( initialState.customEndDate ); const [grouping, setGrouping] = useState( initialState.grouping ); const [viewMode, setViewMode] = useState( initialState.viewMode ); const [sorting, setSorting] = useState( initialState.sorting ); const [filters, setFilters] = useState( initialState.filters ); const [compareMode, setCompareMode] = useState( initialState.compareMode ); const [savedDefaultView, setSavedDefaultView] = useState( defaultView || null ); const contextValue = useMemo( () => ({ dateRange, customStartDate, customEndDate, filters, grouping, viewMode, compareMode, setCompareMode, apiNonce, restUrl }), [dateRange, customStartDate, customEndDate, filters, grouping, viewMode, compareMode, apiNonce, restUrl] ); // Components registered via this filter are rendered in the filter bar (Pro: the compare toggle). const filterBarComponents = useMemo( () => { const components = applyFilters( 'adpresso.analytics.filterBarComponents', [] ); return Array.isArray( components ) ? components : []; }, [] ); // Sections below the chart and table (Pro: the fallback counters). const sectionComponents = useMemo( () => { const sections = applyFilters( 'adpresso.analytics.sections', [] ); return Array.isArray( sections ) ? sections : []; }, [] ); // STICKY OBSERVER LOGIC --- const sentinelRef = useRef( null ); const filterBarRef = useRef( null ); const filtersRef = useRef( filters ); const isScrolledRef = useRef( false ); const updateStickyState = () => { const bar = filterBarRef.current; if ( !bar ) { return; } const currentFilters = filtersRef.current; // Check: do we have active filters. const hasActiveFilters = (currentFilters.ads && currentFilters.ads.length > 0) || (currentFilters.groups && currentFilters.groups.length > 0) || (currentFilters.placements && currentFilters.placements.length > 0); // Only sticky when scrolled AND filters are active. if ( isScrolledRef.current && hasActiveFilters ) { bar.classList.add( 'is-stuck' ); } else { bar.classList.remove( 'is-stuck' ); } }; const availableColumns = [ {key: 'impressions', label: __( 'Rendered Impressions', 'adpresso' )}, {key: 'clicks', label: __( 'Clicks', 'adpresso' )}, {key: 'ctr', label: __( 'CTR (%)', 'adpresso' )}, {key: 'viewability', label: __( 'Active View Rate (%)', 'adpresso' ), disabled: !isPro}, {key: 'impressions_av', label: __( 'Active View Impressions', 'adpresso' ), disabled: !isPro} ]; // Initial State: Set everything to true. const [visibleColumns, setVisibleColumns] = useState( { impressions: true, impressions_av: false, clicks: true, ctr: true, viewability: isPro } ); // Check sticky state. useEffect( () => { filtersRef.current = filters; updateStickyState(); }, [filters] ); useEffect( () => { const sentinelEl = sentinelRef.current; const filterBarEl = filterBarRef.current; if ( !sentinelEl || !filterBarEl ) { return; } const observer = new IntersectionObserver( ( entries ) => { const entry = entries[0]; // !isIntersecting means: Sentinel has been scrolled out. // Save the scroll status in the ref. isScrolledRef.current = !entry.isIntersecting; // Call the central logic. updateStickyState(); }, { root: null, threshold: 0, rootMargin: '-33px 0px 0px 0px' } ); observer.observe( sentinelEl ); return () => observer.disconnect(); }, [isChartLoading, isTableLoading] ); // Effect to write the state back in the URL. useEffect( () => { const currentUrlParams = getQueryArgs( window.location.search ); // Create the query object. const queryArgs = { page: currentUrlParams.page || 'adpresso-statistics', range: dateRange, start_date: dateRange === 'custom' && customStartDate ? customStartDate : undefined, end_date: dateRange === 'custom' && customEndDate ? customEndDate : undefined, group_by: grouping, view_mode: viewMode, orderby: sorting.orderBy, order: sorting.order, // Add filters when they aren't empty. filter_ads: filters.ads.length ? filters.ads.join( ',' ) : undefined, filter_groups: filters.groups.length ? filters.groups.join( ',' ) : undefined, filter_placements: filters.placements.length ? filters.placements.join( ',' ) : undefined, compare: isPro && compareMode ? compareMode : undefined }; // Create the new URL. const newUrl = addQueryArgs( window.location.pathname, queryArgs ); window.history.pushState( {path: newUrl}, '', newUrl ); }, [dateRange, customStartDate, customEndDate, grouping, viewMode, sorting, filters, compareMode] ); useEffect( () => { const endpoint = `adpresso/v1/analytics/filter-options`; wp.apiFetch( {path: endpoint, headers: {'X-WP-Nonce': apiNonce}} ) .then( data => setFilterOptions( data ) ) .catch( error => console.error( 'Failed to load filter options:', error ) ); }, [] ); // Loading effect for the chart. useEffect( () => { const request = {current: true}; const {setChartData: storeChartData, setCompareData: storeCompareData, setIsChartLoading: storeChartLoading} = guardSetters( {setChartData, setCompareData: setChartCompareData, setIsChartLoading}, request ); const defaultChartLogic = () => { storeChartLoading( true ); let queryArgs = { range: dateRange, view_mode: 'date', group_by: grouping, orderby: 'date', order: 'asc' }; // Add Custom Dates if ( dateRange === 'custom' && customStartDate && customEndDate ) { queryArgs.start_date = customStartDate; queryArgs.end_date = customEndDate; } if ( filters.ads.length > 0 ) { queryArgs['filter_ads'] = filters.ads.join( ',' ); } if ( filters.groups.length > 0 ) { queryArgs['filter_groups'] = filters.groups.join( ',' ); } if ( filters.placements.length > 0 ) { queryArgs['filter_placements'] = filters.placements.join( ',' ); } const endpoint = addQueryArgs( `adpresso/v1/analytics/overview`, queryArgs ); wp.apiFetch( {path: endpoint, headers: {'X-WP-Nonce': apiNonce}} ) .then( data => { storeChartData( data ); // Also clears stale compare data after compare mode was switched off. storeCompareData( null ); } ) .catch( error => { console.error( 'Failed to load analytics data:', error ); storeChartData( [] ); storeCompareData( null ); } ) .finally( () => { storeChartLoading( false ); } ); }; // Everything a replacement fetch needs; the chart is always a date timeline. const proFilterPayload = { dateRange, customStartDate, customEndDate, compareMode, filters, grouping, viewMode: 'date', sorting: {orderBy: 'date', order: 'asc'}, setChartData: storeChartData, setCompareData: storeCompareData, setIsChartLoading: storeChartLoading, apiNonce, restUrl }; const logicToRun = applyFilters( 'adpresso.analytics.fetchChartData', defaultChartLogic, proFilterPayload ); if ( typeof logicToRun === 'function' ) { logicToRun(); } return () => { request.current = false; }; }, [dateRange, filters, grouping, customStartDate, customEndDate, compareMode] ); // Data hook for the table. useEffect( () => { const request = {current: true}; const {setTableData: storeTableData, setCompareData: storeCompareData, setIsTableLoading: storeTableLoading} = guardSetters( {setTableData, setCompareData: setTableCompareData, setIsTableLoading}, request ); const defaultTableLogic = () => { storeTableLoading( true ); let queryArgs = { range: dateRange, view_mode: viewMode, group_by: grouping, orderby: sorting.orderBy, order: sorting.order }; if ( dateRange === 'custom' && customStartDate && customEndDate ) { queryArgs.start_date = customStartDate; queryArgs.end_date = customEndDate; } if ( filters.ads.length > 0 ) { queryArgs['filter_ads'] = filters.ads.join( ',' ); } if ( filters.groups.length > 0 ) { queryArgs['filter_groups'] = filters.groups.join( ',' ); } if ( filters.placements.length > 0 ) { queryArgs['filter_placements'] = filters.placements.join( ',' ); } const endpoint = addQueryArgs( `adpresso/v1/analytics/overview`, queryArgs ); wp.apiFetch( {path: endpoint, headers: {'X-WP-Nonce': apiNonce}} ) .then( data => { storeTableData( data ); storeCompareData( null ); } ) .catch( error => { storeTableData( [] ); storeCompareData( null ); } ) .finally( () => storeTableLoading( false ) ); }; const proFilterPayload = { dateRange, customStartDate, customEndDate, compareMode, viewMode, filters, grouping, sorting, setTableData: storeTableData, setCompareData: storeCompareData, setIsTableLoading: storeTableLoading, apiNonce, restUrl }; const logicToRun = applyFilters( 'adpresso.analytics.fetchTableData', defaultTableLogic, proFilterPayload ); if ( typeof logicToRun === 'function' ) { logicToRun(); } return () => { request.current = false; }; }, [dateRange, viewMode, filters, sorting, customStartDate, customEndDate, grouping, compareMode] ); const handleSaveView = useCallback( () => { const currentParams = buildCurrentParams( dateRange, grouping, viewMode, sorting, filters, compareMode, customStartDate, customEndDate ); // Check if the current state is already the saved one. const isCurrentlyDefault = areObjectsEqual( currentParams, tierView( savedDefaultView ) ); let payload = {}; // Set the current view, if it's not the saved one. if ( !isCurrentlyDefault ) { payload = currentParams; // Free keeps a compare mode saved while Pro was active, for when Pro returns. if ( !isPro && savedDefaultView?.compare ) { payload = {...currentParams, compare: savedDefaultView.compare}; } } wp.apiFetch( { path: `adpresso/v1/analytics/save-default-view`, method: 'POST', headers: {'X-WP-Nonce': apiNonce}, data: {'view_params': payload} } ) .then( response => { console.log( 'Response:', response ); if ( response.action === 'saved' ) { setSavedDefaultView( response.saved_view ); if ( window.AdPresso?.notifications ) { window.AdPresso.notifications.addSuccess( __( 'Default view saved!', 'adpresso' ) ); } } else if ( response.action === 'deleted' ) { setSavedDefaultView( null ); if ( window.AdPresso?.notifications ) { window.AdPresso.notifications.addInfo( __( 'Default view removed.', 'adpresso' ) ); } } } ) .catch( error => { console.error( 'Failed to save view:', error ); if ( window.AdPresso?.notifications ) { window.AdPresso.notifications.addError( __( 'Could not save default view. Please try again.', 'adpresso' ) ); } } ); }, [dateRange, customStartDate, customEndDate, grouping, viewMode, sorting, filters, compareMode, apiNonce, savedDefaultView] ); const changeViewMode = ( mode ) => { setViewMode( mode ); setSorting( prev => sortingForView( mode, prev ) ); }; const handleDrilldown = ( drilldownParams ) => { setPreviousState( { viewMode: viewMode, filters: filters } ); changeViewMode( drilldownParams.viewMode ); setFilters( prevFilters => ({ ...prevFilters, ...drilldownParams.filters }) ); }; const handleGoBack = () => { if ( !previousState ) { return; } changeViewMode( previousState.viewMode ); setFilters( previousState.filters ); setPreviousState( null ); }; const handleRangeChange = ( newRange ) => { if ( typeof newRange === 'string' ) { // Case A: Preset (e.g. 'last_30_days') setDateRange( newRange ); setCustomStartDate( null ); setCustomEndDate( null ); } else if ( typeof newRange === 'object' && newRange !== null ) { // Case B: Custom Range Object from the calendar. const custom = normalizeCustomRange( newRange.start, newRange.end ); if ( custom ) { setDateRange( 'custom' ); setCustomStartDate( custom.start ); setCustomEndDate( custom.end ); } } }; const viewModeOptions = [ {key: 'date', label: __( 'By Date', 'adpresso' )}, {key: 'ads', label: __( 'By Ad', 'adpresso' )}, {key: 'groups', label: __( 'By Group', 'adpresso' )}, {key: 'placements', label: __( 'By Placement', 'adpresso' )} ]; const currentParams = buildCurrentParams( dateRange, grouping, viewMode, sorting, filters, compareMode, customStartDate, customEndDate ); const isDefaultView = areObjectsEqual( currentParams, tierView( savedDefaultView ) ); const adFilterField = useMemo( () => ({ id: 'ads', placeholder: __( 'Filter by Ads...', 'adpresso' ), options: filterOptions.ads }), [filterOptions.ads] ); const groupFilterField = useMemo( () => ({ id: 'groups', placeholder: __( 'Filter by Groups...', 'adpresso' ), options: filterOptions.groups }), [filterOptions.groups] ); const placementFilterField = useMemo( () => ({ id: 'placements', placeholder: __( 'Filter by Placements...', 'adpresso' ), options: filterOptions.placements }), [filterOptions.placements] ); const handleFilterChange = ( filterKey, newValues ) => { setFilters( prevFilters => ({ ...prevFilters, [filterKey]: newValues }) ); }; const hasActiveFilters = (filters.ads && filters.ads.length > 0) || (filters.groups && filters.groups.length > 0) || (filters.placements && filters.placements.length > 0); // The reset function. const handleResetFilters = () => { const resetState = { ...filters, ads: [], groups: [], placements: [] }; if ( typeof setFilters === 'function' ) { setFilters( resetState ); } }; if ( isChartLoading && isTableLoading && tableData.length === 0 ) { return ; } const rangeParamForView = (dateRange === 'custom') ? {key: 'custom', start: customStartDate, end: customEndDate} : dateRange; return (

AdPresso

{/* Not an h1: WP moves admin notices after the first .wrap h1/h2. */}
{__( 'AdPresso Statistics', 'adpresso' )}
0 ? 'ads-loaded' : 'ads-loading'} field={adFilterField} value={filters.ads} onChange={handleFilterChange} tokenClassName="adpresso-color-ad" /> 0 ? 'groups-loaded' : 'groups-loading'} field={groupFilterField} value={filters.groups} onChange={handleFilterChange} tokenClassName="adpresso-color-group" /> 0 ? 'placements-loaded' : 'placements-loading'} field={placementFilterField} value={filters.placements} onChange={handleFilterChange} tokenClassName="adpresso-color-placement" />
{isChartLoading && (
)} {chartData.length > 0 ? ( ) : ( !isChartLoading &&

{__( 'No data found for this period.', 'adpresso' )}

)}
{/**/} {isTableLoading && (
)}
{sectionComponents.map( ( Section, index ) =>
)}
); }; export default StatisticsApp;