diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56b46efd2652..25fb91ed95e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3829,9 +3829,18 @@ importers: '@date-fns/tz': specifier: 1.4.1 version: 1.4.1 + '@tanstack/react-query': + specifier: 5.90.8 + version: 5.90.8(react@18.3.1) + '@wordpress/api-fetch': + specifier: 7.46.0 + version: 7.46.0 '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/core-data': + specifier: 7.46.0 + version: 7.46.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/data': specifier: 10.46.0 version: 10.46.0(react@18.3.1) @@ -3844,6 +3853,9 @@ importers: '@wordpress/route': specifier: 0.12.0 version: 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.46.0 + version: 4.46.0 date-fns: specifier: 4.1.0 version: 4.1.0 @@ -3854,9 +3866,18 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config '@babel/core': specifier: 7.29.0 version: 7.29.0 + '@tanstack/react-query-devtools': + specifier: 5.90.2 + version: 5.90.2(@tanstack/react-query@5.90.8(react@18.3.1))(react@18.3.1) + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260225.1 version: 7.0.0-dev.20260225.1 @@ -3866,6 +3887,9 @@ importers: browserslist: specifier: 4.28.2 version: 4.28.2 + jest: + specifier: 30.4.2 + version: 30.4.2 projects/packages/protect-models: {} diff --git a/projects/packages/premium-analytics/babel.config.cjs b/projects/packages/premium-analytics/babel.config.cjs new file mode 100644 index 000000000000..ed6bf0680c44 --- /dev/null +++ b/projects/packages/premium-analytics/babel.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + presets: [ + [ + '@automattic/jetpack-webpack-config/babel/preset', + { pluginReplaceTextdomain: { textdomain: 'jetpack-premium-analytics' } }, + ], + ], +}; diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1316-integrate-data-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1316-integrate-data-package-into-analytics new file mode 100644 index 000000000000..d2029922c0f4 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1316-integrate-data-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port data package (React Query report hooks, fetchers, and processing) as an internal package from next-woocommerce-analytics. diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs index 4d905c2efea4..b0ef3d32d68f 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -1,16 +1,30 @@ import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; /** - * Soften JSDoc rules for `packages/datetime/**` so the initial port can - * land. Temporary — backfill proper descriptions on the helpers and - * remove this override (at which point this whole file can go away). + * Soften JSDoc rules for the internal `packages/*` ports so the initial + * ports can land. Temporary — backfill proper descriptions on the helpers + * and remove this override (at which point this whole file can go away). */ -export default defineConfig( makeBaseConfig( import.meta.url ), { - files: [ 'packages/datetime/**' ], - rules: { - 'jsdoc/require-description': 'off', - 'jsdoc/require-param-description': 'off', - 'jsdoc/require-returns': 'off', - 'jsdoc/check-indentation': 'off', +export default defineConfig( + makeBaseConfig( import.meta.url ), + { + files: [ 'packages/datetime/**', 'packages/data/**' ], + rules: { + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + }, }, -} ); + { + // The data port carries a couple of upstream patterns this temporary + // override keeps as-is: intentional `any` escapes for the generic report + // `TData` (see use-report.ts), and `react` flagged as extraneous because + // the internal package's deps are declared on the parent manifest. + files: [ 'packages/data/**' ], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'import/no-extraneous-dependencies': 'off', + }, + } +); diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 3f0ab771f18b..254d21c6e6f4 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "wp-build && mkdir -p build/modules/boot && cp shims/boot-asset.php build/modules/boot/index.min.asset.php", "build-production": "NODE_ENV=production wp-build && mkdir -p build/modules/boot && cp shims/boot-asset.php build/modules/boot/index.min.asset.php", + "test": "jest --config=tests/jest.config.cjs", "typecheck": "tsgo --noEmit", "watch": "wp-build --watch" }, @@ -30,19 +31,27 @@ }, "dependencies": { "@date-fns/tz": "1.4.1", + "@tanstack/react-query": "5.90.8", + "@wordpress/api-fetch": "7.46.0", "@wordpress/boot": "0.13.0", + "@wordpress/core-data": "7.46.0", "@wordpress/data": "10.46.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/route": "0.12.0", + "@wordpress/url": "4.46.0", "date-fns": "4.1.0", "react": "18.3.1", "react-dom": "18.3.1" }, "devDependencies": { + "@automattic/jetpack-webpack-config": "workspace:*", "@babel/core": "7.29.0", + "@tanstack/react-query-devtools": "5.90.2", + "@types/jest": "30.0.0", "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/build": "0.14.0", - "browserslist": "4.28.2" + "browserslist": "4.28.2", + "jest": "30.4.2" } } diff --git a/projects/packages/premium-analytics/packages/data/README.md b/projects/packages/premium-analytics/packages/data/README.md new file mode 100644 index 000000000000..c8492a081340 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/README.md @@ -0,0 +1,431 @@ +# @automattic/jetpack-premium-analytics-data + +Data management for Jetpack Premium Analytics with React Query integration. + +## Installation + +This is an internal package of Jetpack Premium Analytics — it is never +published to npm and is resolved entirely in-tree. It's automatically +available to routes and other internal packages within +`@automattic/jetpack-premium-analytics`. + +```tsx +import { + AnalyticsQueryClientProvider, + useReport, + prefetchReport, + // ... other exports +} from '@automattic/jetpack-premium-analytics-data'; +``` + +## Features + +- **React Query Integration**: Built on `@tanstack/react-query` for + caching and state management +- **Prefetching Support**: Route-based data prefetching for improved + performance +- **Data Processing**: Automatic sanitization of API responses + (strings → numbers) +- **Comparison Support**: Built-in primary + comparison data fetching +- **TypeScript Support**: Fully typed data structures and API responses +- **Smart Caching**: Automatic cache invalidation and background + refetching + +## Usage + +### Setup + +```tsx +import { AnalyticsQueryClientProvider } from '@automattic/jetpack-premium-analytics-data'; + +function App() { + return ( + + {/* Your app components */} + + ); +} +``` + +### Fetching Data + +```tsx +import { + useReportOrders, + useReportOrdersByProductType, + useReportOrderAttribution, + useReportCoupons +} from '@automattic/jetpack-premium-analytics-data'; + +function OrdersReport() { + // Orders endpoint separates primary and comparison periods + const { primary, comparison, hasComparison } = useReportOrders( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + } ); +} + +function OrdersByProductTypeReport() { + // Orders by product type with filtering support + const { primary, comparison, hasComparison } = useReportOrdersByProductType( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + filters: [ + { + key: 'product_type', + value: ['simple'], + compare: 'IN' + } + ] + } ); +} + +function OrderAttributionReport() { + // Order attribution requires a view parameter + const { primary, comparison, hasComparison } = useReportOrderAttribution( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + view: 'channel', // 'channel' | 'source' | 'campaign' | 'device' | 'channel-source' + } ); +} + +function CouponsReport() { + // Coupons works like orders with separate comparison requests + const { primary, comparison, hasComparison } = useReportCoupons( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + } ); +} +``` + +### Prefetching + +```tsx +import { prefetchReport, ensureCoreSettingsReady } from '@automattic/jetpack-premium-analytics-data'; + +export const route = { + beforeLoad: async () => { + // Ensure site settings are loaded first + await ensureCoreSettingsReady(); + + // Now safely prefetch reports + await prefetchReport( 'orders' ); + }, +}; +``` + +## API Reference + +### Individual Hooks (Recommended) + +#### `useReportOrders( params )` + +Fetches orders report data with automatic processing and comparison support. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, and optional comparison params + +**Returns:** `{ primary, comparison, hasComparison }` + +#### `useReportOrdersByProductType( params )` + +Fetches orders report data filtered by product type or other product characteristics with automatic processing and comparison support. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, optional `filters` array, and optional comparison params + +**Filters Structure:** +```typescript +filters: Array<{ + key: string; // e.g., 'product_type', 'virtual' + value: string | string[]; // e.g., ['simple'], '1' + compare: '=' | 'IN' | 'NOT IN' | '!=' | '>' | '<' | '>=' | '<='; +}> +``` + +**Common Filter Examples:** +- Product types: `{ key: 'product_type', value: ['simple', 'variable'], compare: 'IN' }` +- Virtual products: `{ key: 'virtual', value: '1', compare: '=' }` +- Non-virtual products: `{ key: 'virtual', value: '0', compare: '=' }` + +**Returns:** `{ primary, comparison, hasComparison }` + +#### `useReportOrderAttribution( params )` + +Fetches order attribution data with built-in comparison handling. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, `view`, and optional comparison params + +**Returns:** `{ primary, comparison, hasComparison }` + +#### `useReportCoupons( params )` + +Fetches coupons report data with automatic processing and comparison support. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, and optional comparison params + +**Returns:** `{ primary, comparison, hasComparison }` + +### Legacy Hook (Deprecated) + +#### `useReport( reportType, params )` + +**⚠️ Deprecated:** Use individual hooks instead for better type safety and performance. + +**Parameters:** +- `reportType`: `'orders'` | `'orders-by-product-type'` | `'order-attribution'` | `'coupons'` +- `params`: `ReportParams` + +**Returns:** Same as individual hooks above + +### `prefetchReport( reportType, params )` + +Prefetches data for improved performance. Same parameters as `useReport`. + +**Usage:** Call in route `beforeLoad` functions for instant data loading + +**Returns:** Promise that resolves when data is prefetched + +**Caching:** Uses React Query cache, so subsequent `useReport` calls are +instant + +**Example:** +```tsx +// Prefetch orders data +await prefetchReport( 'orders', { from, to, interval } ); + +// Prefetch orders by product type data +await prefetchReport( 'orders-by-product-type', { + from, + to, + interval, + filters: [{ key: 'product_type', value: ['simple'], compare: 'IN' }] +} ); + +// Prefetch order attribution data +await prefetchReport( 'order-attribution', { from, to, interval, view: 'channel' } ); + +// Prefetch coupons data +await prefetchReport( 'coupons', { from, to, interval } ); +``` + +### `normalizeReportParams( params? )` + +Normalizes and validates report parameters, providing defaults when needed. + +**Parameters:** +- `params`: Optional partial parameters object + +**Returns:** `{ primary, comparison? }` with normalized parameters + +**Defaults:** Last 30 days, daily interval when not specified + +**Validation:** Ensures required fields are present for API calls + +### `getDefaultIntervalForPeriod( period, from, to )` + +Returns the optimal default interval for a given time period. + +**Parameters:** +- `period`: `string` - Period identifier (e.g., 'today', 'last-7-days', 'last-30-days') +- `from`: `string` - Start date +- `to`: `string` - End date + +**Returns:** `IntervalType` - Optimal interval ('hour', 'day', 'week', 'month', 'quarter', 'year') + +**Example:** +```tsx +import { getDefaultIntervalForPeriod } from '@automattic/jetpack-premium-analytics-data'; + +const interval = getDefaultIntervalForPeriod( 'last-7-days', from, to ); // Returns 'day' +``` + +### `ORDER_ATTRIBUTION_VIEWS` + +Constant array of available order attribution views. + +**Values:** `['channel', 'source', 'campaign', 'device', 'channel-source']` + +**Example:** +```tsx +import { ORDER_ATTRIBUTION_VIEWS } from '@automattic/jetpack-premium-analytics-data'; + +// Use in components for view selection +const views = ORDER_ATTRIBUTION_VIEWS; // ['channel', 'source', ...] +``` + +## Architecture + +``` +src/ +├── api/ # API functions and query keys +│ ├── index.ts # API exports +│ ├── constants.ts # Shared API endpoint constants +│ ├── report-orders-fetch/ # Orders API client +│ │ ├── index.ts # Orders API exports +│ │ └── report-orders-fetch.ts # Orders API implementation +│ ├── report-orders-by-product-type-fetch/ # Orders by product type API client +│ │ ├── index.ts # Orders by product type API exports +│ │ └── report-orders-by-product-type-fetch.ts # Orders by product type API implementation +│ ├── report-order-attribution-summary-fetch/ # Attribution API client +│ │ ├── index.ts # Attribution API exports +│ │ └── report-order-attribution-summary-fetch.ts # Attribution API implementation +│ └── report-coupons-fetch/ # Coupons API client +│ ├── index.ts # Coupons API exports +│ └── report-coupons-fetch.ts # Coupons API implementation +├── queries/ # React Query configurations +│ ├── index.ts # Query exports +│ ├── report-orders-query.ts # Orders query definition +│ ├── report-orders-by-product-type-query.ts # Orders by product type query definition +│ ├── report-order-attribution-summary-query.ts # Attribution query definition +│ └── report-coupons-query.ts # Coupons query definition +├── hooks/ # React hooks +│ ├── index.ts # Hook exports +│ └── use-report.ts # Main useReport hook with comparison +├── prefetch/ # Prefetching functions +│ ├── index.ts # Prefetch exports +│ └── prefetch-report-orders.ts # Multi-report prefetch logic (orders + attribution + coupons) +├── processing/ # Data sanitization and transformation +│ ├── orders/ # Orders-specific data processing +│ ├── orders-by-product-type/ # Orders by product type data processing +│ ├── coupons/ # Coupons data processing +│ └── order-attribution/ # Attribution data processing +├── providers/ # React Context providers +│ ├── index.ts # Provider exports +│ └── query-client-provider.tsx # React Query client setup +├── defaults/ # Default parameters and configurations +├── utils/ # Utility functions +│ ├── date.ts # Date manipulation utilities (timezone-aware) +│ ├── ensure-core-settings.ts # Core settings initialization +│ ├── interval.ts # Interval calculation and optimization +│ ├── search.ts # Search parameter utilities +│ └── types.ts # Shared utility types (Override, etc.) +└── types.ts # TypeScript type definitions +``` + +### Data Flow + +1. **Route Prefetching**: `beforeLoad` calls `prefetchReport()` to load + data +2. **Component Consumption**: Components use `useReport()` to access cached + data +3. **Automatic Processing**: Raw API responses are sanitized + (strings → numbers) +4. **Comparison Handling**: Primary and comparison queries are managed + automatically +5. **Cache Management**: React Query handles caching, background updates, + and invalidation + +## Date Utilities + +This package provides timezone-aware date utilities that integrate with +WordPress site settings: + +### `localTZDate( value?, timezone? )` + +Creates a timezone-aware date using the site's configured timezone by +default. + +```typescript +import { localTZDate } from '@automattic/jetpack-premium-analytics-data'; + +const now = localTZDate(); // Current time in site timezone +const custom = localTZDate( '2024-01-15', 'America/New_York' ); +``` + +**Parameters:** +- `value` (optional): `number | string | Date` - Date value to convert +- `timezone` (optional): `string` - Target timezone (defaults to site + timezone) + +**Returns:** `TZDate` - Timezone-aware date object + + +### `dateToISOStringWithLocalTZ( date, timezone? )` + +Converts a date to ISO string with the site's timezone offset applied. + +```typescript +const withTZ = dateToISOStringWithLocalTZ( new Date() ); +// Returns: "2024-01-15T14:30:00.000-05:00" (with site timezone offset) +``` + +**Parameters:** +- `date`: `Date` - Date to convert +- `timezone` (optional): `string` - Target timezone (defaults to site + timezone) + +**Returns:** `string` - ISO string with timezone offset + +### `getSiteTimezone()` + +Returns the WordPress site's configured timezone string. + +```typescript +const timezone = getSiteTimezone(); +// Returns: "America/New_York" or "+05:30" (offset format) +``` + +**Returns:** `string` - Site timezone from WordPress settings + +**Note:** This function will throw an error if called before core settings +are loaded. Use `ensureCoreSettingsReady()` in route loaders to prevent +this. + +### `ensureCoreSettingsReady()` + +Ensures WordPress core settings (site and general settings) are loaded +before accessing timezone-dependent functions. + +```typescript +// In route loaders or beforeLoad hooks +await ensureCoreSettingsReady(); +// Now getSiteTimezone() can be safely called +``` + +**Returns:** `Promise` - Resolves when settings are loaded + +**Features:** +- Memoizes the promise to avoid duplicate requests +- Prevents race conditions during navigation +- Essential for route prefetching and hover preloading + +These functions automatically use the WordPress site's timezone settings +and provide consistent date handling across the analytics interface. + +## Complete API Exports + +This package exports the following public API: + +### Components +- `AnalyticsQueryClientProvider` - React Query provider wrapper + +### Hooks +- `useReportOrders` - Hook for fetching orders report data +- `useReportOrdersByProductType` - Hook for fetching orders by product type with filtering +- `useReportOrderAttribution` - Hook for fetching order attribution data +- `useReportCoupons` - Hook for fetching coupons report data +- `useReport` - Legacy main hook for fetching report data (deprecated) + +### Functions +- `prefetchReport` - Prefetch data for routes +- `normalizeReportParams` - Normalize and validate parameters +- `getDefaultIntervalForPeriod` - Get optimal interval for time period + +### Date Utilities +- `localTZDate` - Create timezone-aware dates +- `dateToISOStringWithLocalTZ` - Convert to ISO with timezone +- `getSiteTimezone` - Get WordPress site timezone +- `ensureCoreSettingsReady` - Ensure settings are loaded + +### Constants +- `ORDER_ATTRIBUTION_VIEWS` - Available attribution view types + +### Types +- `ReportDataMap` - TypeScript type mapping for report data structures diff --git a/projects/packages/premium-analytics/packages/data/package.json b/projects/packages/premium-analytics/packages/data/package.json new file mode 100644 index 000000000000..74470af6fbd8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/package.json @@ -0,0 +1,23 @@ +{ + "name": "@automattic/jetpack-premium-analytics-data", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false, + "dependencies": { + "@date-fns/tz": "1.4.1", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@tanstack/react-query": "5.90.8", + "@wordpress/api-fetch": "7.46.0", + "@wordpress/core-data": "7.46.0", + "@wordpress/data": "10.46.0", + "@wordpress/i18n": "^6.9.0", + "@wordpress/url": "4.46.0", + "date-fns": "4.1.0" + }, + "devDependencies": { + "@tanstack/react-query-devtools": "5.90.2" + } +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/constants.ts b/projects/packages/premium-analytics/packages/data/src/api/constants.ts new file mode 100644 index 000000000000..eb04e41b4633 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/constants.ts @@ -0,0 +1,4 @@ +/** + * Constants for API endpoints + */ +export const reportsPath = '/wc/v3/woocommerce-analytics/proxy/reports'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/index.ts b/projects/packages/premium-analytics/packages/data/src/api/index.ts new file mode 100644 index 000000000000..771d51ddba7b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/index.ts @@ -0,0 +1,45 @@ +/** + * Internal dependencies + */ +import type { RequestReportBookingsParams } from './report-bookings-fetch'; +import type { RequestReportCouponsByDateParams } from './report-coupons-by-date-fetch'; +import type { RequestReportCouponsParams } from './report-coupons-fetch'; +import type { RequestReportCustomersParams } from './report-customers-fetch'; +import type { RequestReportOrderAttributionByProductParams } from './report-order-attribution-by-product-fetch'; +import type { RequestReportOrderAttributionSummaryParams } from './report-order-attribution-summary-fetch'; +import type { RequestReportOrdersParams } from './report-orders-fetch'; +import type { RequestReportProductsParams } from './report-products-fetch'; +import type { RequestReportSessionsByDeviceParams } from './report-sessions-by-device-fetch'; +import type { RequestReportVisitorsByLocationParams } from './report-visitors-by-location-fetch'; +import type { RequestReportVisitorsParams } from './report-visitors-fetch'; + +export type ReportQueryParams = Partial< + RequestReportOrdersParams & + RequestReportOrderAttributionSummaryParams & + RequestReportOrderAttributionByProductParams & + RequestReportCouponsParams & + RequestReportCouponsByDateParams & + RequestReportCustomersParams & + RequestReportProductsParams & + RequestReportVisitorsParams & + RequestReportVisitorsByLocationParams & + RequestReportBookingsParams & + RequestReportSessionsByDeviceParams +>; + +export { fetchReportOrders } from './report-orders-fetch'; +export { + fetchReportOrderAttributionSummary, + ORDER_ATTRIBUTION_VIEWS, +} from './report-order-attribution-summary-fetch'; +export { fetchReportOrderAttributionByProduct } from './report-order-attribution-by-product-fetch'; +export { fetchReportCoupons } from './report-coupons-fetch'; +export { fetchReportCouponsByDate } from './report-coupons-by-date-fetch'; +export { fetchReportCustomers } from './report-customers-fetch'; +export { fetchReportProducts } from './report-products-fetch'; +export { fetchReportVisitors } from './report-visitors-fetch'; +export { fetchReportVisitorsByLocation } from './report-visitors-by-location-fetch'; +export { fetchReportBookings } from './report-bookings-fetch'; +export { fetchReportSessionsByDevice } from './report-sessions-by-device-fetch'; +export { exportReport } from './report-export-fetch'; +export type { ExportReportParams, ExportReportResponse } from './report-export-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/index.ts new file mode 100644 index 000000000000..8dd1607b5e9e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/index.ts @@ -0,0 +1,5 @@ +export { fetchReportBookings } from './report-bookings-fetch'; +export type { + ReportsBookingsByDateResponse, + RequestReportBookingsParams, +} from './report-bookings-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/report-bookings-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/report-bookings-fetch.ts new file mode 100644 index 000000000000..c06c8fa36fa2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/report-bookings-fetch.ts @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsBookingsByDateSummary = { + status_unpaid: string; + status_pending_confirmation: string; + status_confirmed: string; + status_paid: string; + status_cancelled: string; + status_complete: string; + attendance_status_booked: string; + attendance_status_no_show: string; + attendance_status_checked_in: string; + date_start: string; + date_end: string; +}; + +type BookingsReportDataItem = ReportsBookingsByDateSummary & { + time_interval: string; +}; + +export type ReportsBookingsByDateResponse = { + data: BookingsReportDataItem[]; + summary: ReportsBookingsByDateSummary; +}; + +export type RequestReportBookingsParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportBookings( { + from, + to, + interval, + filters, + date_type, +}: RequestReportBookingsParams ): Promise< ReportsBookingsByDateResponse > { + const apiUrl = `${ reportsPath }/bookings/by-date`; + + const path = addQueryArgs( apiUrl, { + from, + to, + interval, + filters, + date_type, + } ); + + return apiFetch( { path } ) as Promise< ReportsBookingsByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/index.ts new file mode 100644 index 000000000000..f9cc046877b8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportConversionRate, + type RequestReportConversionRateParams, +} from './report-conversion-rate-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/report-conversion-rate-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/report-conversion-rate-fetch.ts new file mode 100644 index 000000000000..eaea267f5bdc --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/report-conversion-rate-fetch.ts @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsConversionRateByDateSummary = { + active_sessions: string; + visitors: string; + with_cart_addition: string; + reached_checkout: string; + completed_checkout: string; + date_end: string; + date_start: string; +}; + +type ConversionRateReportDataItem = { + date_start: string; + date_end: string; + active_sessions: string; + visitors: string; + with_cart_addition: string; + reached_checkout: string; + completed_checkout: string; +}; + +type ReportsConversionRateByDateResponse = { + data: ConversionRateReportDataItem[]; + summary: ReportsConversionRateByDateSummary; +}; + +export type RequestReportConversionRateParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + */ +export async function fetchReportConversionRate( { + from, + to, + interval, + filters, +}: RequestReportConversionRateParams ): Promise< ReportsConversionRateByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-conversion-rate`, { + from, + to, + interval, + filters, + } ); + + return apiFetch( { + path, + } ) as Promise< ReportsConversionRateByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/index.ts new file mode 100644 index 000000000000..cd9dbe298e36 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportCouponsByDate, + type RequestReportCouponsByDateParams, +} from './report-coupons-by-date-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/report-coupons-by-date-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/report-coupons-by-date-fetch.ts new file mode 100644 index 000000000000..d58602ec6645 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/report-coupons-by-date-fetch.ts @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type CouponsByDateDataItem = { + time_interval: string; + date_start: string; + date_end: string; + total_orders: string; + orders_with_coupon: string; + orders_without_coupon: string; + total_sales: string; + sales_with_coupon: string; + sales_without_coupon: string; + total_discount_amount: string; + net_sales_after_discount: string; + coupon_usage_percentage: string; +}; + +type CouponsByDateSummary = { + total_orders: string; + orders_with_coupon: string; + orders_without_coupon: string; + total_sales: string; + sales_with_coupon: string; + sales_without_coupon: string; + total_discount_amount: string; + net_sales_after_discount: string; + coupon_usage_percentage: string; + date_start: string; + date_end: string; +}; + +export type ReportsCouponsByDateResponse = { + summary: CouponsByDateSummary; + data: CouponsByDateDataItem[]; +}; + +export type RequestReportCouponsByDateParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportCouponsByDate( { + from, + to, + interval, + filters, + date_type, +}: RequestReportCouponsByDateParams ): Promise< ReportsCouponsByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/coupons/by-date`, { + from, + to, + interval, + filters, + date_type, + } ); + + return apiFetch< ReportsCouponsByDateResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/index.ts new file mode 100644 index 000000000000..54bbee440ef2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/index.ts @@ -0,0 +1 @@ +export { fetchReportCoupons, type RequestReportCouponsParams } from './report-coupons-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/report-coupons-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/report-coupons-fetch.ts new file mode 100644 index 000000000000..c25dc9dd29c0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/report-coupons-fetch.ts @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type CouponsDataItem = { + coupon_code: string; + discount_amount: string; + total_sales: string; + orders_count: string; +}; + +type CouponsDataSummary = { + total_sales: string; + total_discount_amount: string; + total_orders: string; + date_start: string; + date_end: string; +}; + +export type ReportsCouponsResponse = { + summary: CouponsDataSummary; + data: CouponsDataItem[]; +}; + +export type RequestReportCouponsParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportCoupons( { + from, + to, + interval, + filters, + date_type, +}: RequestReportCouponsParams ): Promise< ReportsCouponsResponse > { + const path = addQueryArgs( `${ reportsPath }/coupons/`, { + from, + to, + interval, + filters, + date_type, + orderby: 'total_sales', + } ); + + return apiFetch< ReportsCouponsResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/index.ts new file mode 100644 index 000000000000..ec5993677f34 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/index.ts @@ -0,0 +1 @@ +export * from './report-customers-by-date-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/report-customers-by-date-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/report-customers-by-date-fetch.ts new file mode 100644 index 000000000000..9d9e83b66d5d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/report-customers-by-date-fetch.ts @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsCustomersByDateSummary = { + total_net_sales: string; + total_gross_sales: string; + total_discounts: string; + total_refunds: string; + total_orders: string; + total_average_order_value: string; + total_avg_items_per_order: string; + total_customers: string; + new_customers: string; + returning_customers: string; + new_customer_sales: string; + new_customer_gross_sales: string; + new_customer_discounts: string; + new_customer_refunds: string; + new_customer_orders: string; + new_customer_avg_order_value: string; + new_customer_avg_items_per_order: string; + returning_customer_sales: string; + returning_customer_gross_sales: string; + returning_customer_discounts: string; + returning_customer_refunds: string; + returning_customer_orders: string; + returning_customer_avg_order_value: string; + returning_customer_avg_items_per_order: string; + date_start: string; + date_end: string; +}; + +type CustomersReportDataItem = { + time_interval: string; + date_start: string; + date_end: string; + total_customers: string; + new_customers: string; + returning_customers: string; + orders_count: string; + new_customer_orders: string; + returning_customer_orders: string; + net_sales: string; + new_customer_net_sales: string; + returning_customer_net_sales: string; +}; + +type ReportsCustomersByDateResponse = { + data: CustomersReportDataItem[]; + summary: ReportsCustomersByDateSummary; +}; + +export type RequestReportCustomersByDateParams = BaseReportParams; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.date_type + */ +export async function fetchReportCustomersByDate( { + from, + to, + interval, + date_type, +}: RequestReportCustomersByDateParams ): Promise< ReportsCustomersByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/customers/by-date`, { + from, + to, + interval, + date_type, + } ); + + return apiFetch( { path } ) as Promise< ReportsCustomersByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/index.ts new file mode 100644 index 000000000000..b5d7c2f32fe4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/index.ts @@ -0,0 +1 @@ +export { fetchReportCustomers, type RequestReportCustomersParams } from './report-customers-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/report-customers-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/report-customers-fetch.ts new file mode 100644 index 000000000000..1747d7c8cdb4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/report-customers-fetch.ts @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type CustomersNewReturningSummary = { + total_net_sales: string; + total_orders: string; + new_customer_sales: string; + returning_customer_sales: string; + date_start: string; + date_end: string; +}; + +type CustomersNewReturningItem = { + customer_type: 'new' | 'returning'; + net_sales: string; + orders_count: string; +}; + +type ReportsCustomersNewReturningResponse = { + summary: CustomersNewReturningSummary; + data: CustomersNewReturningItem[]; +}; + +export type RequestReportCustomersParams = Omit< BaseReportParams, 'interval' > & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportCustomers( { + from, + to, + filters, + date_type, +}: RequestReportCustomersParams ): Promise< ReportsCustomersNewReturningResponse > { + const path = addQueryArgs( `${ reportsPath }/customers/new-returning`, { + from, + to, + filters, + date_type, + } ); + + return apiFetch( { + path, + } ) as Promise< ReportsCustomersNewReturningResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/index.ts new file mode 100644 index 000000000000..4b36b16295d5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/index.ts @@ -0,0 +1,2 @@ +export { exportReport } from './report-export-fetch'; +export type { ExportReportParams, ExportReportResponse } from './report-export-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/report-export-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/report-export-fetch.ts new file mode 100644 index 000000000000..645bdff72b3f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/report-export-fetch.ts @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Export request parameters + */ +export interface ExportReportParams { + reportType: string | string[]; + from: string; // ISO 8601 date string + to: string; // ISO 8601 date string + interval?: string; + compareFrom?: string; // ISO 8601 date string + compareTo?: string; // ISO 8601 date string +} + +/** + * Export response from the API + */ +export interface ExportReportResponse { + success: boolean; + message: string; + job_ids?: Record< string, number >; // Multiple report exports + partial?: boolean; // Indicates if some exports failed + errors?: Record< string, string >; // Failed report types and their error messages +} + +/** + * Export one or more reports via email + * + * @param params - Export parameters + * @return Promise that resolves to the export response + */ +export async function exportReport( params: ExportReportParams ): Promise< ExportReportResponse > { + const path = '/wc/v3/woocommerce-analytics/reports/csv-export'; + + const body = { + report_type: Array.isArray( params.reportType ) ? params.reportType : [ params.reportType ], + from: params.from, + to: params.to, + interval: params.interval || 'day', + delivery_method: 'email', + ...( params.compareFrom && params.compareTo + ? { + compare_from: params.compareFrom, + compare_to: params.compareTo, + } + : {} ), + }; + + return apiFetch( { + path, + method: 'POST', + data: body, + } ) as Promise< ExportReportResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/index.ts new file mode 100644 index 000000000000..2039d9b82507 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/index.ts @@ -0,0 +1 @@ +export * from './report-order-attribution-by-product-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/report-order-attribution-by-product-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/report-order-attribution-by-product-fetch.ts new file mode 100644 index 000000000000..7e35aff2130e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/report-order-attribution-by-product-fetch.ts @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; +import type { ORDER_ATTRIBUTION_VIEWS } from '../report-order-attribution-summary-fetch/report-order-attribution-summary-fetch'; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +type OrderAttributionByProductInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: string; +}; + +type OrderAttributionByProductItem = { + item: string; + value: string; + intervals: OrderAttributionByProductInterval[]; +}; + +export type OrderAttributionByProductResponse = { + view: OrderAttributionView; + order_by: string; + data: OrderAttributionByProductItem[]; +}; + +export type RequestReportOrderAttributionByProductParams = BaseReportParams & { + view: OrderAttributionView; + filters?: FilterCondition[]; +}; + +/** + * Fetches order attribution by product data from the WC Analytics REST API + * + * This endpoint supports product filtering similar to fetchReportOrdersByProductType. + * Unlike the regular order-attribution endpoint, this one: + * - Does not support compare_from/compare_to parameters + * - Returns data in a flatter structure (no current_period/previous_period nesting) + * - Requires separate requests for comparison data + * + * @param params - Query parameters + * @return Promise resolving to order attribution by product response + */ +export async function fetchReportOrderAttributionByProduct( + params: RequestReportOrderAttributionByProductParams +): Promise< OrderAttributionByProductResponse > { + const { from, to, interval, view, filters, date_type } = params; + + const queryParams: Record< string, any > = { + from, + to, + interval, + view, + date_type, + }; + + // Add filters to query params if provided + if ( filters && filters.length > 0 ) { + queryParams.filters = filters; + } + + const path = addQueryArgs( + `${ reportsPath }/order-attribution-by-product/${ view }/summary`, + queryParams + ); + + return apiFetch< OrderAttributionByProductResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/index.ts new file mode 100644 index 000000000000..285b15296160 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/index.ts @@ -0,0 +1,6 @@ +export { + fetchReportOrderAttributionSummary, + ORDER_ATTRIBUTION_VIEWS, + type RequestReportOrderAttributionSummaryParams, + type OrderAttributionSummaryResponse, +} from './report-order-attribution-summary-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch.ts new file mode 100644 index 000000000000..6c62c763f539 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch.ts @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +export const ORDER_ATTRIBUTION_VIEWS = [ + 'channel', + 'source', + 'campaign', + 'device', + 'channel-source', +] as const; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +type OrderAttributionInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: string; +}; + +type OrderAttributionPeriod = { + value: string; + intervals: OrderAttributionInterval[]; +}; + +type OrderAttributionSummaryItem = { + item: string; + current_period: OrderAttributionPeriod; + previous_period: OrderAttributionPeriod; +}; + +export type OrderAttributionSummaryResponse = { + view: OrderAttributionView; + order_by: string; + data: OrderAttributionSummaryItem[]; +}; + +export type RequestReportOrderAttributionSummaryParams = BaseReportParams & { + view: OrderAttributionView; + compare_from: string; + compare_to: string; +}; + +/** + * Fetches order attribution summary data from the WC Analytics REST API + * + * Note: Order attribution summary endpoint returns both primary and comparison + * data in a single response, unlike orders endpoint which requires + * separate requests. The endpoint requires compare_from and compare_to parameters; + * when no comparison is needed, it uses the same values as the primary range. + * + * @param params - Query parameters + * @return Promise resolving to order attribution summary response + */ +export async function fetchReportOrderAttributionSummary( + params: RequestReportOrderAttributionSummaryParams +): Promise< OrderAttributionSummaryResponse > { + const { from, to, interval, view, compare_from, compare_to, date_type } = params; + + /* + * Order attribution endpoint requires compare_from and compare_to. + * When no comparison is needed, use the same values as primary range. + */ + const queryParams: Record< string, string | undefined > = { + from, + to, + interval, + view, + compare_from, + compare_to, + date_type, + }; + + const path = addQueryArgs( `${ reportsPath }/order-attribution/${ view }/summary`, queryParams ); + + return apiFetch< OrderAttributionSummaryResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/index.ts new file mode 100644 index 000000000000..2762252eb226 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/index.ts @@ -0,0 +1,5 @@ +export { + fetchReportOrders, + type RequestReportOrdersParams, + type ReportsOrdersByDateResponse, +} from './report-orders-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/report-orders-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/report-orders-fetch.ts new file mode 100644 index 000000000000..c97e7f3d8aa9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/report-orders-fetch.ts @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { hasProductFilters } from '../../utils/product-filters'; +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsOrdersByDateSummary = { + average_order_value: string; + avg_items: string; + cogs_amount: string; + coupons: string; + date_end: string; + date_start: string; + orders_no: string; + orders_value_gross: string; + orders_value_net: string; + paid_orders_count: string; + paid_net_sales: string; + product_net_revenue: string; + profit_margin: string; + refunds: string; + total_sales: string; + unpaid_orders_count: string; + unpaid_net_sales: string; +}; + +type OrdersReportDataItem = ReportsOrdersByDateSummary & { + time_interval?: string; +}; + +export type ReportsOrdersByDateResponse = { + data: OrdersReportDataItem[]; + summary: ReportsOrdersByDateSummary; +}; + +export type RequestReportOrdersParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportOrders( { + from, + to, + interval, + filters, + date_type, +}: RequestReportOrdersParams ): Promise< ReportsOrdersByDateResponse > { + const hasProductFiltersValue = hasProductFilters( filters ); + const apiUrl = hasProductFiltersValue + ? `${ reportsPath }/orders-by-product-type/by-date` + : `${ reportsPath }/orders/by-date`; + + const path = addQueryArgs( apiUrl, { + from, + to, + interval, + filters, + date_type, + } ); + + return apiFetch( { path } ) as Promise< ReportsOrdersByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/index.ts new file mode 100644 index 000000000000..c786309418cf --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/index.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export { fetchReportProducts, type RequestReportProductsParams } from './report-products-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/report-products-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/report-products-fetch.ts new file mode 100644 index 000000000000..8af88655042d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/report-products-fetch.ts @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { BaseReportParams } from '../../utils/types'; +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; + +export type RequestReportProductsParams = Omit< BaseReportParams, 'interval' > & { + limit?: number; + orderby?: string; + order?: 'asc' | 'desc'; + filters?: FilterCondition[]; +}; + +type ReportProductsResponse = { + data: { + product_id: string; + product_name: string; + product_net_revenue: string; + product_gross_revenue: string; + product_type: string; + orders_count: string; + sku: string; + total_quantity: string; + stock_status: string; + }[]; + summary: { + total_orders: string; + total_products: string; + total_quantity: string; + total_revenue: string; + }; +}; + +/** + * Fetches products report data from the WooCommerce Analytics API + * @param params + */ +export async function fetchReportProducts( + params: RequestReportProductsParams +): Promise< ReportProductsResponse > { + const queryArgs: Record< string, any > = { + from: params.from, + to: params.to, + date_type: params.date_type, + }; + + if ( params.limit ) { + queryArgs.limit = params.limit; + } + + if ( params.orderby ) { + queryArgs.orderby = params.orderby; + } + + if ( params.order ) { + queryArgs.order = params.order; + } + + // Add filters to query params if provided + if ( params.filters && params.filters.length > 0 ) { + queryArgs.filters = params.filters; + } + + return apiFetch< ReportProductsResponse >( { + path: addQueryArgs( `${ reportsPath }/products`, queryArgs ), + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/index.ts new file mode 100644 index 000000000000..2da42f37b7e6 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportSessionsByDevice, + type RequestReportSessionsByDeviceParams, +} from './report-sessions-by-device-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/report-sessions-by-device-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/report-sessions-by-device-fetch.ts new file mode 100644 index 000000000000..3909742411dc --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/report-sessions-by-device-fetch.ts @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +/** + * Raw response item from the sessions/by-device endpoint. + */ +type SessionsByDeviceItem = { + device_type: string; + active_sessions: string; +}; + +/** + * Summary data from the sessions/by-device endpoint. + */ +type SessionsByDeviceSummary = { + active_sessions: string; + total_orders: string; + date_start: string; + date_end: string; +}; + +/** + * Raw response structure from the sessions/by-device endpoint. + */ +type ReportsSessionsByDeviceResponse = { + summary: SessionsByDeviceSummary; + data: SessionsByDeviceItem[]; +}; + +export type RequestReportSessionsByDeviceParams = Omit< BaseReportParams, 'interval' >; + +/** + * Fetch sessions by device type report data. + * + * This endpoint returns a breakdown of sessions by device category + * (Mobile, Desktop, Tablet) for the specified date range. + * + * @param params - Request parameters + * @param params.from - Start date in YYYY-MM-DD format + * @param params.to - End date in YYYY-MM-DD format + */ +export async function fetchReportSessionsByDevice( { + from, + to, +}: RequestReportSessionsByDeviceParams ): Promise< ReportsSessionsByDeviceResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-device`, { + from, + to, + } ); + + return apiFetch( { path } ) as Promise< ReportsSessionsByDeviceResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/index.ts new file mode 100644 index 000000000000..18115cbe9222 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportVisitorsByLocation, + type RequestReportVisitorsByLocationParams, +} from './report-visitors-by-location-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/report-visitors-by-location-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/report-visitors-by-location-fetch.ts new file mode 100644 index 000000000000..bbb9c7fa6193 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/report-visitors-by-location-fetch.ts @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +type VisitorsByLocationReportDataItem = { + country_code: string; + label: string; + region?: string; + visitors: string; +}; + +type ReportsVisitorsByLocationSummary = { + visitors: string; + date_start: string; + date_end: string; +}; + +type ReportsVisitorsByLocationResponse = { + data: VisitorsByLocationReportDataItem[]; + summary?: ReportsVisitorsByLocationSummary; +}; + +export type RequestReportVisitorsByLocationParams = BaseReportParams & { + group_by: 'country' | 'region'; + country_code?: string; + limit?: number; +}; + +/** + * Fetch visitors grouped by location (country or region) for the selected period. + * + * This endpoint is proxied through `/wc/v3/woocommerce-analytics/proxy/reports/...` + * and ultimately served by wpcom analytics. + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.group_by + * @param root0.country_code + * @param root0.limit + */ +export async function fetchReportVisitorsByLocation( { + from, + to, + interval, + group_by, + country_code, + limit, +}: RequestReportVisitorsByLocationParams ): Promise< ReportsVisitorsByLocationResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-location`, { + from, + to, + interval, + group_by, + country_code, + limit, + } ); + + return apiFetch( { path } ) as Promise< ReportsVisitorsByLocationResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/index.ts new file mode 100644 index 000000000000..164ef5351eb7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/index.ts @@ -0,0 +1 @@ +export { fetchReportVisitors, type RequestReportVisitorsParams } from './report-visitors-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/report-visitors-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/report-visitors-fetch.ts new file mode 100644 index 000000000000..93895fca50cd --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/report-visitors-fetch.ts @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsVisitorsByDateSummary = { + active_sessions: string; + date_end: string; + date_start: string; + visitors: string; +}; + +type VisitorsReportDataItem = ReportsVisitorsByDateSummary & { + time_interval: string; +}; + +type ReportsVisitorsByDateResponse = { + data: VisitorsReportDataItem[]; + summary: ReportsVisitorsByDateSummary; +}; + +export type RequestReportVisitorsParams = BaseReportParams; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + */ +export async function fetchReportVisitors( { + from, + to, + interval, +}: RequestReportVisitorsParams ): Promise< ReportsVisitorsByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-date`, { + from, + to, + interval, + } ); + + return apiFetch( { path } ) as Promise< ReportsVisitorsByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/defaults/__tests__/get-default-preset.test.ts b/projects/packages/premium-analytics/packages/data/src/defaults/__tests__/get-default-preset.test.ts new file mode 100644 index 000000000000..ab6da5ee50fb --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/defaults/__tests__/get-default-preset.test.ts @@ -0,0 +1,92 @@ +/** + * Mock WordPress dependencies so date.ts can load. The select mock + * returns site settings with timezone: 'UTC' so getSiteTimezone() + * returns UTC, making localTZDate create UTC-aware TZDates. + */ +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); + +jest.mock( '@wordpress/data', () => ( { + select: jest.fn( () => ( { + getEntityRecord: jest.fn( () => ( { timezone: 'UTC' } ) ), + } ) ), +} ) ); + +jest.mock( '../../utils/ensure-core-settings', () => ( { + ensureCoreSettingsReady: jest.fn( () => Promise.resolve() ), +} ) ); +/** + * Internal dependencies + */ +import { getDefaultPreset, getDefaultQueryParams } from '../reports'; + +describe( 'getDefaultQueryParams - preset override', () => { + beforeEach( () => { + jest.useFakeTimers(); + jest.setSystemTime( new Date( '2025-03-15T12:00:00.000Z' ) ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'defaults to last-30-days when no preset is given', () => { + expect( getDefaultQueryParams().preset ).toBe( 'last-30-days' ); + } ); + + it( 'uses today preset when passed', () => { + expect( getDefaultQueryParams( false, 'today' ).preset ).toBe( 'today' ); + } ); + + it( 'uses last-7-days preset when passed', () => { + expect( getDefaultQueryParams( false, 'last-7-days' ).preset ).toBe( 'last-7-days' ); + } ); + + it( 'uses last-30-days preset when passed', () => { + expect( getDefaultQueryParams( false, 'last-30-days' ).preset ).toBe( 'last-30-days' ); + } ); +} ); + +describe( 'getDefaultPreset', () => { + beforeEach( () => { + jest.useFakeTimers(); + jest.setSystemTime( new Date( '2025-03-15T12:00:00.000Z' ) ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'returns last-30-days when no launched date', () => { + expect( getDefaultPreset() ).toBe( 'last-30-days' ); + } ); + + it( 'returns last-30-days for undefined', () => { + expect( getDefaultPreset( undefined ) ).toBe( 'last-30-days' ); + } ); + + it( 'returns today when store launched today', () => { + expect( getDefaultPreset( '2025-03-15T00:00:00Z' ) ).toBe( 'today' ); + } ); + + it( 'returns last-7-days when launched 3 days ago', () => { + expect( getDefaultPreset( '2025-03-12T00:00:00Z' ) ).toBe( 'last-7-days' ); + } ); + + it( 'returns last-7-days when launched exactly 7 days ago', () => { + expect( getDefaultPreset( '2025-03-08T00:00:00Z' ) ).toBe( 'last-7-days' ); + } ); + + it( 'returns last-30-days when launched 8 days ago', () => { + expect( getDefaultPreset( '2025-03-07T00:00:00Z' ) ).toBe( 'last-30-days' ); + } ); + + it( 'returns last-30-days when launched months ago', () => { + expect( getDefaultPreset( '2024-01-01T00:00:00Z' ) ).toBe( 'last-30-days' ); + } ); + + it( 'returns today when launched in the future', () => { + expect( getDefaultPreset( '2025-04-01T00:00:00Z' ) ).toBe( 'today' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/defaults/index.ts b/projects/packages/premium-analytics/packages/data/src/defaults/index.ts new file mode 100644 index 000000000000..1231063dab14 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/defaults/index.ts @@ -0,0 +1 @@ +export { getDefaultPreset, getDefaultQueryParams } from './reports'; diff --git a/projects/packages/premium-analytics/packages/data/src/defaults/reports.ts b/projects/packages/premium-analytics/packages/data/src/defaults/reports.ts new file mode 100644 index 000000000000..76813df11046 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/defaults/reports.ts @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { getComparisonRangeFromPreset } from '@jetpack-premium-analytics/datetime'; +import { differenceInCalendarDays, startOfDay } from 'date-fns'; +/** + * Internal dependencies + */ +import { + localTZDate, + dateToISOStringWithLocalTZ, + getDefaultIntervalForPeriod, + computeDateRangeFromPreset, + type PresetType, + type ReportParams, +} from '../utils'; + +const DEFAULT_PRESET: PresetType = 'last-30-days'; + +/** + * Pick the default date-range preset based on how long + * the store has been live. + * + * - Not launched / unknown → last-30-days (safe default) + * - Launched today → today + * - Launched ≤ 7 days ago → last-7-days + * - Launched > 7 days ago → last-30-days + * @param launchedDate + */ +export function getDefaultPreset( launchedDate?: string ): PresetType { + if ( ! launchedDate ) { + return DEFAULT_PRESET; + } + + const today = startOfDay( localTZDate() ); + const launched = startOfDay( localTZDate( launchedDate ) ); + const daysSinceLaunch = differenceInCalendarDays( today, launched ); + + if ( daysSinceLaunch <= 0 ) { + return 'today'; + } + + if ( daysSinceLaunch <= 7 ) { + return 'last-7-days'; + } + + return DEFAULT_PRESET; +} + +/** + * Build report query parameters (from, to, interval, preset) + * for the given date-range preset. Defaults to `last-30-days`. + * + * Callers that need a dynamic default (e.g. based on store + * age) should resolve the preset externally and pass it in. + * @param withComparison + * @param preset + */ +export const getDefaultQueryParams = ( + /** + * Include previous-period comparison range. + */ + withComparison: boolean = false, + + /** + * Date-range preset. Defaults to `last-30-days`. + */ + preset: PresetType = DEFAULT_PRESET +): ReportParams => { + const range = computeDateRangeFromPreset( preset ); + + if ( ! range ) { + throw new Error( `Unknown preset: ${ preset }` ); + } + + const { from: fromString, to: toString } = range; + + const interval = getDefaultIntervalForPeriod( undefined, fromString, toString ); + + if ( ! withComparison ) { + return { + from: fromString, + to: toString, + preset, + interval, + }; + } + + const from = localTZDate( new Date( fromString ) ); + const to = localTZDate( new Date( toString ) ); + + const comparisonParams = getComparisonRangeFromPreset( + { + from, + to, + }, + 'previous-period' + ); + + return { + from: fromString, + to: toString, + preset, + interval, + compare_from: comparisonParams?.from + ? dateToISOStringWithLocalTZ( comparisonParams?.from ) + : undefined, + compare_to: comparisonParams?.to + ? dateToISOStringWithLocalTZ( comparisonParams?.to ) + : undefined, + compare_preset: 'previous-period', + comp: '1', + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/index.ts b/projects/packages/premium-analytics/packages/data/src/hooks/index.ts new file mode 100644 index 000000000000..ceb26db69eed --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/index.ts @@ -0,0 +1,12 @@ +export { useReportOrders } from './use-report-orders'; +export { useReportOrderAttribution } from './use-report-order-attribution'; +export { useReportCoupons } from './use-report-coupons'; +export { useReportCouponsByDate } from './use-report-coupons-by-date'; +export { useReportCustomers } from './use-report-customers'; +export { useReportConversionRate } from './use-report-conversion-rate'; +export { useReportBookings } from './use-report-bookings'; + +/** + * @deprecated Use individual hooks instead: useReportOrders, useReportOrderAttribution, useReportCoupons + */ +export { useReport } from './use-report'; diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-product-images.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-product-images.ts new file mode 100644 index 000000000000..4c76b44e3610 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-product-images.ts @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { useQuery } from '@tanstack/react-query'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { sanitizeReportProductsResponse } from '../processing/products'; +import type { ProductImage } from '../types/product-image'; + +// Infer the product ID type from the sanitized products response +type ProductId = ReturnType< + typeof sanitizeReportProductsResponse +>[ 'data' ][ number ][ 'product_id' ]; + +export interface UseProductImagesParams { + productIds: ProductId[]; +} + +export interface ProductImageResponse { + id: number; + name: string; + images: { + id: number; + src: string; + name: string; + alt: string; + }[]; +} + +/** + * Fetches product images from the WooCommerce REST API + * @param productIds + */ +async function fetchProductImages( + productIds: ProductId[] +): Promise< ( ProductImage & { productId: ProductId } )[] > { + if ( ! productIds.length ) { + return []; + } + + // Use the include parameter to get only the products we need + const queryArgs = { + include: productIds.join( ',' ), + per_page: productIds.length, + }; + + try { + const response = await apiFetch< ProductImageResponse[] >( { + path: addQueryArgs( '/wc/v3/products', queryArgs ), + } ); + + return response.map( product => ( { + productId: product.id, + imageUrl: product.images?.[ 0 ]?.src || '', + imageAlt: product.images?.[ 0 ]?.alt || product.name, + } ) ); + } catch { + return []; + } +} + +const getProductImagesQueryKey = ( params: UseProductImagesParams ) => + [ 'product-images', params.productIds.sort().join( ',' ) ] as const; + +/** + * Hook to fetch product images for a list of product IDs + * @param params - Object containing the list of product IDs to fetch images for + */ +export function useProductImages( params: UseProductImagesParams ) { + return useQuery( { + queryKey: getProductImagesQueryKey( params ), + queryFn: async () => { + const images = await fetchProductImages( params.productIds ); + return images.reduce( + ( acc: Record< number, ProductImage >, image: ProductImage & { productId: number } ) => { + acc[ image.productId ] = { + imageUrl: image.imageUrl, + imageAlt: image.imageAlt, + }; + return acc; + }, + {} + ); + }, + enabled: params.productIds.length > 0, + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-bookings.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-bookings.ts new file mode 100644 index 000000000000..7c50d401510d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-bookings.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { reportBookingsQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportBookings( params: ReportParams ) { + return useReport( p => reportBookingsQuery( p ), params, { + disabledComparisonKey: [ 'reports', 'bookings', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-conversion-rate.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-conversion-rate.ts new file mode 100644 index 000000000000..b27ec3f9360f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-conversion-rate.ts @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import { reportConversionRateQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportConversionRateOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportConversionRate( + params: ReportParams, + options?: UseReportConversionRateOptions +) { + return useReport( p => reportConversionRateQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'conversion-rate', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons-by-date.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons-by-date.ts new file mode 100644 index 000000000000..a88b7a7b282b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons-by-date.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { reportCouponsByDateQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportCouponsByDate( params: ReportParams ) { + return useReport( p => reportCouponsByDateQuery( p ), params, { + disabledComparisonKey: [ 'reports', 'couponsByDate', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons.ts new file mode 100644 index 000000000000..205e0d4780af --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { reportCouponsQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportCoupons( params: ReportParams ) { + return useReport( p => reportCouponsQuery( p ), params, { + disabledComparisonKey: [ 'reports', 'coupons', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers-by-date.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers-by-date.ts new file mode 100644 index 000000000000..08217edd8423 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers-by-date.ts @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import { reportCustomersByDateQuery } from '../queries/report-customers-by-date-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportCustomersByDateOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportCustomersByDate( + params: ReportParams, + options?: UseReportCustomersByDateOptions +) { + return useReport( p => reportCustomersByDateQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'customers', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers.ts new file mode 100644 index 000000000000..2fa65e6724ec --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { reportCustomersQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportCustomers( params: ReportParams ) { + return useReport( p => reportCustomersQuery( p ), params, { + disabledComparisonKey: [ + 'reports', + 'customers', + 'new-returning', + '__comparison__', + 'disabled', + ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-order-attribution.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-order-attribution.ts new file mode 100644 index 000000000000..6377f6e68735 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-order-attribution.ts @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import { reportOrderAttributionSummaryQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportOrderAttributionOptions = { + enabled?: boolean; +}; + +const DISABLED_COMPARISON_KEY = [ + 'reports', + 'order-attribution', + '__comparison__', + 'included-in-primary', +]; + +/** + * + * @param params + * @param options + */ +export function useReportOrderAttribution( + params: ReportParams, + options?: UseReportOrderAttributionOptions +) { + /* + * Compare from and to are required for order attribution summary query. + * When they aren't provided, use the same dates as the primary period. + */ + const compareFrom = params.compare_from ?? params.from; + const compareTo = params.compare_to ?? params.to; + + return useReport( + ( p, queryType ) => { + // Order attribution requires the view parameter + if ( ! params.view ) { + return { + queryKey: [ 'reports', 'order-attribution', '__disabled__', 'no-view-param' ], + enabled: false, + }; + } + + if ( queryType === 'comparison' ) { + return { + queryKey: DISABLED_COMPARISON_KEY, + enabled: false, + }; + } + + return reportOrderAttributionSummaryQuery( { + ...p, + view: params.view, + compare_from: compareFrom, + compare_to: compareTo, + date_type: params.date_type, + } ); + }, + params, + { + enabled: options?.enabled, + disabledComparisonKey: DISABLED_COMPARISON_KEY, + } + ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-orders.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-orders.ts new file mode 100644 index 000000000000..89cf74039adb --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-orders.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { reportOrdersQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportOrdersOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportOrders( params: ReportParams, options?: UseReportOrdersOptions ) { + return useReport( p => reportOrdersQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'orders', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-products.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-products.ts new file mode 100644 index 000000000000..905477389c79 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-products.ts @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { reportProductsQuery } from '../queries/report-products-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + * @param limit + */ +export function useReportProducts( params: ReportParams, limit = 5 ) { + return useReport( p => reportProductsQuery( { ...p, limit } ), params, { + disabledComparisonKey: [ 'reports', 'products', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-sessions-by-device.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-sessions-by-device.ts new file mode 100644 index 000000000000..7ac25ef8f69b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-sessions-by-device.ts @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { reportSessionsByDeviceQuery } from '../queries/report-sessions-by-device-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportSessionsByDeviceOptions = { + enabled?: boolean; +}; + +/** + * Hook for fetching sessions by device type report data. + * + * Returns a breakdown of website sessions by device category + * (Mobile, Desktop, Tablet) for the specified date range. + * + * @param params - Report parameters including date range and comparison dates + * @param options - Optional configuration + * + * @example + * ```typescript + * const { primary, comparison, hasComparison, isLoading, hasData } = + * useReportSessionsByDevice( reportParams ); + * + * // Access the data + * const { summary, data } = primary.data; + * const totalSessions = summary.total_sessions; + * ``` + */ +export function useReportSessionsByDevice( + params: ReportParams, + options?: UseReportSessionsByDeviceOptions +) { + return useReport( p => reportSessionsByDeviceQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'sessions', 'by-device', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors-by-location.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors-by-location.ts new file mode 100644 index 000000000000..ac9b8146508d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors-by-location.ts @@ -0,0 +1,38 @@ +/** + * Internal dependencies + */ +import { reportVisitorsByLocationQuery } from '../queries/report-visitors-by-location-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportVisitorsByLocationOptions = { + enabled?: boolean; + groupBy?: 'country' | 'region'; + countryCode?: string; + limit?: number; +}; + +/** + * + * @param params + * @param options + */ +export function useReportVisitorsByLocation( + params: ReportParams, + options?: UseReportVisitorsByLocationOptions +) { + return useReport( + p => + reportVisitorsByLocationQuery( { + ...p, + group_by: options?.groupBy ?? 'country', + country_code: options?.countryCode, + limit: options?.limit, + } ), + params, + { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'visitors', 'by-location', '__comparison__', 'disabled' ], + } + ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors.ts new file mode 100644 index 000000000000..b6eafd7a3f3f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { reportVisitorsQuery } from '../queries/report-visitors-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportVisitorsOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportVisitors( params: ReportParams, options?: UseReportVisitorsOptions ) { + return useReport( p => reportVisitorsQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'visitors', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report.ts new file mode 100644 index 000000000000..e72a203f2e80 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report.ts @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +/** + * Internal dependencies + */ +import { hasComparisonEnabled, type ReportParams } from '../utils/search'; + +type UseReportOptions = { + enabled?: boolean; + disabledComparisonKey?: string[]; +}; + +type QueryFactory< TData > = ( + params: any, + queryType: 'primary' | 'comparison' +) => UseQueryOptions< TData >; + +/** + * Generic hook for fetching report data with comparison support. + * + * This hook handles the common pattern of fetching primary and comparison + * data for analytics reports. It automatically manages the comparison query + * based on the presence of comparison dates in the params. + * + * @template TData - The type of data returned by the query + * + * @param queryFactory - Function that creates a query options object from params + * @param params - Report parameters including dates, filters, and comparison dates + * @param options - Optional configuration + * @param options.enabled - Whether the queries should be enabled (default: true) + * @param options.disabledComparisonKey - Query key to use when comparison is disabled + * + * @return Object containing primary and comparison query results + * + * @example + * ```typescript + * const { primary, comparison, hasComparison, isLoading, hasData } = useReport( + * (params) => reportOrdersQuery(params, hasProductFilters), + * reportParams, + * { + * enabled: true, + * disabledComparisonKey: ['reports', 'orders', '__comparison__', 'disabled'], + * } + * ); + * ``` + */ +export function useReport< TData >( + queryFactory: QueryFactory< TData >, + params: ReportParams, + options?: UseReportOptions +) { + const queryEnabled = options?.enabled ?? true; + const comparisonEnabled = hasComparisonEnabled( params ); + + // Create primary query + const primaryQueryOptions = queryFactory( + { + from: params.from, + to: params.to, + interval: params.interval, + filters: params.filters, + date_type: params.date_type, + }, + 'primary' + ); + + // Create comparison query if comparison is enabled + const comparisonQueryOptions = comparisonEnabled + ? queryFactory( + { + from: params.compare_from, + to: params.compare_to, + interval: params.interval, + filters: params.filters, + date_type: params.date_type, + }, + 'comparison' + ) + : { + queryKey: options?.disabledComparisonKey ?? [ 'reports', '__comparison__', 'disabled' ], + }; + + const primary = useQuery( { + ...primaryQueryOptions, + enabled: queryEnabled && ( primaryQueryOptions.enabled ?? true ), + } ); + + const comparison = useQuery( { + ...comparisonQueryOptions, + enabled: queryEnabled && comparisonEnabled && ( comparisonQueryOptions.enabled ?? true ), + } ); + + // Compute common derived states + const isLoading = primary.isLoading || comparison.isLoading; + const isFetching = primary.isFetching || comparison.isFetching; + + /** + * Check if data exists using standardized response fields. + * + * All sanitized report responses follow a consistent structure: + * - `summary`: Always present (aggregated metrics) + * - `data`: Array of time-series or items (orders, bookings, products, etc.) + * - `steps`: Array of funnel steps (conversion-rate only) + * + * We check multiple fields because different endpoints return different combinations: + * - Time-series reports (orders, bookings, visitors): { summary, data } + * - Conversion funnel: { summary, data, steps, overallRate } + * - List reports (products, coupons): { summary, data } + * + * The use of `as any` is intentional here to handle the generic TData type, + * since we cannot add constraints to the generic without breaking existing usage. + * + * Note: With placeholderData enabled in queries, this hasData check is sufficient + * to determine loading states: + * - If hasData is true: We have data to display (show with loading indicator if fetching) + * - If hasData is false: No data to display (show skeleton) + */ + const hasData = + Boolean( ( primary.data as any )?.summary ) || + Boolean( ( primary.data as any )?.data?.length ) || + Boolean( ( primary.data as any )?.steps?.length ) || + Boolean( ( comparison.data as any )?.summary ) || + Boolean( ( comparison.data as any )?.data?.length ) || + Boolean( ( comparison.data as any )?.steps?.length ); + + // Combined refetch function that refetches both queries. + // If both primary and comparison queries fail, clicking "Retry" should refetch both. + const primaryRefetch = primary.refetch; + const comparisonRefetch = comparison.refetch; + const refetch = useCallback( async () => { + await Promise.all( [ + primaryRefetch(), + comparisonEnabled ? comparisonRefetch() : Promise.resolve(), + ] ); + }, [ comparisonEnabled, primaryRefetch, comparisonRefetch ] ); + + return { + primary, + comparison, + hasComparison: comparisonEnabled, + isLoading, + isFetching, + hasData, + // Error handling + isError: primary.isError || comparison.isError, + error: primary.error ?? comparison.error, + refetch, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/index.ts b/projects/packages/premium-analytics/packages/data/src/index.ts new file mode 100644 index 000000000000..6f02580c031e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/index.ts @@ -0,0 +1,41 @@ +export { AnalyticsQueryClientProvider, queryClient } from './providers/query-client-provider'; +export { GlobalErrorProvider, useGlobalError } from './providers/global-error-context'; +export { globalErrorManager, type GlobalErrorType } from './providers/global-error-manager'; +export { useReportOrders } from './hooks/use-report-orders'; +export { useReportOrderAttribution } from './hooks/use-report-order-attribution'; +export { useReportCoupons } from './hooks/use-report-coupons'; +export { useReportCouponsByDate } from './hooks/use-report-coupons-by-date'; +export { useReportCustomers } from './hooks/use-report-customers'; +export { useReportCustomersByDate } from './hooks/use-report-customers-by-date'; +export { useReportConversionRate } from './hooks/use-report-conversion-rate'; +export { useReportProducts } from './hooks/use-report-products'; +export { useProductImages } from './hooks/use-product-images'; +export { useReportVisitors } from './hooks/use-report-visitors'; +export { useReportVisitorsByLocation } from './hooks/use-report-visitors-by-location'; +export { useReportBookings } from './hooks/use-report-bookings'; +export { useReportSessionsByDevice } from './hooks/use-report-sessions-by-device'; +export { prefetchReport } from './prefetch'; +export { + normalizeReportParams, + hasComparisonEnabled, + type PresetType, + type ReportParams, +} from './utils/search'; +export { + dateToISOStringWithLocalTZ, + ensureCoreSettingsReady, + getSiteTimezone, + getSiteGmtOffset, + localTZDate, + hasProductFilters, + isSelectablePreset, +} from './utils'; +export type { ReportDataMap } from './types'; +export type { ReportQueryParams } from './api'; +export type { FilterCondition } from './types/filter-condition'; +export type { ProductType } from './types/product-type'; +export { ORDER_ATTRIBUTION_VIEWS } from './api/report-order-attribution-summary-fetch'; +export { getDefaultIntervalForPeriod, getDateFormatFromInterval } from './utils/interval'; +export { getDefaultPreset, getDefaultQueryParams } from './defaults'; +export { exportReport } from './api'; +export type { ExportReportParams, ExportReportResponse } from './api'; diff --git a/projects/packages/premium-analytics/packages/data/src/prefetch/index.ts b/projects/packages/premium-analytics/packages/data/src/prefetch/index.ts new file mode 100644 index 000000000000..88eaef8a0f2a --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/prefetch/index.ts @@ -0,0 +1 @@ +export { prefetchReport } from './prefetch-report'; diff --git a/projects/packages/premium-analytics/packages/data/src/prefetch/prefetch-report.ts b/projects/packages/premium-analytics/packages/data/src/prefetch/prefetch-report.ts new file mode 100644 index 000000000000..b73aac40dca0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/prefetch/prefetch-report.ts @@ -0,0 +1,103 @@ +/** + * Internal dependencies + */ +import { queryClient } from '../providers'; +import { + reportOrdersQuery, + reportOrderAttributionSummaryQuery, + reportCouponsQuery, + reportCouponsByDateQuery, + reportCustomersQuery, + reportCustomersByDateQuery, + reportVisitorsQuery, + reportVisitorsByLocationQuery, + reportSessionsByDeviceQuery, + reportProductsQuery, + reportConversionRateQuery, +} from '../queries'; + +type RequestReportParamsMap = { + orders: Parameters< typeof reportOrdersQuery >[ 0 ]; + 'order-attribution': Parameters< typeof reportOrderAttributionSummaryQuery >[ 0 ]; + coupons: Parameters< typeof reportCouponsQuery >[ 0 ]; + 'coupons-by-date': Parameters< typeof reportCouponsByDateQuery >[ 0 ]; + customers: Parameters< typeof reportCustomersQuery >[ 0 ]; + 'customers-by-date': Parameters< typeof reportCustomersByDateQuery >[ 0 ]; + visitors: Parameters< typeof reportVisitorsQuery >[ 0 ]; + 'visitors-by-location': Parameters< typeof reportVisitorsByLocationQuery >[ 0 ]; + 'sessions-by-device': Parameters< typeof reportSessionsByDeviceQuery >[ 0 ]; + products: Parameters< typeof reportProductsQuery >[ 0 ]; + 'conversion-rate': Parameters< typeof reportConversionRateQuery >[ 0 ]; +}; + +/** + * + * @param reportType + * @param params + */ +export async function prefetchReport< T extends keyof RequestReportParamsMap >( + reportType: T = 'orders' as T, + params: RequestReportParamsMap[ T ] +) { + switch ( reportType ) { + case 'orders': + return queryClient.ensureQueryData( + reportOrdersQuery( params as RequestReportParamsMap[ 'orders' ] ) + ); + + case 'order-attribution': + return queryClient.ensureQueryData( + reportOrderAttributionSummaryQuery( + params as RequestReportParamsMap[ 'order-attribution' ] + ) + ); + + case 'coupons': + return queryClient.ensureQueryData( + reportCouponsQuery( params as RequestReportParamsMap[ 'coupons' ] ) + ); + + case 'coupons-by-date': + return queryClient.ensureQueryData( + reportCouponsByDateQuery( params as RequestReportParamsMap[ 'coupons-by-date' ] ) + ); + + case 'customers': + return queryClient.ensureQueryData( + reportCustomersQuery( params as RequestReportParamsMap[ 'customers' ] ) + ); + + case 'customers-by-date': + return queryClient.ensureQueryData( + reportCustomersByDateQuery( params as RequestReportParamsMap[ 'customers-by-date' ] ) + ); + + case 'visitors': + return queryClient.ensureQueryData( + reportVisitorsQuery( params as RequestReportParamsMap[ 'visitors' ] ) + ); + + case 'visitors-by-location': + return queryClient.ensureQueryData( + reportVisitorsByLocationQuery( params as RequestReportParamsMap[ 'visitors-by-location' ] ) + ); + + case 'sessions-by-device': + return queryClient.ensureQueryData( + reportSessionsByDeviceQuery( params as RequestReportParamsMap[ 'sessions-by-device' ] ) + ); + + case 'products': + return queryClient.ensureQueryData( + reportProductsQuery( params as RequestReportParamsMap[ 'products' ] ) + ); + + case 'conversion-rate': + return queryClient.ensureQueryData( + reportConversionRateQuery( params as RequestReportParamsMap[ 'conversion-rate' ] ) + ); + + default: + throw new Error( `Unsupported report type: ${ reportType }` ); + } +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/bookings/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/bookings/index.ts new file mode 100644 index 000000000000..7073cb9e7d32 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/bookings/index.ts @@ -0,0 +1,105 @@ +/** + * Internal dependencies + */ +import { fetchReportBookings } from '../../api/report-bookings-fetch'; +import { safeParseInt } from '../../utils/parsing'; +import type { Override } from '../../utils/types'; + +type ReportsBookingsByDateResponse = Awaited< ReturnType< typeof fetchReportBookings > >; +type RawBookingsReportDataItem = ReportsBookingsByDateResponse[ 'data' ][ number ]; +type RawBookingsReportSummaryItem = ReportsBookingsByDateResponse[ 'summary' ]; + +type SanitizedBookingsByDateItem = Override< + RawBookingsReportDataItem, + { + status_unpaid: number; + status_pending_confirmation: number; + status_confirmed: number; + status_paid: number; + status_cancelled: number; + status_complete: number; + attendance_status_booked: number; + attendance_status_no_show: number; + attendance_status_checked_in: number; + } +>; + +type SanitizedBookingsSummaryItem = Override< + RawBookingsReportSummaryItem, + { + status_unpaid: number; + status_pending_confirmation: number; + status_confirmed: number; + status_paid: number; + status_cancelled: number; + status_complete: number; + attendance_status_booked: number; + attendance_status_no_show: number; + attendance_status_checked_in: number; + } +>; + +/** + * Sanitize/process a single booking item by converting strings to numbers + * @param item + */ +function sanitizeBookingItem( item: RawBookingsReportDataItem ): SanitizedBookingsByDateItem { + return { + ...item, + status_unpaid: safeParseInt( item.status_unpaid ), + status_pending_confirmation: safeParseInt( item.status_pending_confirmation ), + status_confirmed: safeParseInt( item.status_confirmed ), + status_paid: safeParseInt( item.status_paid ), + status_cancelled: safeParseInt( item.status_cancelled ), + status_complete: safeParseInt( item.status_complete ), + attendance_status_booked: safeParseInt( item.attendance_status_booked ), + attendance_status_no_show: safeParseInt( item.attendance_status_no_show ), + attendance_status_checked_in: safeParseInt( item.attendance_status_checked_in ), + }; +} + +/** + * Sanitize/process a single booking summary item by converting strings to numbers + * @param item + */ +function sanitizeBookingSummaryItem( + item: RawBookingsReportSummaryItem +): SanitizedBookingsSummaryItem { + return { + ...item, + status_unpaid: safeParseInt( item.status_unpaid ), + status_pending_confirmation: safeParseInt( item.status_pending_confirmation ), + status_confirmed: safeParseInt( item.status_confirmed ), + status_paid: safeParseInt( item.status_paid ), + status_cancelled: safeParseInt( item.status_cancelled ), + status_complete: safeParseInt( item.status_complete ), + attendance_status_booked: safeParseInt( item.attendance_status_booked ), + attendance_status_no_show: safeParseInt( item.attendance_status_no_show ), + attendance_status_checked_in: safeParseInt( item.attendance_status_checked_in ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedBookingsByDateResponse = { + summary: SanitizedBookingsSummaryItem; + data: SanitizedBookingsByDateItem[]; +}; + +/** + * Sanitize the response from the reports/bookings/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` and `data` items have different structures (summary lacks time_interval), + * so we use different sanitizer functions for each. + * @param response + */ +export const sanitizeReportBookingsResponse = ( + response: ReportsBookingsByDateResponse +): SanitizedBookingsByDateResponse => { + return { + summary: sanitizeBookingSummaryItem( response.summary ), + data: response.data.map( sanitizeBookingItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/conversion-rate/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/conversion-rate/index.ts new file mode 100644 index 000000000000..1135cf3a43f0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/conversion-rate/index.ts @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { fetchReportConversionRate } from '../../api/report-conversion-rate-fetch'; +import { safeParseInt } from '../../utils/parsing'; +import type { Override } from '../../utils/types'; + +type ReportsConversionRateByDateResponse = Awaited< + ReturnType< typeof fetchReportConversionRate > +>; +type RawConversionRateReportDataItem = ReportsConversionRateByDateResponse[ 'data' ][ number ]; +type SanitizedConversionRateByDateItem = Override< + RawConversionRateReportDataItem, + { + active_sessions: number; + visitors: number; + with_cart_addition: number; + reached_checkout: number; + completed_checkout: number; + conversion_rate: number; // calculated field + } +>; + +/** + * Sanitize/process a single conversion rate item by converting strings to numbers + * and calculating the conversion rate + * @param item + */ +function sanitizeConversionRateItem( + item: RawConversionRateReportDataItem +): SanitizedConversionRateByDateItem { + const activeSessionsNum = safeParseInt( item.active_sessions ); + const visitorsNum = safeParseInt( item.visitors ); + const withCartAdditionNum = safeParseInt( item.with_cart_addition ); + const reachedCheckoutNum = safeParseInt( item.reached_checkout ); + const completedCheckoutNum = safeParseInt( item.completed_checkout ); + + // Calculate conversion rate as decimal (e.g., 0.035 for 3.5%) + // This format works with formatMetricValue 'percentage' type + const conversionRate = activeSessionsNum > 0 ? completedCheckoutNum / activeSessionsNum : 0; + + return { + ...item, + active_sessions: activeSessionsNum, + visitors: visitorsNum, + with_cart_addition: withCartAdditionNum, + reached_checkout: reachedCheckoutNum, + completed_checkout: completedCheckoutNum, + conversion_rate: conversionRate, + }; +} + +/** + * Funnel step for conversion rate visualization + */ +type FunnelStep = { + id: string; + label: string; + count: number; + rate: number; +}; + +/** + * Processed response with funnel steps and overall conversion rate + */ +type SanitizedConversionRateByDateResponse = { + summary: SanitizedConversionRateByDateItem; + data: SanitizedConversionRateByDateItem[]; + steps: FunnelStep[]; + overallRate: number; +}; + +/** + * Sanitize the response from the sessions/by-conversion-rate endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportConversionRateResponse = ( + response: ReportsConversionRateByDateResponse +): SanitizedConversionRateByDateResponse => { + // Handle cases where response might not have the expected structure + const defaultSummary = { + active_sessions: '0', + visitors: '0', + with_cart_addition: '0', + reached_checkout: '0', + completed_checkout: '0', + date_start: '', + date_end: '', + }; + + const sanitizedSummary = sanitizeConversionRateItem( response?.summary || defaultSummary ); + + // Create funnel steps from the summary data + const steps: FunnelStep[] = [ + { + id: 'sessions', + label: __( 'Sessions', 'jetpack-premium-analytics' ), + count: sanitizedSummary.active_sessions, + rate: 100, // Starting point + }, + { + id: 'cart-addition', + label: __( 'Cart', 'jetpack-premium-analytics' ), + count: sanitizedSummary.with_cart_addition, + rate: + sanitizedSummary.active_sessions > 0 + ? ( sanitizedSummary.with_cart_addition / sanitizedSummary.active_sessions ) * 100 + : 0, + }, + { + id: 'checkout', + label: __( 'Checkout', 'jetpack-premium-analytics' ), + count: sanitizedSummary.reached_checkout, + rate: + sanitizedSummary.active_sessions > 0 + ? ( sanitizedSummary.reached_checkout / sanitizedSummary.active_sessions ) * 100 + : 0, + }, + { + id: 'completed', + label: __( 'Purchase', 'jetpack-premium-analytics' ), + count: sanitizedSummary.completed_checkout, + rate: + sanitizedSummary.active_sessions > 0 + ? ( sanitizedSummary.completed_checkout / sanitizedSummary.active_sessions ) * 100 + : 0, + }, + ]; + + return { + summary: sanitizedSummary, + data: response?.data ? response.data.map( sanitizeConversionRateItem ) : [], + steps, + overallRate: sanitizedSummary.conversion_rate, + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/coupons-by-date/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/coupons-by-date/index.ts new file mode 100644 index 000000000000..c8af73181fce --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/coupons-by-date/index.ts @@ -0,0 +1,105 @@ +/** + * Internal dependencies + */ +import { fetchReportCouponsByDate } from '../../api/report-coupons-by-date-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCouponsByDateResponse = Awaited< ReturnType< typeof fetchReportCouponsByDate > >; +type RawSummary = ReportsCouponsByDateResponse[ 'summary' ]; +type RawDataItem = ReportsCouponsByDateResponse[ 'data' ][ number ]; + +/** + * Processed summary with numeric values. + */ +type SanitizedCouponsByDateSummary = Override< + RawSummary, + { + total_orders: number; + orders_with_coupon: number; + orders_without_coupon: number; + total_sales: number; + sales_with_coupon: number; + sales_without_coupon: number; + total_discount_amount: number; + net_sales_after_discount: number; + coupon_usage_percentage: number; + } +>; + +/** + * Processed data item with numeric values. + */ +type SanitizedCouponsByDateDataItem = Override< + RawDataItem, + { + total_orders: number; + orders_with_coupon: number; + orders_without_coupon: number; + total_sales: number; + sales_with_coupon: number; + sales_without_coupon: number; + total_discount_amount: number; + net_sales_after_discount: number; + coupon_usage_percentage: number; + } +>; + +/** + * Processed response with numeric values. + */ +type SanitizedCouponsByDateResponse = { + summary: SanitizedCouponsByDateSummary; + data: SanitizedCouponsByDateDataItem[]; +}; + +/** + * + * @param item + */ +function sanitizeItem( item: RawDataItem ): SanitizedCouponsByDateDataItem { + return { + ...item, + total_orders: parseInt( item.total_orders, 10 ), + orders_with_coupon: parseInt( item.orders_with_coupon, 10 ), + orders_without_coupon: parseInt( item.orders_without_coupon, 10 ), + total_sales: parseFloat( item.total_sales ), + sales_with_coupon: parseFloat( item.sales_with_coupon ), + sales_without_coupon: parseFloat( item.sales_without_coupon ), + total_discount_amount: parseFloat( item.total_discount_amount ), + net_sales_after_discount: parseFloat( item.net_sales_after_discount ), + coupon_usage_percentage: parseFloat( item.coupon_usage_percentage ), + }; +} + +/** + * + * @param summary + */ +function sanitizeSummary( summary: RawSummary ): SanitizedCouponsByDateSummary { + return { + ...summary, + total_orders: parseInt( summary.total_orders, 10 ), + orders_with_coupon: parseInt( summary.orders_with_coupon, 10 ), + orders_without_coupon: parseInt( summary.orders_without_coupon, 10 ), + total_sales: parseFloat( summary.total_sales ), + sales_with_coupon: parseFloat( summary.sales_with_coupon ), + sales_without_coupon: parseFloat( summary.sales_without_coupon ), + total_discount_amount: parseFloat( summary.total_discount_amount ), + net_sales_after_discount: parseFloat( summary.net_sales_after_discount ), + coupon_usage_percentage: parseFloat( summary.coupon_usage_percentage ), + }; +} + +/** + * Sanitize the response from the reports/coupons/by-date endpoint. + * Converts string values to numbers for calculations and charting. + * @param response + */ +export const sanitizeReportCouponsByDateResponse = ( + response: ReportsCouponsByDateResponse +): SanitizedCouponsByDateResponse => { + return { + summary: sanitizeSummary( response.summary ), + data: response.data.map( sanitizeItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/coupons/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/coupons/index.ts new file mode 100644 index 000000000000..1f40db489cd3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/coupons/index.ts @@ -0,0 +1,81 @@ +/** + * Internal dependencies + */ +import { fetchReportCoupons } from '../../api/report-coupons-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCouponsResponse = Awaited< ReturnType< typeof fetchReportCoupons > >; +type RawCouponsDataItem = ReportsCouponsResponse[ 'data' ][ number ]; +type RawCouponsDataSummary = ReportsCouponsResponse[ 'summary' ]; + +/** + * Processed data item (numbers for calculations) + */ +type SanitizedCouponsDataItem = Override< + RawCouponsDataItem, + { + discount_amount: number; + total_sales: number; + orders_count: number; + } +>; + +/** + * Processed summary (numbers for calculations) + */ +type SanitizedCouponsDataSummary = Override< + RawCouponsDataSummary, + { + total_sales: number; + total_discount_amount: number; + total_orders: number; + } +>; + +/** + * Processed response with numeric values + */ +type SanitizedCouponsResponse = { + summary: SanitizedCouponsDataSummary; + data: SanitizedCouponsDataItem[]; +}; + +/** + * Sanitize/process a single coupon item by converting strings to numbers + * @param item + */ +function sanitizeCouponItem( item: RawCouponsDataItem ): SanitizedCouponsDataItem { + return { + ...item, + discount_amount: parseFloat( item.discount_amount ), + total_sales: parseFloat( item.total_sales ), + orders_count: parseInt( item.orders_count, 10 ), + }; +} + +/** + * Sanitize/process summary by converting strings to numbers + * @param summary + */ +function sanitizeCouponSummary( summary: RawCouponsDataSummary ): SanitizedCouponsDataSummary { + return { + ...summary, + total_sales: parseFloat( summary.total_sales ), + total_discount_amount: parseFloat( summary.total_discount_amount ), + total_orders: parseInt( summary.total_orders, 10 ), + }; +} + +/** + * Sanitize the response from the reports/coupons endpoint + * Converts string values to numbers for easier calculations and charting. + * @param response + */ +export const sanitizeReportCouponsResponse = ( + response: ReportsCouponsResponse +): SanitizedCouponsResponse => { + return { + summary: sanitizeCouponSummary( response.summary ), + data: response.data.map( sanitizeCouponItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/customers-by-date/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/customers-by-date/index.ts new file mode 100644 index 000000000000..8b098d7adde2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/customers-by-date/index.ts @@ -0,0 +1,149 @@ +/** + * Internal dependencies + */ +import { fetchReportCustomersByDate } from '../../api/report-customers-by-date-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCustomersByDateResponse = Awaited< ReturnType< typeof fetchReportCustomersByDate > >; +type RawCustomersByDateSummary = ReportsCustomersByDateResponse[ 'summary' ]; +type RawCustomersByDateItem = ReportsCustomersByDateResponse[ 'data' ][ number ]; + +/** + * Processed summary (numbers for calculations) + */ +type SanitizedCustomersByDateSummary = Override< + RawCustomersByDateSummary, + { + total_net_sales: number; + total_gross_sales: number; + total_discounts: number; + total_refunds: number; + total_orders: number; + total_average_order_value: number; + total_avg_items_per_order: number; + total_customers: number; + new_customers: number; + returning_customers: number; + new_customer_sales: number; + new_customer_gross_sales: number; + new_customer_discounts: number; + new_customer_refunds: number; + new_customer_orders: number; + new_customer_avg_order_value: number; + new_customer_avg_items_per_order: number; + returning_customer_sales: number; + returning_customer_gross_sales: number; + returning_customer_discounts: number; + returning_customer_refunds: number; + returning_customer_orders: number; + returning_customer_avg_order_value: number; + returning_customer_avg_items_per_order: number; + // Add computed field for compatibility + customers: number; + } +>; + +/** + * Processed item (numbers for calculations) + */ +type SanitizedCustomersByDateItem = Override< + RawCustomersByDateItem, + { + total_customers: number; + new_customers: number; + returning_customers: number; + orders_count: number; + new_customer_orders: number; + returning_customer_orders: number; + net_sales: number; + new_customer_net_sales: number; + returning_customer_net_sales: number; + // Add computed field for compatibility + customers: number; + } +>; + +/** + * Processed response with numeric values + */ +export type SanitizedCustomersByDateResponse = { + summary: SanitizedCustomersByDateSummary; + data: SanitizedCustomersByDateItem[]; +}; + +/** + * Sanitize/process a single customer item by converting strings to numbers + * @param item + */ +function sanitizeCustomerByDateItem( item: RawCustomersByDateItem ): SanitizedCustomersByDateItem { + const totalCustomers = parseInt( item.total_customers, 10 ); + return { + ...item, + total_customers: totalCustomers, + new_customers: parseInt( item.new_customers, 10 ), + returning_customers: parseInt( item.returning_customers, 10 ), + orders_count: parseInt( item.orders_count, 10 ), + new_customer_orders: parseInt( item.new_customer_orders, 10 ), + returning_customer_orders: parseInt( item.returning_customer_orders, 10 ), + net_sales: parseFloat( item.net_sales ), + new_customer_net_sales: parseFloat( item.new_customer_net_sales ), + returning_customer_net_sales: parseFloat( item.returning_customer_net_sales ), + // Add alias for compatibility with chart builder + customers: totalCustomers, + }; +} + +/** + * Sanitize/process the summary by converting strings to numbers + * @param summary + */ +function sanitizeCustomerByDateSummary( + summary: RawCustomersByDateSummary +): SanitizedCustomersByDateSummary { + const totalCustomers = parseInt( summary.total_customers, 10 ); + return { + ...summary, + total_net_sales: parseFloat( summary.total_net_sales ), + total_gross_sales: parseFloat( summary.total_gross_sales ), + total_discounts: parseFloat( summary.total_discounts ), + total_refunds: parseFloat( summary.total_refunds ), + total_orders: parseInt( summary.total_orders, 10 ), + total_average_order_value: parseFloat( summary.total_average_order_value ), + total_avg_items_per_order: parseFloat( summary.total_avg_items_per_order ), + total_customers: totalCustomers, + new_customers: parseInt( summary.new_customers, 10 ), + returning_customers: parseInt( summary.returning_customers, 10 ), + new_customer_sales: parseFloat( summary.new_customer_sales ), + new_customer_gross_sales: parseFloat( summary.new_customer_gross_sales ), + new_customer_discounts: parseFloat( summary.new_customer_discounts ), + new_customer_refunds: parseFloat( summary.new_customer_refunds ), + new_customer_orders: parseInt( summary.new_customer_orders, 10 ), + new_customer_avg_order_value: parseFloat( summary.new_customer_avg_order_value ), + new_customer_avg_items_per_order: parseFloat( summary.new_customer_avg_items_per_order ), + returning_customer_sales: parseFloat( summary.returning_customer_sales ), + returning_customer_gross_sales: parseFloat( summary.returning_customer_gross_sales ), + returning_customer_discounts: parseFloat( summary.returning_customer_discounts ), + returning_customer_refunds: parseFloat( summary.returning_customer_refunds ), + returning_customer_orders: parseInt( summary.returning_customer_orders, 10 ), + returning_customer_avg_order_value: parseFloat( summary.returning_customer_avg_order_value ), + returning_customer_avg_items_per_order: parseFloat( + summary.returning_customer_avg_items_per_order + ), + // Add alias for compatibility with chart builder + customers: totalCustomers, + }; +} + +/** + * Sanitize the response from the reports/customers/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * @param response + */ +export const sanitizeReportCustomersByDateResponse = ( + response: ReportsCustomersByDateResponse +): SanitizedCustomersByDateResponse => { + return { + summary: sanitizeCustomerByDateSummary( response.summary ), + data: response.data.map( sanitizeCustomerByDateItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/customers/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/customers/index.ts new file mode 100644 index 000000000000..780eca7c831f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/customers/index.ts @@ -0,0 +1,85 @@ +/** + * Internal dependencies + */ +import { fetchReportCustomers } from '../../api/report-customers-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCustomersNewReturningResponse = Awaited< ReturnType< typeof fetchReportCustomers > >; +type RawCustomersNewReturningSummary = ReportsCustomersNewReturningResponse[ 'summary' ]; +type RawCustomersNewReturningItem = ReportsCustomersNewReturningResponse[ 'data' ][ number ]; + +/** + * Processed summary (numbers for calculations) + */ +type SanitizedCustomersNewReturningSummary = Override< + RawCustomersNewReturningSummary, + { + total_net_sales: number; + total_orders: number; + new_customer_sales: number; + returning_customer_sales: number; + } +>; + +/** + * Processed item (numbers for calculations) + */ +type SanitizedCustomersNewReturningItem = Override< + RawCustomersNewReturningItem, + { + net_sales: number; + orders_count: number; + } +>; + +/** + * Processed response with numeric values + */ +type SanitizedCustomersNewReturningResponse = { + summary: SanitizedCustomersNewReturningSummary; + data: SanitizedCustomersNewReturningItem[]; +}; + +/** + * Sanitize/process a single customer item by converting strings to numbers + * @param item + */ +function sanitizeCustomerItem( + item: RawCustomersNewReturningItem +): SanitizedCustomersNewReturningItem { + return { + ...item, + net_sales: parseFloat( item.net_sales ), + orders_count: parseInt( item.orders_count, 10 ), + }; +} + +/** + * Sanitize/process the summary by converting strings to numbers + * @param summary + */ +function sanitizeCustomerSummary( + summary: RawCustomersNewReturningSummary +): SanitizedCustomersNewReturningSummary { + return { + ...summary, + total_net_sales: parseFloat( summary.total_net_sales ), + total_orders: parseInt( summary.total_orders, 10 ), + new_customer_sales: parseFloat( summary.new_customer_sales ), + returning_customer_sales: parseFloat( summary.returning_customer_sales ), + }; +} + +/** + * Sanitize the response from the reports/customers/new-returning endpoint + * Converts string values to numbers for easier calculations and charting. + * @param response + */ +export const sanitizeReportCustomersResponse = ( + response: ReportsCustomersNewReturningResponse +): SanitizedCustomersNewReturningResponse => { + return { + summary: sanitizeCustomerSummary( response.summary ), + data: response.data.map( sanitizeCustomerItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/index.ts new file mode 100644 index 000000000000..2e9bf6a848fa --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/index.ts @@ -0,0 +1,12 @@ +// Resource-specific processing +export * from './orders'; +export * from './customers'; +export * from './products'; +export * from './visitors'; +export * from './visitors-by-location'; + +// TODO: Add coupons processing functions +// export * from './coupons'; + +// TODO: Add order attribution processing functions +// export * from './order-attribution'; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/index.ts new file mode 100644 index 000000000000..c98abdb066b7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/index.ts @@ -0,0 +1,2 @@ +export * from './sanitize-order-attribution-summary-response'; +export * from './normalize-order-attribution-by-product-response'; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/normalize-order-attribution-by-product-response.ts b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/normalize-order-attribution-by-product-response.ts new file mode 100644 index 000000000000..5ed83bf8404e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/normalize-order-attribution-by-product-response.ts @@ -0,0 +1,82 @@ +/** + * Internal dependencies + */ +import type { OrderAttributionByProductResponse } from '../../api/report-order-attribution-by-product-fetch'; +import type { OrderAttributionSummaryResponse } from '../../api/report-order-attribution-summary-fetch'; + +/** + * Normalizes the order-attribution-by-product API response to match the + * structure of the regular order-attribution API response. + * + * The new API has a flatter structure without current_period/previous_period nesting, + * so we need to transform it to match the expected format for widgets. + * + * @param currentResponse - Response from the current period request + * @param previousResponse - Optional response from the comparison period request + * @return Normalized response matching OrderAttributionSummaryResponse structure + */ +export function normalizeOrderAttributionByProductResponse( + currentResponse: OrderAttributionByProductResponse, + previousResponse?: OrderAttributionByProductResponse +): OrderAttributionSummaryResponse { + // Create a map for quick lookup of previous period data by item + const previousDataMap = new Map< string, ( typeof currentResponse.data )[ 0 ] >(); + if ( previousResponse ) { + previousResponse.data.forEach( item => { + previousDataMap.set( item.item, item ); + } ); + } + + // Transform the flat structure to nested structure + const normalizedData = currentResponse.data.map( currentItem => { + const previousItem = previousDataMap.get( currentItem.item ); + + // If no previous response provided (no comparison), use current data for both periods + // This matches the behavior of the existing API when compare_from/to equal from/to + const previousValue = previousItem?.value || currentItem.value; + const previousIntervals = previousItem?.intervals || currentItem.intervals; + + return { + item: currentItem.item, + current_period: { + value: currentItem.value, + intervals: currentItem.intervals, + }, + previous_period: { + value: previousValue, + intervals: previousIntervals, + }, + }; + } ); + + // Handle items that exist in previous period but not in current + // This ensures we don't lose data when an item had sales in the previous period but not current + if ( previousResponse ) { + previousResponse.data.forEach( previousItem => { + const existsInCurrent = currentResponse.data.some( item => item.item === previousItem.item ); + + if ( ! existsInCurrent ) { + normalizedData.push( { + item: previousItem.item, + current_period: { + value: '0', + intervals: previousItem.intervals.map( interval => ( { + ...interval, + net_sales: '0', + } ) ), + }, + previous_period: { + value: previousItem.value, + intervals: previousItem.intervals, + }, + } ); + } + } ); + } + + return { + view: currentResponse.view, + order_by: currentResponse.order_by, + data: normalizedData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/sanitize-order-attribution-summary-response.ts b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/sanitize-order-attribution-summary-response.ts new file mode 100644 index 000000000000..09572282b098 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/sanitize-order-attribution-summary-response.ts @@ -0,0 +1,118 @@ +/** + * Internal dependencies + */ +import { fetchReportOrderAttributionSummary } from '../../api/report-order-attribution-summary-fetch'; +import { sanitizeStringNumber } from '../utils'; + +type OrderAttributionSummaryResponse = Awaited< + ReturnType< typeof fetchReportOrderAttributionSummary > +>; + +type OrderAttributionView = OrderAttributionSummaryResponse[ 'view' ]; + +/** + * Internal types for processing + */ +type OrderAttributionInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: string; +}; + +type OrderAttributionPeriod = { + value: string; + intervals: OrderAttributionInterval[]; +}; + +type OrderAttributionSummaryItem = { + item: string; + current_period: OrderAttributionPeriod; + previous_period: OrderAttributionPeriod; +}; + +/** + * Processed (sanitized) response types + */ +type SanitizedOrderAttributionInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: number; +}; + +type SanitizedOrderAttributionPeriod = { + value: number; + intervals: SanitizedOrderAttributionInterval[]; +}; + +type SanitizedOrderAttributionSummaryItem = { + item: string; + current_period: SanitizedOrderAttributionPeriod; + previous_period: SanitizedOrderAttributionPeriod; +}; + +export type SanitizedOrderAttributionSummaryResponse = { + view: OrderAttributionView; + order_by: string; + data: SanitizedOrderAttributionSummaryItem[]; +}; + +/** + * Sanitizes a single interval by converting string net_sales to number + * @param interval + */ +function sanitizeOrderAttributionInterval( + interval: OrderAttributionInterval +): SanitizedOrderAttributionInterval { + return { + time_interval: interval.time_interval, + date_start: interval.date_start, + date_end: interval.date_end, + net_sales: sanitizeStringNumber( interval.net_sales ), + }; +} + +/** + * Sanitizes a period by converting value to number and intervals + * @param period + */ +function sanitizeOrderAttributionPeriod( + period: OrderAttributionPeriod +): SanitizedOrderAttributionPeriod { + return { + value: sanitizeStringNumber( period.value ), + intervals: period.intervals.map( sanitizeOrderAttributionInterval ), + }; +} + +/** + * Sanitizes a single order attribution summary item + * @param item + */ +function sanitizeOrderAttributionSummaryItem( + item: OrderAttributionSummaryItem +): SanitizedOrderAttributionSummaryItem { + return { + item: item.item, + current_period: sanitizeOrderAttributionPeriod( item.current_period ), + previous_period: sanitizeOrderAttributionPeriod( item.previous_period ), + }; +} + +/** + * Sanitizes the order attribution summary response by converting all string + * numbers to actual numbers + * + * @param response - Raw API response from /summary endpoint + * @return Sanitized response with numbers instead of strings + */ +export function sanitizeReportOrderAttributionSummaryResponse( + response: OrderAttributionSummaryResponse +): SanitizedOrderAttributionSummaryResponse { + return { + view: response.view, + order_by: response.order_by, + data: response.data.map( sanitizeOrderAttributionSummaryItem ), + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/orders-by-product-type/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/orders-by-product-type/index.ts new file mode 100644 index 000000000000..75be705212bb --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/orders-by-product-type/index.ts @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { safeParseFloat, safeParseInt } from '../../utils/parsing'; +import type { + ReportsOrdersByDateResponse, + RequestReportOrdersParams, +} from '../../api/report-orders-fetch'; +import type { Override } from '../../utils/types'; + +/** + * Re-export the request params type for backwards compatibility. + * The orders-by-product-type endpoint uses the same request/response + * types as the orders endpoint. + */ +export type { RequestReportOrdersParams as RequestReportOrdersByProductTypeParams }; + +type ReportsOrdersByProductTypeByDateResponse = ReportsOrdersByDateResponse; +type RawOrdersByProductTypeReportDataItem = + ReportsOrdersByProductTypeByDateResponse[ 'data' ][ number ]; +type SanitizedOrdersByProductTypeByDateItem = Override< + RawOrdersByProductTypeReportDataItem, + { + average_order_value: number; + avg_items: number; + cogs_amount: number; + coupons: number; + orders_no: number; + orders_value_gross: number; + orders_value_net: number; + product_net_revenue: number; + profit_margin: number; + refunds: number; + total_sales: number; + } +>; + +/** + * Sanitize/process a single orders by product type item by converting strings to numbers + * @param item + */ +function sanitizeOrdersByProductTypeItem( + item: RawOrdersByProductTypeReportDataItem +): SanitizedOrdersByProductTypeByDateItem { + return { + ...item, + average_order_value: safeParseFloat( item.average_order_value ), + avg_items: safeParseFloat( item.avg_items ), + cogs_amount: safeParseFloat( item.cogs_amount ), + coupons: safeParseInt( item.coupons ), + orders_no: safeParseInt( item.orders_no ), + orders_value_gross: safeParseFloat( item.orders_value_gross ), + orders_value_net: safeParseFloat( item.orders_value_net ), + product_net_revenue: safeParseFloat( item.product_net_revenue ), + profit_margin: safeParseFloat( item.profit_margin ), + refunds: safeParseFloat( item.refunds ), + total_sales: safeParseFloat( item.total_sales ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedOrdersByProductTypeByDateResponse = { + summary: SanitizedOrdersByProductTypeByDateItem; + data: SanitizedOrdersByProductTypeByDateItem[]; +}; + +/** + * Sanitize the response from the reports/orders-by-product-type/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportOrdersByProductTypeResponse = ( + response: ReportsOrdersByProductTypeByDateResponse +): SanitizedOrdersByProductTypeByDateResponse => { + return { + summary: sanitizeOrdersByProductTypeItem( response.summary ), + data: response.data.map( sanitizeOrdersByProductTypeItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/orders/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/orders/index.ts new file mode 100644 index 000000000000..00b7a8acf6d4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/orders/index.ts @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import { fetchReportOrders } from '../../api/report-orders-fetch'; +import { safeParseFloat, safeParseInt } from '../../utils/parsing'; +import type { Override } from '../../utils/types'; + +type ReportsOrdersByDateResponse = Awaited< ReturnType< typeof fetchReportOrders > >; +type RawOrdersReportDataItem = ReportsOrdersByDateResponse[ 'data' ][ number ]; +type SanitizedOrdersByDateItem = Override< + RawOrdersReportDataItem, + { + average_order_value: number; + avg_items: number; + cogs_amount: number; + coupons: number; + orders_no: number; + orders_value_gross: number; + orders_value_net: number; + paid_orders_count: number; + paid_net_sales: number; + product_net_revenue: number; + profit_margin: number; + refunds: number; + total_sales: number; + unpaid_orders_count: number; + unpaid_net_sales: number; + } +>; + +/** + * Sanitize/process a single order item by converting strings to numbers + * @param item + */ +function sanitizeOrderItem( item: RawOrdersReportDataItem ): SanitizedOrdersByDateItem { + return { + ...item, + average_order_value: safeParseFloat( item.average_order_value ), + avg_items: safeParseFloat( item.avg_items ), + cogs_amount: safeParseFloat( item.cogs_amount ), + coupons: safeParseInt( item.coupons ), + orders_no: safeParseInt( item.orders_no ), + orders_value_gross: safeParseFloat( item.orders_value_gross ), + orders_value_net: safeParseFloat( item.orders_value_net ), + paid_orders_count: safeParseInt( item.paid_orders_count ), + paid_net_sales: safeParseFloat( item.paid_net_sales ), + product_net_revenue: safeParseFloat( item.product_net_revenue ), + profit_margin: safeParseFloat( item.profit_margin ), + refunds: safeParseFloat( item.refunds ), + total_sales: safeParseFloat( item.total_sales ), + unpaid_orders_count: safeParseInt( item.unpaid_orders_count ), + unpaid_net_sales: safeParseFloat( item.unpaid_net_sales ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedOrdersByDateResponse = { + summary: SanitizedOrdersByDateItem; + data: SanitizedOrdersByDateItem[]; +}; + +/** + * Sanitize the response from the reports/orders/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportOrdersResponse = ( + response: ReportsOrdersByDateResponse +): SanitizedOrdersByDateResponse => { + return { + summary: sanitizeOrderItem( response.summary ), + data: response.data.map( sanitizeOrderItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/products/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/products/index.ts new file mode 100644 index 000000000000..6d2ba237964f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/products/index.ts @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ +import { fetchReportProducts } from '../../api/report-products-fetch'; +import type { Override } from '../../utils/types'; + +type ReportProductsResponse = Awaited< ReturnType< typeof fetchReportProducts > >; + +type RawProductsReportDataItem = ReportProductsResponse[ 'data' ][ number ]; +type RawProductsReportSummary = ReportProductsResponse[ 'summary' ]; + +type SanitizedProductsItem = Override< + RawProductsReportDataItem, + { + product_id: number; + orders_count: number; + product_net_revenue: number; + total_quantity: number; + } +>; + +type SanitizedProductsSummary = Override< + RawProductsReportSummary, + { + total_orders: number; + total_products: number; + total_quantity: number; + total_revenue: number; + } +>; + +/** + * Sanitize/process a single product item by converting strings to numbers + * @param item + */ +function sanitizeProductItem( item: RawProductsReportDataItem ): SanitizedProductsItem { + return { + ...item, + product_id: parseInt( item.product_id, 10 ), + orders_count: parseInt( item.orders_count, 10 ), + product_net_revenue: parseFloat( item.product_net_revenue ), + total_quantity: parseInt( item.total_quantity, 10 ), + }; +} + +/** + * + * @param summary + */ +function sanitizeProductSummary( summary: RawProductsReportSummary ): SanitizedProductsSummary { + return { + ...summary, + total_orders: parseInt( summary.total_orders, 10 ), + total_products: parseInt( summary.total_products, 10 ), + total_quantity: parseInt( summary.total_quantity, 10 ), + total_revenue: parseFloat( summary.total_revenue ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedProductsResponse = { + summary: SanitizedProductsSummary; + data: SanitizedProductsItem[]; +}; + +/** + * Sanitize the response from the reports/products endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportProductsResponse = ( + response: ReportProductsResponse +): SanitizedProductsResponse => { + return { + summary: sanitizeProductSummary( response.summary ), + data: ( response.data || [] ).map( sanitizeProductItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/sessions-by-device/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/sessions-by-device/index.ts new file mode 100644 index 000000000000..127c39843e31 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/sessions-by-device/index.ts @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import { fetchReportSessionsByDevice } from '../../api/report-sessions-by-device-fetch'; + +/** + * Inferred types from fetch response + */ +type ReportsSessionsByDeviceResponse = Awaited< ReturnType< typeof fetchReportSessionsByDevice > >; + +/** + * Raw item type from API response + */ +type SessionsByDeviceItem = ReportsSessionsByDeviceResponse[ 'data' ][ number ]; + +/** + * Sanitized item with numeric values + */ +type SanitizedSessionsByDeviceItem = { + device_type: string; + active_sessions: number; +}; + +/** + * Summary with total sessions + */ +type SessionsByDeviceSummary = { + total_sessions: number; +}; + +/** + * Processed response structure + */ +type SanitizedSessionsByDeviceResponse = { + summary: SessionsByDeviceSummary; + data: SanitizedSessionsByDeviceItem[]; +}; + +/** + * Sanitize a single sessions by device item by converting strings to numbers. + * + * @param item - Raw item from API response + */ +function sanitizeSessionsByDeviceItem( item: SessionsByDeviceItem ): SanitizedSessionsByDeviceItem { + return { + device_type: item.device_type || '', + active_sessions: parseInt( item.active_sessions, 10 ) || 0, + }; +} + +/** + * Sanitize the response from the sessions/by-device endpoint. + * + * Converts string values to numbers for easier calculations and charting. + * Also calculates total sessions summary. + * + * @param response - Raw API response with summary and items + */ +export const sanitizeReportSessionsByDeviceResponse = ( + response: ReportsSessionsByDeviceResponse +): SanitizedSessionsByDeviceResponse => { + const items = response?.data ?? []; + const data = items + .filter( item => item.device_type ) // Filter out empty device types + .map( sanitizeSessionsByDeviceItem ); + + const totalSessions = data.reduce( ( acc, item ) => acc + item.active_sessions, 0 ); + + return { + summary: { + total_sessions: totalSessions, + }, + data, + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/utils.ts b/projects/packages/premium-analytics/packages/data/src/processing/utils.ts new file mode 100644 index 000000000000..c0dc5636992a --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/utils.ts @@ -0,0 +1,11 @@ +/** + * Converts a string number to an actual number, with fallback to 0 + * for invalid values. + * + * @param value - String number from API + * @return Parsed number or 0 if invalid + */ +export function sanitizeStringNumber( value: string ): number { + const parsed = parseFloat( value ); + return isNaN( parsed ) ? 0 : parsed; +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/visitors-by-location/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/visitors-by-location/index.ts new file mode 100644 index 000000000000..b9dac4936c16 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/visitors-by-location/index.ts @@ -0,0 +1,78 @@ +/** + * Internal dependencies + */ +import { fetchReportVisitorsByLocation } from '../../api/report-visitors-by-location-fetch'; +import type { Override } from '../../utils/types'; + +/** + * Inferred types + */ +type ReportsVisitorsByLocationResponse = Awaited< + ReturnType< typeof fetchReportVisitorsByLocation > +>; +type RawVisitorsByLocationItem = ReportsVisitorsByLocationResponse[ 'data' ][ number ]; +type RawVisitorsByLocationSummary = NonNullable< ReportsVisitorsByLocationResponse[ 'summary' ] >; + +type SanitizedVisitorsByLocationItem = Override< + RawVisitorsByLocationItem, + { + visitors: number; + } +>; + +type SanitizedVisitorsByLocationSummary = Override< + RawVisitorsByLocationSummary, + { + visitors: number; + } +>; + +/** + * + * @param item + */ +function sanitizeVisitorsByLocationItem( + item: RawVisitorsByLocationItem +): SanitizedVisitorsByLocationItem { + const visitors = Number.parseInt( item.visitors, 10 ); + + return { + ...item, + visitors: Number.isNaN( visitors ) ? 0 : visitors, + }; +} + +/** + * + * @param summary + */ +function sanitizeVisitorsByLocationSummary( + summary: RawVisitorsByLocationSummary +): SanitizedVisitorsByLocationSummary { + const visitors = Number.parseInt( summary.visitors, 10 ); + + return { + ...summary, + visitors: Number.isNaN( visitors ) ? 0 : visitors, + }; +} + +type SanitizedVisitorsByLocationResponse = { + summary: SanitizedVisitorsByLocationSummary; + data: SanitizedVisitorsByLocationItem[]; +}; + +export const sanitizeReportVisitorsByLocationResponse = ( + response: ReportsVisitorsByLocationResponse +): SanitizedVisitorsByLocationResponse => { + const defaultSummary: RawVisitorsByLocationSummary = { + visitors: '0', + date_start: '', + date_end: '', + }; + + return { + summary: sanitizeVisitorsByLocationSummary( response?.summary ?? defaultSummary ), + data: response?.data ? response.data.map( sanitizeVisitorsByLocationItem ) : [], + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/visitors/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/visitors/index.ts new file mode 100644 index 000000000000..7d14cf71ebc1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/visitors/index.ts @@ -0,0 +1,80 @@ +/** + * Internal dependencies + */ +import { fetchReportVisitors } from '../../api/report-visitors-fetch'; +import type { Override } from '../../utils/types'; + +/** + * Inferred types + */ +type ReportsVisitorsByDateResponse = Awaited< ReturnType< typeof fetchReportVisitors > >; +type RawVisitorsReportDataItem = ReportsVisitorsByDateResponse[ 'data' ][ number ]; +type RawVisitorsReportDataSummary = ReportsVisitorsByDateResponse[ 'summary' ]; + +type SanitizedVisitorsByDateItem = Override< + RawVisitorsReportDataItem, + { + active_sessions: number; + visitors: number; + time_interval?: string; + } +>; + +type SanitizedVisitorsByDateSummary = Override< + RawVisitorsReportDataSummary, + { + active_sessions: number; + visitors: number; + } +>; + +type SanitizeVisitorsItemArg = Override< + RawVisitorsReportDataItem, + { + time_interval?: string; + } +>; + +/** + * Sanitize/process a single visitors item by converting strings to numbers + * @param item + */ +function sanitizeVisitorsItem( item: SanitizeVisitorsItemArg ): SanitizedVisitorsByDateItem { + return { + ...item, + active_sessions: parseInt( item.active_sessions, 10 ), + visitors: parseInt( item.visitors, 10 ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedVisitorsByDateResponse = { + summary: SanitizedVisitorsByDateSummary; + data: SanitizedVisitorsByDateItem[]; +}; + +/** + * Sanitize the response from the sessions/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportVisitorsResponse = ( + response: ReportsVisitorsByDateResponse +): SanitizedVisitorsByDateResponse => { + const defaultSummary = { + active_sessions: '0', + visitors: '0', + date_start: '', + date_end: '', + }; + + return { + summary: sanitizeVisitorsItem( response?.summary ?? defaultSummary ), + data: response?.data ? response.data.map( sanitizeVisitorsItem ) : [], + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/providers/global-error-context.tsx b/projects/packages/premium-analytics/packages/data/src/providers/global-error-context.tsx new file mode 100644 index 000000000000..cf1058f6255e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/global-error-context.tsx @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { onlineManager } from '@tanstack/react-query'; +import { + createContext, + useContext, + useEffect, + useMemo, + useSyncExternalStore, + type ReactNode, +} from 'react'; +/** + * Internal dependencies + */ +import { globalErrorManager, type GlobalErrorType } from './global-error-manager'; + +interface GlobalErrorContextValue { + globalError: GlobalErrorType; + setGlobalError: ( error: GlobalErrorType ) => void; + clearGlobalError: () => void; + isGlobalError: boolean; +} + +const GlobalErrorContext = createContext< GlobalErrorContextValue | null >( null ); + +/** + * Connects React to the global error manager via useSyncExternalStore. + * Also subscribes to network status changes via onlineManager. + * @param root0 + * @param root0.children + */ +export function GlobalErrorProvider( { children }: { children: ReactNode } ) { + const globalError = useSyncExternalStore( + globalErrorManager.subscribe, + globalErrorManager.getError, + globalErrorManager.getError + ); + + /** + * Subscribe to TanStack Query's onlineManager to detect network status. + * + * When offline, TanStack Query pauses queries (doesn't execute them), + * so QueryCache.onError never fires. We detect offline status here and + * properly clean up the subscription when the provider unmounts. + */ + useEffect( () => { + // Check initial online status on mount + if ( ! onlineManager.isOnline() ) { + globalErrorManager.setError( 'network' ); + } + + const unsubscribe = onlineManager.subscribe( isOnline => { + if ( ! isOnline ) { + globalErrorManager.setError( 'network' ); + } else if ( globalErrorManager.getError() === 'network' ) { + globalErrorManager.clearError(); + } + } ); + + return unsubscribe; + }, [] ); + + const contextValue = useMemo( + () => ( { + globalError, + setGlobalError: globalErrorManager.setError, + clearGlobalError: globalErrorManager.clearError, + isGlobalError: globalError !== null, + } ), + [ globalError ] + ); + + return ( + { children } + ); +} + +let hasWarnedAboutMissingProvider = false; + +const defaultContextValue: GlobalErrorContextValue = { + globalError: null, + setGlobalError: () => {}, + clearGlobalError: () => {}, + isGlobalError: false, +}; + +/** + * Access global error state. Returns defaults if used outside GlobalErrorProvider. + */ +export function useGlobalError(): GlobalErrorContextValue { + const context = useContext( GlobalErrorContext ); + + if ( context ) { + return context; + } + + if ( ! hasWarnedAboutMissingProvider ) { + hasWarnedAboutMissingProvider = true; + // eslint-disable-next-line no-console + console.warn( + 'useGlobalError was called outside of GlobalErrorProvider. ' + + 'Wrap your component tree with GlobalErrorProvider.' + ); + } + + return defaultContextValue; +} diff --git a/projects/packages/premium-analytics/packages/data/src/providers/global-error-manager.ts b/projects/packages/premium-analytics/packages/data/src/providers/global-error-manager.ts new file mode 100644 index 000000000000..9686d09e595b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/global-error-manager.ts @@ -0,0 +1,35 @@ +/** + * External dependencies + */ + +export type GlobalErrorType = 'network' | 'auth' | 'server' | null; + +type Listener = () => void; + +/** + * Manages global error state outside of React, enabling error state to be set + * from onlineManager subscription and consumed via useSyncExternalStore. + */ +class GlobalErrorManager { + private error: GlobalErrorType = null; + private listeners = new Set< Listener >(); + + getError = (): GlobalErrorType => this.error; + + setError = ( error: GlobalErrorType ): void => { + if ( this.error === error ) { + return; + } + this.error = error; + this.listeners.forEach( listener => listener() ); + }; + + clearError = (): void => this.setError( null ); + + subscribe = ( listener: Listener ): ( () => void ) => { + this.listeners.add( listener ); + return () => this.listeners.delete( listener ); + }; +} + +export const globalErrorManager = new GlobalErrorManager(); diff --git a/projects/packages/premium-analytics/packages/data/src/providers/index.ts b/projects/packages/premium-analytics/packages/data/src/providers/index.ts new file mode 100644 index 000000000000..9b6bc769e9bf --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/index.ts @@ -0,0 +1,5 @@ +export { queryClient, AnalyticsQueryClientProvider } from './query-client-provider'; + +export { GlobalErrorProvider, useGlobalError } from './global-error-context'; + +export { globalErrorManager, type GlobalErrorType } from './global-error-manager'; diff --git a/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx new file mode 100644 index 000000000000..1baf4f0ea5be --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; +import { ReactNode, lazy, Suspense } from 'react'; +/** + * Internal dependencies + */ +import { globalErrorManager } from './global-error-manager'; + +const DEFAULT_STALE_TIME = 5 * 60 * 1000; +const DEFAULT_GC_TIME = 10 * 60 * 1000; + +/** + * Whether to render the React Query Devtools. + * + * Devtools are opt-in and OFF by default. Enable them by setting a global + * debug flag on `window` (e.g. in the browser console: + * `window.jetpackPremiumAnalyticsQueryDevtools = true`) and reloading. They + * are also enabled automatically outside of production builds. + * + * @return Whether the devtools should be rendered. + */ +function areQueryDevtoolsEnabled(): boolean { + if ( + typeof window !== 'undefined' && + ( window as { jetpackPremiumAnalyticsQueryDevtools?: boolean } ) + .jetpackPremiumAnalyticsQueryDevtools === true + ) { + return true; + } + + return process.env.NODE_ENV !== 'production'; +} + +const ReactQueryDevtoolsProduction = lazy( () => + import( '@tanstack/react-query-devtools/production' ).then( d => ( { + default: d.ReactQueryDevtools, + } ) ) +); + +/** + * Extract HTTP status code from various error formats. + * WordPress REST API errors may have different shapes. + * @param error + */ +function getErrorStatus( error: unknown ): number | null { + if ( ! error || typeof error !== 'object' ) { + return null; + } + + const err = error as Record< string, unknown >; + + // Standard fetch Response error + if ( typeof err.status === 'number' ) { + return err.status; + } + + // WordPress REST API error format + if ( err.data && typeof err.data === 'object' ) { + const data = err.data as Record< string, unknown >; + if ( typeof data.status === 'number' ) { + return data.status; + } + } + + // Nested response object + if ( err.response && typeof err.response === 'object' ) { + const response = err.response as Record< string, unknown >; + if ( typeof response.status === 'number' ) { + return response.status; + } + } + + return null; +} + +/** + * QueryCache with global error detection for auth and server errors. + * + * Error codes handled: + * - 401: Authentication failure (session expired, invalid token) + * - 502: Bad gateway (proxy/load balancer can't reach upstream) + * - 503: Service unavailable (server overloaded or under maintenance) + * - 504: Gateway timeout (request took too long) + * + * This is QueryClient configuration (not a side effect subscription), so it's + * appropriate at module level. The globalErrorManager singleton is used here + * because QueryClient must be instantiated once (singleton pattern), but the + * error state is safely consumed via useSyncExternalStore in GlobalErrorProvider. + * + * Network errors are handled separately in GlobalErrorProvider via onlineManager. + */ +const queryCache = new QueryCache( { + onError: error => { + const currentError = globalErrorManager.getError(); + + // Don't override network error (highest priority) + if ( currentError === 'network' ) { + return; + } + + const status = getErrorStatus( error ); + + if ( status === 401 ) { + // Auth errors take precedence over server errors, but not network errors. + if ( currentError !== 'auth' ) { + globalErrorManager.setError( 'auth' ); + } + } else if ( status === 502 || status === 503 || status === 504 ) { + // Server errors: only set if no higher-priority error exists. + if ( currentError !== 'auth' && currentError !== 'server' ) { + globalErrorManager.setError( 'server' ); + } + } + }, + onSuccess: () => { + // Clear transient server errors once queries start succeeding again. + if ( globalErrorManager.getError() === 'server' ) { + globalErrorManager.clearError(); + } + }, +} ); + +export const queryClient = new QueryClient( { + queryCache, + defaultOptions: { + queries: { + /* + * Stale time is the time after which the data + * is considered stale and a new request is made. + * Stale time: 5 minutes + */ + staleTime: DEFAULT_STALE_TIME, + + /* + * GC time is the time after which the data is considered garbage + * collected and removed from the cache. + * GC time: 10 minutes + */ + gcTime: DEFAULT_GC_TIME, + + /** + * Noop fetcher to prevent react-query errors for empty queries in console. + */ + queryFn: () => Promise.resolve( undefined ), + }, + }, +} ); + +export const AnalyticsQueryClientProvider = ( { children }: { children: ReactNode } ) => { + return ( + + <>{ children } + { areQueryDevtoolsEnabled() && ( + + + + ) } + + ); +}; diff --git a/projects/packages/premium-analytics/packages/data/src/queries/index.ts b/projects/packages/premium-analytics/packages/data/src/queries/index.ts new file mode 100644 index 000000000000..f5b0604ab3f9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/index.ts @@ -0,0 +1,12 @@ +export { reportOrdersQuery } from './report-orders-query'; +export { reportOrderAttributionSummaryQuery } from './report-order-attribution-summary-query'; +export { reportCouponsQuery } from './report-coupons-query'; +export { reportCouponsByDateQuery } from './report-coupons-by-date-query'; +export { reportCustomersQuery } from './report-customers-query'; +export { reportCustomersByDateQuery } from './report-customers-by-date-query'; +export { reportConversionRateQuery } from './report-conversion-rate-query'; +export { reportProductsQuery } from './report-products-query'; +export { reportVisitorsQuery } from './report-visitors-query'; +export { reportVisitorsByLocationQuery } from './report-visitors-by-location-query'; +export { reportSessionsByDeviceQuery } from './report-sessions-by-device-query'; +export { reportBookingsQuery } from './report-bookings-query'; diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-bookings-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-bookings-query.ts new file mode 100644 index 000000000000..8a223b3f8ce9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-bookings-query.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportBookings } from '../api'; +import { sanitizeReportBookingsResponse } from '../processing/bookings'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportBookingsParams = Parameters< typeof fetchReportBookings >[ 0 ]; + +const getReportBookingsQueryKey = ( p: RequestReportBookingsParams ) => + [ 'reports', 'bookings', 'by-date', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportBookingsQuery( + params: RequestReportBookingsParams +): UseQueryOptions< ReportDataMap[ 'bookings' ] > { + return { + queryKey: getReportBookingsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportBookings( params ); + return sanitizeReportBookingsResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-conversion-rate-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-conversion-rate-query.ts new file mode 100644 index 000000000000..7e60af58f333 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-conversion-rate-query.ts @@ -0,0 +1,41 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportConversionRate } from '../api/report-conversion-rate-fetch'; +import { sanitizeReportConversionRateResponse } from '../processing/conversion-rate'; +import type { RequestReportConversionRateParams } from '../api/report-conversion-rate-fetch'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +const getReportConversionRateQueryKey = ( p: RequestReportConversionRateParams ) => + [ 'reports', 'conversion-rate', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportConversionRateQuery( + params: RequestReportConversionRateParams +): UseQueryOptions< ReturnType< typeof sanitizeReportConversionRateResponse > > { + return { + queryKey: getReportConversionRateQueryKey( params ), + queryFn: async () => { + const response = await fetchReportConversionRate( params ); + return sanitizeReportConversionRateResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-by-date-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-by-date-query.ts new file mode 100644 index 000000000000..a02418ab87e1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-by-date-query.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCouponsByDate } from '../api'; +import { sanitizeReportCouponsByDateResponse } from '../processing/coupons-by-date'; +import { FilterCondition } from '../types/filter-condition'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCouponsByDateParams = Parameters< typeof fetchReportCouponsByDate >[ 0 ] & { + filters?: FilterCondition[]; +}; + +const getQueryKey = ( p: RequestReportCouponsByDateParams ) => + [ 'reports', 'couponsByDate', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportCouponsByDateQuery( + params: RequestReportCouponsByDateParams +): UseQueryOptions< ReportDataMap[ 'couponsByDate' ] > { + return { + queryKey: getQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCouponsByDate( params ); + return sanitizeReportCouponsByDateResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-query.ts new file mode 100644 index 000000000000..c37f0d78ec0d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-query.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCoupons } from '../api'; +import { sanitizeReportCouponsResponse } from '../processing/coupons'; +import { FilterCondition } from '../types/filter-condition'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCouponsParams = Parameters< typeof fetchReportCoupons >[ 0 ] & { + filters?: FilterCondition[]; +}; + +const getReportCouponsQueryKey = ( p: RequestReportCouponsParams ) => + [ 'reports', 'coupons', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportCouponsQuery( + params: RequestReportCouponsParams +): UseQueryOptions< ReportDataMap[ 'coupons' ] > { + return { + queryKey: getReportCouponsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCoupons( params ); + return sanitizeReportCouponsResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-customers-by-date-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-by-date-query.ts new file mode 100644 index 000000000000..1537d84c7353 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-by-date-query.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCustomersByDate } from '../api/report-customers-by-date-fetch'; +import { sanitizeReportCustomersByDateResponse } from '../processing/customers-by-date'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCustomersByDateParams = Parameters< typeof fetchReportCustomersByDate >[ 0 ]; + +const getReportCustomersByDateQueryKey = ( p: RequestReportCustomersByDateParams ) => + [ 'reports', 'customers', 'by-date', p.from, p.to, p.interval, p.date_type ] as const; + +/** + * + * @param params + */ +export function reportCustomersByDateQuery( + params: RequestReportCustomersByDateParams +): UseQueryOptions< ReportDataMap[ 'customersByDate' ] > { + return { + queryKey: getReportCustomersByDateQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCustomersByDate( params ); + return sanitizeReportCustomersByDateResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-customers-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-query.ts new file mode 100644 index 000000000000..0cfdb1a30cfa --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-query.ts @@ -0,0 +1,51 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCustomers } from '../api'; +import { sanitizeReportCustomersResponse } from '../processing/customers'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCustomersParams = Parameters< typeof fetchReportCustomers >[ 0 ]; + +const getReportCustomersQueryKey = ( p: RequestReportCustomersParams ) => [ + 'reports', + 'customers', + 'new-returning', + p.from, + p.to, + p.date_type, + p.filters, +]; + +/** + * + * @param params + */ +export function reportCustomersQuery( + params: RequestReportCustomersParams +): UseQueryOptions< ReportDataMap[ 'customers' ] > { + return { + queryKey: getReportCustomersQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCustomers( params ); + return sanitizeReportCustomersResponse( response ); + }, + + /** + * Enable the query only if the from and to are set. + * Note: interval is not required for customers endpoint. + */ + enabled: !! ( params.from && params.to ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-order-attribution-summary-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-order-attribution-summary-query.ts new file mode 100644 index 000000000000..55be6f0e756d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-order-attribution-summary-query.ts @@ -0,0 +1,130 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportOrderAttributionSummary, fetchReportOrderAttributionByProduct } from '../api'; +import { + sanitizeReportOrderAttributionSummaryResponse, + normalizeOrderAttributionByProductResponse, + type SanitizedOrderAttributionSummaryResponse, +} from '../processing/order-attribution'; +import { hasProductFilters } from '../utils/product-filters'; +import type { FilterCondition } from '../types/filter-condition'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type ReportOrderAttributionSummaryParams = Parameters< + typeof fetchReportOrderAttributionSummary +>[ 0 ] & { + filters?: FilterCondition[]; +}; + +/** + * Creates a query key for order attribution queries. + * + * Note: All comparison parameters are included in the query key because + * order attribution returns both primary and comparison data in a single response. + * @param params + */ +const getReportOrderAttributionQueryKey = ( params: ReportOrderAttributionSummaryParams ) => + [ + 'reports', + 'order-attribution', + params.view, + params.from, + params.to, + params.interval, + params.date_type, + params.compare_from, + params.compare_to, + params.filters, + ] as const; + +/** + * React Query configuration for order attribution summary data. + * + * This query is designed to be used with `use-report` hook, which provides + * standardized loading states and comparison handling. + * + * Important architectural notes: + * - Unlike other report queries, order attribution includes comparison data in the + * PRIMARY response, not in a separate comparison query + * - When used with `use-report`, the comparison query is disabled (it's a no-op) + * - This query supports two API endpoints: + * 1. Regular order-attribution API: Returns both periods in a single response + * 2. By-product API: Fetches periods separately, then normalizes to match (1) + * + * @param params - Query parameters including date ranges and optional filters + * @return React Query options with query key, fetch function, and enabled state + */ +export function reportOrderAttributionSummaryQuery( + params: ReportOrderAttributionSummaryParams +): UseQueryOptions< SanitizedOrderAttributionSummaryResponse > { + return { + queryKey: getReportOrderAttributionQueryKey( params ), + queryFn: async () => { + const hasProductFiltersValue = hasProductFilters( params.filters ); + + // Choose API based on whether product filters are present + if ( hasProductFiltersValue ) { + // By-product API path: Fetch primary and comparison periods in parallel + const { compare_from, compare_to } = params; + + // Determine if we need to fetch comparison period + const shouldFetchComparison = + compare_from && + compare_to && + ( compare_from !== params.from || compare_to !== params.to ); + + // Fetch both periods in parallel for better performance + const [ currentResponse, previousResponse ] = await Promise.all( [ + fetchReportOrderAttributionByProduct( { + from: params.from, + to: params.to, + interval: params.interval, + view: params.view, + filters: params.filters, + date_type: params.date_type, + } ), + shouldFetchComparison + ? fetchReportOrderAttributionByProduct( { + from: compare_from, + to: compare_to, + interval: params.interval, + view: params.view, + filters: params.filters, + date_type: params.date_type, + } ) + : Promise.resolve( undefined ), + ] ); + + // Normalize to match the regular API structure (includes both periods) + const normalizedResponse = normalizeOrderAttributionByProductResponse( + currentResponse, + previousResponse + ); + + return sanitizeReportOrderAttributionSummaryResponse( normalizedResponse ); + } + + // Regular API path: Returns both primary and comparison in one response + const response = await fetchReportOrderAttributionSummary( params ); + return sanitizeReportOrderAttributionSummaryResponse( response ); + }, + + /** + * Enable the query only when all required parameters are present. + * The 'view' parameter is required for order attribution queries. + */ + enabled: !! ( params.from && params.to && params.interval && params.view ), + + /** + * Keep previous data while fetching to prevent flash of empty state. + * This provides a smoother user experience during data refetching. + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-orders-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-orders-query.ts new file mode 100644 index 000000000000..29deb2e9a552 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-orders-query.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportOrders } from '../api'; +import { sanitizeReportOrdersResponse } from '../processing/orders'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportOrdersParams = Parameters< typeof fetchReportOrders >[ 0 ]; + +const getReportOrdersQueryKey = ( p: RequestReportOrdersParams ) => [ + 'reports', + 'orders', + p.from, + p.to, + p.interval, + p.date_type, + p.filters || [], +]; + +/** + * + * @param params + */ +export function reportOrdersQuery( + params: RequestReportOrdersParams +): UseQueryOptions< ReportDataMap[ 'orders' ] > { + return { + queryKey: getReportOrdersQueryKey( params ), + queryFn: async () => { + const response = await fetchReportOrders( params ); + return sanitizeReportOrdersResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-products-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-products-query.ts new file mode 100644 index 000000000000..a46bb686d4ed --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-products-query.ts @@ -0,0 +1,54 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportProducts } from '../api/report-products-fetch'; +import { sanitizeReportProductsResponse } from '../processing/products'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportProductsParams = Parameters< typeof fetchReportProducts >[ 0 ]; + +type SanitizedProductsResponse = ReturnType< typeof sanitizeReportProductsResponse >; + +const getReportProductsQueryKey = ( p: RequestReportProductsParams ) => + [ + 'reports', + 'products', + p.from, + p.to, + p.date_type, + p.limit, + p.orderby, + p.order, + p.filters, + ] as const; + +/** + * + * @param params + */ +export function reportProductsQuery( + params: RequestReportProductsParams +): UseQueryOptions< SanitizedProductsResponse > { + return { + queryKey: getReportProductsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportProducts( params ); + return sanitizeReportProductsResponse( response ); + }, + + /** + * Enable the query only if the from and to are set. + */ + enabled: !! ( params.from && params.to ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-sessions-by-device-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-sessions-by-device-query.ts new file mode 100644 index 000000000000..b00ce00bf33c --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-sessions-by-device-query.ts @@ -0,0 +1,45 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportSessionsByDevice } from '../api/report-sessions-by-device-fetch'; +import { sanitizeReportSessionsByDeviceResponse } from '../processing/sessions-by-device'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportSessionsByDeviceParams = Parameters< typeof fetchReportSessionsByDevice >[ 0 ]; + +const getReportSessionsByDeviceQueryKey = ( p: RequestReportSessionsByDeviceParams ) => + [ 'reports', 'sessions', 'by-device', p.from, p.to ] as const; + +/** + * Creates query options for fetching sessions by device report data. + * + * @param params - Request parameters with from/to dates + */ +export function reportSessionsByDeviceQuery( + params: RequestReportSessionsByDeviceParams +): UseQueryOptions< ReportDataMap[ 'sessionsByDevice' ] > { + return { + queryKey: getReportSessionsByDeviceQueryKey( params ), + queryFn: async () => { + const response = await fetchReportSessionsByDevice( params ); + return sanitizeReportSessionsByDeviceResponse( response ); + }, + + /** + * Enable the query only if from and to dates are set. + * Note: This endpoint doesn't use interval (it's not a time-series). + */ + enabled: !! ( params.from && params.to ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-by-location-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-by-location-query.ts new file mode 100644 index 000000000000..32663a38b75e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-by-location-query.ts @@ -0,0 +1,48 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportVisitorsByLocation } from '../api'; +import { sanitizeReportVisitorsByLocationResponse } from '../processing/visitors-by-location'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportVisitorsByLocationParams = Parameters< + typeof fetchReportVisitorsByLocation +>[ 0 ]; + +const getReportVisitorsByLocationQueryKey = ( p: RequestReportVisitorsByLocationParams ) => + [ + 'reports', + 'visitors', + 'by-location', + p.group_by, + p.country_code ?? null, + p.from, + p.to, + p.interval, + p.limit ?? null, + ] as const; + +/** + * + * @param params + */ +export function reportVisitorsByLocationQuery( + params: RequestReportVisitorsByLocationParams +): UseQueryOptions< ReportDataMap[ 'visitorsByLocation' ] > { + return { + queryKey: getReportVisitorsByLocationQueryKey( params ), + queryFn: async () => { + const response = await fetchReportVisitorsByLocation( params ); + return sanitizeReportVisitorsByLocationResponse( response ); + }, + + enabled: !! ( params.from && params.to && params.interval ), + + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-query.ts new file mode 100644 index 000000000000..78d2f138d554 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-query.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportVisitors } from '../api'; +import { sanitizeReportVisitorsResponse } from '../processing/visitors'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportVisitorsParams = Parameters< typeof fetchReportVisitors >[ 0 ]; + +const getReportVisitorsQueryKey = ( p: RequestReportVisitorsParams ) => + [ 'reports', 'visitors', 'by-date', p.from, p.to, p.interval, p.date_type ] as const; + +/** + * + * @param params + */ +export function reportVisitorsQuery( + params: RequestReportVisitorsParams +): UseQueryOptions< ReportDataMap[ 'visitors' ] > { + return { + queryKey: getReportVisitorsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportVisitors( params ); + return sanitizeReportVisitorsResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/types.ts b/projects/packages/premium-analytics/packages/data/src/types.ts new file mode 100644 index 000000000000..76b5d9f1a945 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types.ts @@ -0,0 +1,99 @@ +/** + * Internal dependencies + */ +import { sanitizeReportOrdersResponse, sanitizeReportProductsResponse } from './processing'; +import { sanitizeReportBookingsResponse } from './processing/bookings'; +import { sanitizeReportConversionRateResponse } from './processing/conversion-rate'; +import { sanitizeReportCouponsResponse } from './processing/coupons'; +import { sanitizeReportCouponsByDateResponse } from './processing/coupons-by-date'; +import { sanitizeReportCustomersResponse } from './processing/customers'; +import { sanitizeReportCustomersByDateResponse } from './processing/customers-by-date'; +import { sanitizeReportOrderAttributionSummaryResponse } from './processing/order-attribution'; +import { sanitizeReportOrdersByProductTypeResponse } from './processing/orders-by-product-type'; +import { sanitizeReportSessionsByDeviceResponse } from './processing/sessions-by-device'; +import { sanitizeReportVisitorsResponse } from './processing/visitors'; +import { sanitizeReportVisitorsByLocationResponse } from './processing/visitors-by-location'; +import type { ReportParams } from './utils/search'; + +export type ReportType = + | 'orders' + | 'orders-by-product-type' + | 'order-attribution' + | 'coupons' + | 'couponsByDate' + | 'customers' + | 'customersByDate' + | 'products' + | 'visitors' + | 'visitorsByLocation' + | 'conversionRate' + | 'bookings' + | 'sessionsByDevice'; + +export type QueryParams = ReportParams & { + p?: string; // encoded pathname +}; + +// Inferred from processing/orders.ts +type SanitizedOrdersByDateResponse = ReturnType< typeof sanitizeReportOrdersResponse >; + +// Inferred from processing/order-attribution.ts +type SanitizedOrderAttributionSummaryResponse = ReturnType< + typeof sanitizeReportOrderAttributionSummaryResponse +>; + +// Inferred from processing/coupons.ts +type SanitizedCouponsResponse = ReturnType< typeof sanitizeReportCouponsResponse >; + +// Inferred from processing/coupons-by-date/index.ts +type SanitizedCouponsByDateResponse = ReturnType< typeof sanitizeReportCouponsByDateResponse >; + +// Inferred from processing/customers.ts +type SanitizedCustomersResponse = ReturnType< typeof sanitizeReportCustomersResponse >; + +// Inferred from processing/customers-by-date/index.ts +type SanitizedCustomersByDateResponse = ReturnType< typeof sanitizeReportCustomersByDateResponse >; + +// Inferred from processing/products.ts +type SanitizedProductsResponse = ReturnType< typeof sanitizeReportProductsResponse >; + +// Inferred from processing/visitors.ts +type SanitizedVisitorsResponse = ReturnType< typeof sanitizeReportVisitorsResponse >; + +// Inferred from processing/visitors-by-location.ts +type SanitizedVisitorsByLocationResponse = ReturnType< + typeof sanitizeReportVisitorsByLocationResponse +>; + +// Inferred from processing/conversion-rate.ts +type SanitizedConversionRateResponse = ReturnType< typeof sanitizeReportConversionRateResponse >; + +// Inferred from processing/orders-by-product-type.ts +type SanitizedOrdersByProductTypeResponse = ReturnType< + typeof sanitizeReportOrdersByProductTypeResponse +>; + +// Inferred from processing/bookings.ts +type SanitizedBookingsResponse = ReturnType< typeof sanitizeReportBookingsResponse >; + +// Inferred from processing/sessions-by-device.ts +type SanitizedSessionsByDeviceResponse = ReturnType< + typeof sanitizeReportSessionsByDeviceResponse +>; + +// Type mapping for report types to their PROCESSED data structures +export interface ReportDataMap { + orders: SanitizedOrdersByDateResponse; // Returns processed data with numbers + 'orders-by-product-type': SanitizedOrdersByProductTypeResponse; // Returns processed orders by product type data with numbers + 'order-attribution': SanitizedOrderAttributionSummaryResponse; // Returns processed attribution data + coupons: SanitizedCouponsResponse; // Returns processed coupons data with numbers + couponsByDate: SanitizedCouponsByDateResponse; // Returns processed coupons-by-date data with numbers + customers: SanitizedCustomersResponse; // Returns processed customers data with numbers + customersByDate: SanitizedCustomersByDateResponse; // Returns processed customers by date data with numbers + products: SanitizedProductsResponse; // Returns raw products data + visitors: SanitizedVisitorsResponse; // Returns processed visitors data with numbers + visitorsByLocation: SanitizedVisitorsByLocationResponse; // Returns processed visitors grouped by location (country or region) + conversionRate: SanitizedConversionRateResponse; // Returns processed conversion rate data with numbers + bookings: SanitizedBookingsResponse; // Returns processed bookings data with numbers + sessionsByDevice: SanitizedSessionsByDeviceResponse; // Returns processed sessions by device data with numbers +} diff --git a/projects/packages/premium-analytics/packages/data/src/types/filter-condition.ts b/projects/packages/premium-analytics/packages/data/src/types/filter-condition.ts new file mode 100644 index 000000000000..d6bb433880a2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types/filter-condition.ts @@ -0,0 +1,23 @@ +/** + * Filter condition types for API queries + */ + +// Different type of filters have different comparison operators +// @see https://github.a8c.com/Automattic/wpcom/tree/72572945acd96d29adf9ea8f38fc3e99c9a4a668/wp-content/rest-api-plugins/endpoints/woocommerce-analytics/Reports/Filter +export type FilterCondition = { + key: string; + value: string | string[]; + compare: + | '=' + | 'IN' + | 'NOT IN' + | '!=' + | '>' + | '<' + | '>=' + | '<=' + | 'BETWEEN' + | 'NOT BETWEEN' + | 'LIKE' + | 'NOT LIKE'; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/types/product-image.ts b/projects/packages/premium-analytics/packages/data/src/types/product-image.ts new file mode 100644 index 000000000000..8fc5b65ad822 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types/product-image.ts @@ -0,0 +1,7 @@ +/** + * Product image type definition + */ +export interface ProductImage { + imageUrl: string; + imageAlt: string; +} diff --git a/projects/packages/premium-analytics/packages/data/src/types/product-type.ts b/projects/packages/premium-analytics/packages/data/src/types/product-type.ts new file mode 100644 index 000000000000..2b5a41ab1cf8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types/product-type.ts @@ -0,0 +1,4 @@ +/** + * Product type categories for filtering and organization + */ +export type ProductType = 'general' | 'products' | 'bookings'; diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/compute-date-range-from-preset.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/compute-date-range-from-preset.test.ts new file mode 100644 index 000000000000..e6c2ea4e4a58 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/compute-date-range-from-preset.test.ts @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import { tz } from '@date-fns/tz'; +import { + startOfDay, + endOfDay, + subDays, + subMonths, + subYears, + startOfMonth, + endOfMonth, + startOfYear, + endOfYear, +} from 'date-fns'; +/** + * Mocks – getSiteTimezone and dateToISOStringWithLocalTZ + * depend on WordPress core store. + * We mock them to remove that dependency. + * + * dateToISOStringWithLocalTZ normalizes to UTC Z-format + * (matching native Date.toISOString) since the mock timezone + * is +00:00 and all dates are UTC. + */ +jest.mock( '../date', () => ( { + getSiteTimezone: jest.fn( () => '+00:00' ), + dateToISOStringWithLocalTZ: jest.fn( ( date: Date ) => new Date( date.getTime() ).toISOString() ), +} ) ); +/** + * Internal dependencies + */ +import { computeDateRangeFromPreset } from '../preset-date-range'; + +/* + * Pin "now" to 2026-02-19 12:00:00 UTC for deterministic results. + * + * Expected dates are computed in UTC. Since TZ is mocked to +00:00, + * computePrimaryRange runs in UTC and dateToISOStringWithLocalTZ + * normalizes to Z-format via the mock. + */ +const NOW = new Date( '2026-02-19T12:00:00.000Z' ); +const UTC = tz( '+00:00' ); + +/* + * Normalize a TZDate or Date to Z-format ISO string, + * ensuring the expected values match the mock's output format. + */ +/** + * + * @param date + */ +function toZ( date: Date ): string { + return new Date( date.getTime() ).toISOString(); +} + +const TODAY_START = startOfDay( NOW, { in: UTC } ); +const TODAY_END = endOfDay( NOW, { in: UTC } ); +const YESTERDAY_END = endOfDay( subDays( TODAY_START, 1 ), { in: UTC } ); +const LAST_MONTH = subMonths( TODAY_START, 1 ); + +beforeAll( () => { + jest.useFakeTimers(); + jest.setSystemTime( NOW ); +} ); + +afterAll( () => { + jest.useRealTimers(); +} ); + +describe( 'computeDateRangeFromPreset', () => { + it( 'returns today range for "today"', () => { + const range = computeDateRangeFromPreset( 'today' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( TODAY_START ) ); + expect( range!.to ).toBe( toZ( TODAY_END ) ); + } ); + + it( 'returns yesterday range for "yesterday"', () => { + const range = computeDateRangeFromPreset( 'yesterday' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 1 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 7-day range ending yesterday for "last-7-days"', () => { + const range = computeDateRangeFromPreset( 'last-7-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 7 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 30-day range ending yesterday for "last-30-days"', () => { + const range = computeDateRangeFromPreset( 'last-30-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 30 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 90-day range ending yesterday for "last-90-days"', () => { + const range = computeDateRangeFromPreset( 'last-90-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 90 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 365-day range ending yesterday for "last-365-days"', () => { + const range = computeDateRangeFromPreset( 'last-365-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 365 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns last calendar month for "last-month"', () => { + const range = computeDateRangeFromPreset( 'last-month' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( startOfMonth( LAST_MONTH, { in: UTC } ) ) ); + expect( range!.to ).toBe( toZ( endOfMonth( LAST_MONTH, { in: UTC } ) ) ); + } ); + + it( 'returns last 12 calendar months for "last-12-months"', () => { + const range = computeDateRangeFromPreset( 'last-12-months' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( startOfMonth( subMonths( TODAY_START, 12 ), { in: UTC } ) ) ); + expect( range!.to ).toBe( toZ( endOfMonth( LAST_MONTH, { in: UTC } ) ) ); + } ); + + it( 'returns last calendar year for "last-year"', () => { + const range = computeDateRangeFromPreset( 'last-year' ); + const lastYear = subYears( TODAY_START, 1 ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( startOfYear( lastYear, { in: UTC } ) ) ); + expect( range!.to ).toBe( toZ( endOfYear( lastYear, { in: UTC } ) ) ); + } ); + + it( 'returns undefined for unrecognized preset', () => { + // @ts-expect-error – testing with invalid preset on purpose + const range = computeDateRangeFromPreset( 'not-a-preset' ); + + expect( range ).toBeUndefined(); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/has-comparison-enabled.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/has-comparison-enabled.test.ts new file mode 100644 index 000000000000..1dc771865b48 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/has-comparison-enabled.test.ts @@ -0,0 +1,81 @@ +/** + * Mocks – break the dependency chain to `@wordpress/core-data`. + */ +jest.mock( '../../defaults', () => ( { + getDefaultQueryParams: jest.fn(), +} ) ); + +jest.mock( '../preset-date-range', () => ( { + computeDateRangeFromPreset: jest.fn(), +} ) ); + +jest.mock( '../interval', () => ( { + getDefaultIntervalForPeriod: jest.fn(), +} ) ); +/** + * Internal dependencies + */ +import { hasComparisonEnabled } from '../search'; + +describe( 'hasComparisonEnabled', () => { + it( 'returns true when all comparison fields are present', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: '2026-01-01T00:00:00.000-05:00', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( true ); + } ); + + it( 'returns false when comp is undefined', () => { + expect( + hasComparisonEnabled( { + compare_from: '2026-01-01T00:00:00.000-05:00', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_from is missing', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_to is missing', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: '2026-01-01T00:00:00.000-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_from is whitespace', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: ' ', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_to is whitespace', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: '2026-01-01T00:00:00.000-05:00', + compare_to: ' ', + } ) + ).toBe( false ); + } ); + + it( 'returns false for empty object', () => { + expect( hasComparisonEnabled( {} ) ).toBe( false ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/normalize-report-params.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/normalize-report-params.test.ts new file mode 100644 index 000000000000..00644be04800 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/normalize-report-params.test.ts @@ -0,0 +1,291 @@ +/** + * Mocks – must appear before the import of the module under test. + */ +jest.mock( '../../defaults', () => ( { + getDefaultQueryParams: jest.fn(), +} ) ); + +jest.mock( '../preset-date-range', () => ( { + computeDateRangeFromPreset: jest.fn(), +} ) ); + +jest.mock( '../interval', () => ( { + getDefaultIntervalForPeriod: jest.fn(), +} ) ); +/** + * Internal dependencies + */ +import { getDefaultQueryParams } from '../../defaults'; +import { getDefaultIntervalForPeriod } from '../interval'; +import { computeDateRangeFromPreset } from '../preset-date-range'; +import { normalizeReportParams } from '../search'; +import type { ReportParams } from '../search'; + +const mockGetDefaults = getDefaultQueryParams as jest.MockedFunction< + typeof getDefaultQueryParams +>; +const mockComputeRange = computeDateRangeFromPreset as jest.MockedFunction< + typeof computeDateRangeFromPreset +>; +const mockGetInterval = getDefaultIntervalForPeriod as jest.MockedFunction< + typeof getDefaultIntervalForPeriod +>; + +/* + * Deterministic date strings. + * FRESH = what computeDateRangeFromPreset returns "today". + * STALE = what the URL had from a previous day. + */ +const FRESH_FROM = '2026-01-20T00:00:00.000-05:00'; +const FRESH_TO = '2026-02-18T23:59:59.999-05:00'; +const STALE_FROM = '2026-01-19T00:00:00.000-05:00'; +const STALE_TO = '2026-02-17T23:59:59.999-05:00'; + +const DEFAULTS_WITH_COMPARISON: ReportParams = { + from: FRESH_FROM, + to: FRESH_TO, + preset: 'last-30-days', + interval: 'day', + compare_from: '2025-12-21T00:00:00.000-05:00', + compare_to: '2026-01-19T23:59:59.999-05:00', + compare_preset: 'previous-period', + comp: '1', +}; + +beforeEach( () => { + jest.clearAllMocks(); + + // Sensible defaults for every test – override per-scenario as needed. + mockGetDefaults.mockReturnValue( { ...DEFAULTS_WITH_COMPARISON } ); + mockComputeRange.mockReturnValue( { + from: FRESH_FROM, + to: FRESH_TO, + } ); + mockGetInterval.mockReturnValue( 'day' ); +} ); + +describe( 'normalizeReportParams', () => { + /* + * Scenario 1 – Fresh load (no params in URL) + * The user visits /dashboard with no query string. + * Expected: defaults kick in, preset "last-30-days" is used, + * and default comparison is applied. + */ + it( 'applies defaults with preset and comparison on fresh load', () => { + const result = normalizeReportParams(); + + // Preset should come from defaults. + expect( result.preset ).toBe( 'last-30-days' ); + expect( mockComputeRange ).toHaveBeenCalledWith( 'last-30-days' ); + + // Dates should come from computeDateRangeFromPreset. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + + // Default comparison should be applied (search is undefined + // → !search?.from → true → default branch). + expect( result.comp ).toBe( '1' ); + expect( result.compare_from ).toBe( DEFAULTS_WITH_COMPARISON.compare_from ); + expect( result.compare_to ).toBe( DEFAULTS_WITH_COMPARISON.compare_to ); + } ); + + /* + * Scenario 2 – Same-day reload with preset + * The URL has preset=last-30-days and from/to that match today's + * computation. The dates are still fresh → no redirect needed. + */ + it( 'returns same dates when preset range is still fresh', () => { + const result = normalizeReportParams( { + from: FRESH_FROM, + to: FRESH_TO, + preset: 'last-30-days', + interval: 'day', + } ); + + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + expect( result.preset ).toBe( 'last-30-days' ); + + // No comparison in search → no comparison in output + // (search.from is present → !search.from is false + // → default comparison branch is skipped). + expect( result.comp ).toBeUndefined(); + } ); + + /* + * Scenario 3 – Next-day reload with stale dates + * The URL has yesterday's dates but the same preset. + * computeDateRangeFromPreset returns fresh dates → redirect. + */ + it( 'recalculates dates when preset range is stale', () => { + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + interval: 'day', + } ); + + // Should use the fresh range from the preset, not stale URL dates. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + expect( result.preset ).toBe( 'last-30-days' ); + expect( mockComputeRange ).toHaveBeenCalledWith( 'last-30-days' ); + } ); + + /* + * Scenario 4 – Custom range (no preset) + * The user picked explicit from/to dates without a preset. + * The dates should be used as-is, no recalculation. + */ + it( 'uses explicit dates as-is when no preset is set', () => { + const customFrom = '2026-01-01T00:00:00.000-05:00'; + const customTo = '2026-01-31T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: customFrom, + to: customTo, + } ); + + expect( result.from ).toBe( customFrom ); + expect( result.to ).toBe( customTo ); + expect( result.preset ).toBeUndefined(); + // computeDateRangeFromPreset should NOT be called. + expect( mockComputeRange ).not.toHaveBeenCalled(); + } ); + + it( 'uses explicit dates as-is when preset is custom', () => { + const customFrom = '2026-01-01T00:00:00.000-05:00'; + const customTo = '2026-01-31T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: customFrom, + to: customTo, + preset: 'custom', + } ); + + expect( result.from ).toBe( customFrom ); + expect( result.to ).toBe( customTo ); + expect( result.preset ).toBeUndefined(); + expect( mockComputeRange ).not.toHaveBeenCalled(); + } ); + + /* + * Scenario 5 – Preset with stale comparison enabled + * The URL has a stale preset and comparison params. + * Primary range is recalculated; comparison is preserved from URL. + */ + it( 'recalculates primary but preserves comparison from URL', () => { + const compFrom = '2025-12-20T00:00:00.000-05:00'; + const compTo = '2026-01-18T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + interval: 'day', + comp: '1', + compare_from: compFrom, + compare_to: compTo, + compare_preset: 'previous-period', + } ); + + // Primary recalculated from preset. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + + // Comparison passed through from search. + expect( result.comp ).toBe( '1' ); + expect( result.compare_from ).toBe( compFrom ); + expect( result.compare_to ).toBe( compTo ); + expect( result.compare_preset ).toBe( 'previous-period' ); + } ); + + /* + * Scenario 6 – Preset without comparison + * The URL has a stale preset but comparison is disabled. + * Primary is recalculated; comparison params are absent. + */ + it( 'recalculates primary with no comparison when comp is absent', () => { + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + interval: 'day', + } ); + + // Primary recalculated. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + + // No comparison in search, and search.from is present + // → default comparison is NOT applied. + expect( result.comp ).toBeUndefined(); + expect( result.compare_from ).toBeUndefined(); + expect( result.compare_to ).toBeUndefined(); + } ); + + /* + * Edge case – Invalid preset in URL is ignored. + */ + it( 'ignores invalid preset and uses URL dates', () => { + const customFrom = '2026-02-01T00:00:00.000-05:00'; + const customTo = '2026-02-15T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: customFrom, + to: customTo, + // @ts-expect-error – testing with invalid preset on purpose + preset: 'not-a-real-preset', + } ); + + expect( result.from ).toBe( customFrom ); + expect( result.to ).toBe( customTo ); + expect( result.preset ).toBeUndefined(); + expect( mockComputeRange ).not.toHaveBeenCalled(); + } ); + + /* + * Edge case – computeDateRangeFromPreset returns undefined + * (e.g., an unimplemented preset). Falls back to search dates. + */ + it( 'falls back to URL dates when preset has no range implementation', () => { + mockComputeRange.mockReturnValue( undefined ); + + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + } ); + + // Preset should be cleared. + expect( result.preset ).toBeUndefined(); + // Falls back to search dates. + expect( result.from ).toBe( STALE_FROM ); + expect( result.to ).toBe( STALE_TO ); + } ); + + /* + * Edge case – date_type is preserved from search. + */ + it( 'preserves date_type from search', () => { + const result = normalizeReportParams( { + from: FRESH_FROM, + to: FRESH_TO, + date_type: 'paid', + } ); + + expect( result.date_type ).toBe( 'paid' ); + } ); + + /* + * Edge case – date_type defaults to "created". + */ + it( 'defaults date_type to created', () => { + const result = normalizeReportParams( { + from: FRESH_FROM, + to: FRESH_TO, + } ); + + expect( result.date_type ).toBe( 'created' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/date.ts b/projects/packages/premium-analytics/packages/data/src/utils/date.ts new file mode 100644 index 000000000000..81f1950d8d4e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/date.ts @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { type TZDate } from '@date-fns/tz'; +import { + toLocalTZ, + formatToTimezoneNaiveString as _formatNaive, + dateToISOStringWithTZ as _toISOWithTZ, +} from '@jetpack-premium-analytics/datetime'; +import { store as coreStore, type Settings } from '@wordpress/core-data'; +import { select } from '@wordpress/data'; + +type FullSettings = Settings & { + gmt_offset: number; +}; + +let DEFAULT_TIME_ZONE: string; +try { + DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '+00:00'; +} catch { + DEFAULT_TIME_ZONE = '+00:00'; +} + +/** + * Format the GMT offset to a string. + * + * @param {number | undefined} offset - The GMT offset. + * @return {string} The formatted GMT offset. + */ +function formatGmtOffset( offset: number | undefined ): string { + if ( ! offset ) { + return DEFAULT_TIME_ZONE; + } + + const sign = offset >= 0 ? '+' : '-'; + const abs = Math.abs( offset ); + const hours = Math.floor( abs ); + const minutes = Math.floor( ( abs - hours ) * 60 + 1e-6 ); + return `${ sign }${ String( hours ).padStart( 2, '0' ) }:${ String( minutes ).padStart( + 2, + '0' + ) }`; +} + +/* + * Get the timezone from the site settings. + * If the timezone is not set, use the GMT offset. + * If the GMT offset is not set, use the default timezone. + * + * @param {string} timezone - The timezone to use. + * @return {string} The timezone. + */ +/** + * + */ +export function getSiteTimezone() { + const siteSettings = select( coreStore ).getEntityRecord( 'root', 'site' ) as FullSettings; + + if ( ! siteSettings ) { + return DEFAULT_TIME_ZONE; + } + + return siteSettings?.timezone?.length + ? siteSettings?.timezone + : formatGmtOffset( siteSettings?.gmt_offset ) || DEFAULT_TIME_ZONE; +} + +/** + * Returns the site's GMT offset as a string (e.g. "+05:30", "-08:00"). + * If site settings are not loaded, throws an error. + * @return {string} The site's GMT offset. + */ +export function getSiteGmtOffset(): string { + const siteSettings = select( coreStore ).getEntityRecord( 'root', 'site' ) as FullSettings; + if ( ! siteSettings ) { + throw new Error( 'getSiteGmtOffset() called before core settings are ready' ); + } + return formatGmtOffset( siteSettings?.gmt_offset ) || DEFAULT_TIME_ZONE; +} + +/** + * Same API and behavior as your current localTZDate: + * - Accepts number | string | Date (or undefined -> now) + * - Uses site timezone by default + * - Returns TZDate (timezone-aware) + * @param value + * @param timezone + */ +export function localTZDate( value?: number | string | Date, timezone?: string ): TZDate { + const tz = timezone ?? getSiteTimezone(); + return toLocalTZ( value, tz ); +} + +/** + * Same semantics as your current helper: + * TZ-aware -> timezone-naive "YYYY-MM-DDTHH:mm:ss.SSS" + * @param date + * @param timezone + */ +export function formatToTimezoneNaiveString( date: Date, timezone?: string ): string { + const tz = timezone ?? getSiteTimezone(); + return _formatNaive( date, tz ); +} + +/** + * Same semantics as your current helper: + * TZ-aware -> ISO with offset "YYYY-MM-DDTHH:mm:ss.SSSxxx" + * @param date + * @param timezone + */ +export function dateToISOStringWithLocalTZ( date: Date, timezone?: string ): string { + const tz = timezone ?? getSiteTimezone(); + return _toISOWithTZ( date, tz ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/ensure-core-settings.ts b/projects/packages/premium-analytics/packages/data/src/utils/ensure-core-settings.ts new file mode 100644 index 000000000000..cd00c8a6e0e5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/ensure-core-settings.ts @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { resolveSelect } from '@wordpress/data'; + +let readyPromise: Promise< void > | null = null; + +/** + * Ensures that 'site' and 'general settings' are in the coreStore. + * Memoizes the same promise to avoid races and duplicate requests. + */ +export function ensureCoreSettingsReady(): Promise< void > { + if ( ! readyPromise ) { + readyPromise = Promise.all( [ + resolveSelect( coreStore ).getEntityRecord( 'root', 'site' ), + resolveSelect( coreStore ).getEntityRecord( 'root', 'settings', 'general' ), + ] ).then( () => void 0 ); + } + return readyPromise; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/index.ts b/projects/packages/premium-analytics/packages/data/src/utils/index.ts new file mode 100644 index 000000000000..0a0763050244 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/index.ts @@ -0,0 +1,15 @@ +export { + localTZDate, + dateToISOStringWithLocalTZ, + formatToTimezoneNaiveString, + getSiteTimezone, + getSiteGmtOffset, +} from './date'; +export { ensureCoreSettingsReady } from './ensure-core-settings'; +export { getDefaultIntervalForPeriod } from './interval'; +export { safeParseInt, safeParseFloat } from './parsing'; +export { computeDateRangeFromPreset } from './preset-date-range'; +export { hasProductFilters } from './product-filters'; +export type { PresetType, ReportParams } from './search'; +export { isSelectablePreset } from '@jetpack-premium-analytics/datetime'; +export type { Override, BaseReportParams } from './types'; diff --git a/projects/packages/premium-analytics/packages/data/src/utils/interval.ts b/projects/packages/premium-analytics/packages/data/src/utils/interval.ts new file mode 100644 index 000000000000..30c962cb435d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/interval.ts @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import { differenceInHours } from 'date-fns'; +/** + * Internal dependencies + */ +import { localTZDate } from './date'; +import type { IntervalType } from './search'; + +/** + * + * @param from + * @param to + */ +function getAllowedIntervalsByRange( from: string, to: string ): IntervalType[] { + // Use hours instead of days to handle ranges that are 1 second short of a full day. + // E.g., '2024-11-01 00:00:00' to '2025-10-31 23:59:59' is 8759 hours (364.958 days), + // which rounds to 365 days, correctly categorizing it as a yearly interval. + const daysDiff = Math.round( + Math.abs( differenceInHours( localTZDate( to ), localTZDate( from ) ) / 24 ) + ); + + if ( daysDiff >= 1095 ) { + return [ 'quarter', 'year' ]; + } else if ( daysDiff >= 365 ) { + return [ 'month', 'quarter' ]; + } else if ( daysDiff >= 90 ) { + return [ 'week', 'month' ]; + } else if ( daysDiff >= 28 ) { + return [ 'day', 'week' ]; + } else if ( daysDiff >= 3 ) { + return [ 'day' ]; + } else if ( daysDiff >= 1 ) { + return [ 'hour', 'day' ]; + } + + return [ 'hour', 'day' ]; +} + +/** + * Returns the allowed selectable intervals for a specific period. + * + * @param period + * @param from + * @param to + * @return {Array} Array containing allowed intervals. + */ +function getAllowedIntervalsForPeriod( + period: string | undefined, + from: string, + to: string +): IntervalType[] { + switch ( period ) { + case 'today': + case 'yesterday': + return [ 'hour', 'day' ]; + case 'last-7-days': + return [ 'day' ]; + case 'last-30-days': + case 'last-month': + return [ 'day', 'week' ]; + case 'last-90-days': + return [ 'week', 'month' ]; + case 'last-12-months': + case 'last-365-days': + case 'last-year': + return [ 'month', 'quarter' ]; + default: + return getAllowedIntervalsByRange( from, to ); + } +} + +/** + * + * @param period + * @param from + * @param to + */ +export function getDefaultIntervalForPeriod( + period: string | undefined, + from: string, + to: string +): IntervalType { + return getAllowedIntervalsForPeriod( period, from, to )?.[ 0 ] ?? 'day'; +} + +/** + * + * @param period + * @param from + * @param to + */ +export function getDateFormatFromInterval( + period: string | undefined, // Pass in undefined to use the default interval. + from: string, + to: string +): string { + const interval = getDefaultIntervalForPeriod( period, from, to ); + + switch ( interval ) { + case 'hour': + return 'HH:mm'; + case 'day': + case 'week': + return 'MMM d'; + case 'month': + return 'MMM yyyy'; + case 'quarter': + return 'qqq yyyy'; + case 'year': + return 'yyyy'; + default: + return 'MMM d'; + } +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/parsing.ts b/projects/packages/premium-analytics/packages/data/src/utils/parsing.ts new file mode 100644 index 000000000000..8a4535a7231d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/parsing.ts @@ -0,0 +1,19 @@ +/** + * Safe integer parsing with fallback value + * @param value + * @param fallback + */ +export function safeParseInt( value: unknown, fallback = 0 ): number { + const num = parseInt( String( value ), 10 ); + return isNaN( num ) ? fallback : num; +} + +/** + * Safe float parsing with fallback value + * @param value + * @param fallback + */ +export function safeParseFloat( value: unknown, fallback = 0 ): number { + const num = parseFloat( String( value ) ); + return isNaN( num ) ? fallback : num; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/preset-date-range.ts b/projects/packages/premium-analytics/packages/data/src/utils/preset-date-range.ts new file mode 100644 index 000000000000..cf0345d1003d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/preset-date-range.ts @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { computePrimaryRange } from '@jetpack-premium-analytics/datetime'; +import { getSiteTimezone, dateToISOStringWithLocalTZ } from './date'; +import type { SelectablePresetId } from '@jetpack-premium-analytics/datetime'; +/** + * Internal dependencies + */ + +/** + * Compute the absolute date range for a given preset ID + * based on the current date and the site's timezone. + * + * Thin wrapper over datetime's computePrimaryRange that + * resolves the site timezone and converts Date -> ISO string. + * + * @param presetId - A valid selectable preset identifier. + * @return The computed { from, to } ISO strings, or undefined + * if the preset is not recognized. + */ +export function computeDateRangeFromPreset( + presetId: SelectablePresetId +): { from: string; to: string } | undefined { + const range = computePrimaryRange( presetId, getSiteTimezone() ); + if ( ! range?.from || ! range?.to ) { + return undefined; + } + + return { + from: dateToISOStringWithLocalTZ( range.from ), + to: dateToISOStringWithLocalTZ( range.to ), + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/product-filters.ts b/projects/packages/premium-analytics/packages/data/src/utils/product-filters.ts new file mode 100644 index 000000000000..063b924a50b2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/product-filters.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import type { FilterCondition } from '../types/filter-condition'; + +/** + * Product lookup table level filters. + */ +const PRODUCT_FILTER_KEYS = [ 'product_type', 'virtual', 'downloadable' ]; + +/** + * Checks if any of the provided filters are product-related filters + * + * @param filters - Array of filter conditions to check + * @return True if any filter is product-related, false otherwise + */ +export function hasProductFilters( filters?: FilterCondition[] ): boolean { + if ( ! filters || ! Array.isArray( filters ) || filters.length === 0 ) { + return false; + } + + return filters.some( filter => PRODUCT_FILTER_KEYS.includes( filter.key ) ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/search.ts b/projects/packages/premium-analytics/packages/data/src/utils/search.ts new file mode 100644 index 000000000000..0aa97aacb501 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/search.ts @@ -0,0 +1,145 @@ +/** + * External dependencies + */ +import { + isSelectablePreset, + type SelectablePresetId, + type ComparisonPresetId, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; +/** + * Internal dependencies + */ +import { ORDER_ATTRIBUTION_VIEWS } from '../api/report-order-attribution-summary-fetch'; +import { getDefaultQueryParams } from '../defaults'; +import { getDefaultIntervalForPeriod } from './interval'; +import { computeDateRangeFromPreset } from './preset-date-range'; +import type { DateType } from './types'; +import type { FilterCondition } from '../types/filter-condition'; + +export type { FilterCondition }; + +/** + * Re-export SelectablePresetId as PresetType for backward compatibility. + * The canonical type now lives in `@jetpack-premium-analytics/datetime`. + */ +export type PresetType = SelectablePresetId; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +export type IntervalType = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/* + * ReportParams are the expected params present in the client URL. + * They aren't meant to be the reports params + * of the API endpoint (RequestReportOrdersParams) + */ +export type ReportParams = { + from: string; + to: string; + preset?: PresetType; + interval: IntervalType; + period?: string; + compare_from?: string; + compare_to?: string; + compare_preset?: ComparisonPresetId; + comp?: '1'; + view?: OrderAttributionView; // For order attribution reports + filters?: FilterCondition[]; + section?: string; + date_type?: DateType; // For filtering by different date fields (created, paid, completed) +}; + +type PartialComparisonFields = Partial< + Pick< ReportParams, 'comp' | 'compare_from' | 'compare_to' > +>; + +/* + * Checks if the comparison is present in the search params. + */ +/** + * + * @param p + */ +export function hasComparisonEnabled< T extends PartialComparisonFields >( p: T ) { + return p.comp === '1' && !! p.compare_from?.trim() && !! p.compare_to?.trim(); +} + +type NormalizeReportParamsArgType = Omit< ReportParams, 'from' | 'to' | 'interval' | 'preset' > & { + from?: string; + to?: string; + interval?: string; + preset?: PrimaryPresetId; +}; + +/** + * Returns normalized params for the report request query. + * When no defined, it will use the defaults. + * + * @param {NormalizeReportParamsArgType} [search] - URL search params. + * @param {PresetType} [defaultPreset] - Override the fallback preset. + */ +export function normalizeReportParams( + search?: NormalizeReportParamsArgType, + defaultPreset?: PresetType +): ReportParams { + const defaults = defaultPreset + ? getDefaultQueryParams( true, defaultPreset ) + : getDefaultQueryParams( true ); + + // Preset handling: + // - Use search.preset only if valid + // - On fresh load (no from/to), fallback to defaults.preset + // - If user has explicit dates but no/invalid preset, + // keep undefined (custom range) + let preset: PresetType | undefined; + if ( search?.preset && isSelectablePreset( search.preset ) ) { + preset = search.preset; + } else if ( ! search?.from && ! search?.to ) { + preset = defaults.preset; + } + + // When a valid preset is present, recalculate from/to + // so rolling ranges like "Last 30 days" stay fresh + // on every page load instead of using stale URL dates. + // If the preset is valid but has no range implementation, + // clear it to avoid silently falling back to stale dates. + let presetRange: ReturnType< typeof computeDateRangeFromPreset >; + if ( preset ) { + presetRange = computeDateRangeFromPreset( preset ); + if ( ! presetRange ) { + preset = undefined; + } + } + + const from = presetRange?.from ?? search?.from ?? defaults.from; + const to = presetRange?.to ?? search?.to ?? defaults.to; + + // Calculate the interval from the resolved date range. + const interval = getDefaultIntervalForPeriod( undefined, from, to ); + + // Params from `search`, or fallback to defaults. + const normalized: ReportParams = { + from, + to, + interval: interval ?? defaults.interval, + preset, + date_type: search?.date_type ?? 'created', + }; + + // Add comparison params from search if enabled + if ( search && hasComparisonEnabled( search ) ) { + normalized.compare_from = search.compare_from; + normalized.compare_to = search.compare_to; + normalized.compare_preset = search.compare_preset; + normalized.comp = '1'; + } else if ( ! search?.from && hasComparisonEnabled( defaults ) ) { + // Fresh load (missing primary params) - apply default comparison + normalized.compare_from = defaults.compare_from; + normalized.compare_to = defaults.compare_to; + normalized.compare_preset = defaults.compare_preset; + normalized.comp = '1'; + } + + return normalized; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/types.ts b/projects/packages/premium-analytics/packages/data/src/utils/types.ts new file mode 100644 index 000000000000..35f200844148 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/types.ts @@ -0,0 +1,29 @@ +/** + * Utility type to override properties of a type. + * Useful for transforming API responses where some properties change type. + * + * @example + * type Raw = { count: string; name: string; } + * type Processed = Override< Raw, { count: number } > + * // Result: { count: number; name: string; } + */ +export type Override< T, U > = Omit< T, keyof U > & U; + +/** + * Date type parameter for filtering reports by different date fields. + * - 'created': Filter by order creation date (date_created_gmt) + * - 'paid': Filter by order payment date (date_paid_gmt) + * - 'completed': Filter by order completion date (date_completed_gmt) + */ +export type DateType = 'created' | 'paid' | 'completed'; + +/** + * Base parameters required by all report endpoints. + * These three parameters are common across all analytics reports. + */ +export type BaseReportParams = { + from: string; + to: string; + interval: string; + date_type?: DateType; +}; diff --git a/projects/packages/premium-analytics/tests/jest.config.cjs b/projects/packages/premium-analytics/tests/jest.config.cjs new file mode 100644 index 000000000000..621d39dcc87a --- /dev/null +++ b/projects/packages/premium-analytics/tests/jest.config.cjs @@ -0,0 +1,13 @@ +const path = require( 'path' ); +const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' ); + +module.exports = { + ...baseConfig, + rootDir: path.join( __dirname, '..' ), + moduleNameMapper: { + ...baseConfig.moduleNameMapper, + // Resolve internal `packages/*` imports to their TypeScript source, + // mirroring the tsconfig `paths` alias (see README → "Internal packages"). + '^@jetpack-premium-analytics/(.*)$': path.join( __dirname, '..', 'packages', '$1', 'src' ), + }, +};