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
{__( 'No data found for this period.', 'adpresso' )}
)}