diff --git a/projects/js-packages/boost-score-api/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/boost-score-api/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/js-packages/boost-score-api/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/js-packages/charts/changelog/charts-220-bar-chart-comparison-mode-keyboard-a11y-zero-baseline-fixes b/projects/js-packages/charts/changelog/charts-220-bar-chart-comparison-mode-keyboard-a11y-zero-baseline-fixes new file mode 100644 index 000000000000..31d4cb15cb6a --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-220-bar-chart-comparison-mode-keyboard-a11y-zero-baseline-fixes @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Charts: fix Bar Chart comparison mode — pair the keyboard tooltip with the focused bar, keep the value axis zero-based, and make the tooltip label/value separator translatable. diff --git a/projects/js-packages/components/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/components/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/components/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/connection/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/connection/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/connection/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/critical-css-gen/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/critical-css-gen/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/js-packages/critical-css-gen/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/js-packages/idc/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/idc/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/idc/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/image-guide/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/image-guide/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/js-packages/image-guide/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/js-packages/jetpack-shared-stores/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/jetpack-shared-stores/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/jetpack-shared-stores/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/licensing/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/licensing/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/licensing/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/number-formatters/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/number-formatters/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/number-formatters/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/react-data-sync-client/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/react-data-sync-client/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/js-packages/react-data-sync-client/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/js-packages/shared-extension-utils/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/shared-extension-utils/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/js-packages/shared-extension-utils/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/js-packages/webpack-config/changelog/update-babel-configs-prep-for-v8 b/projects/js-packages/webpack-config/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..923ec24c217f --- /dev/null +++ b/projects/js-packages/webpack-config/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update babel configurations in preparation for v8. The biggest visible change is that consumers should be sure to set `targets` at the top level of their config if not configuring `browserslist` already. diff --git a/projects/js-packages/webpack-config/changelog/update-babel-configs-prep-for-v8#2 b/projects/js-packages/webpack-config/changelog/update-babel-configs-prep-for-v8#2 new file mode 100644 index 000000000000..36cb379058ac --- /dev/null +++ b/projects/js-packages/webpack-config/changelog/update-babel-configs-prep-for-v8#2 @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add `/targets` entry point, which returns the configured browserslist targets, defaulting to `@wordpress/browserslist-config` rather than browserslist's own defaults. diff --git a/projects/packages/agents-manager/changelog/update-babel-configs-prep-for-v8 b/projects/packages/agents-manager/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/packages/agents-manager/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/packages/connection/changelog/update-babel-configs-prep-for-v8 b/projects/packages/connection/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/packages/connection/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/packages/cookie-consent/README.md b/projects/packages/cookie-consent/README.md index fab6822d02c1..48d5de2758e1 100644 --- a/projects/packages/cookie-consent/README.md +++ b/projects/packages/cookie-consent/README.md @@ -44,7 +44,36 @@ clear `jetpack_cookie_consent_consent_log_db_version`, call: ## Configuration -Filter `jetpack_cookie_consent_config` to override defaults (geo API URL, GDPR/CCPA region lists, cookie policy URL, and the Tracks `event_prefix`). The Tracks event prefix defaults to `jetpack`; set it to `woocommerceanalytics` to keep continuity with the WooCommerce/Unified Analytics Tracks stream. +Filter `jetpack_cookie_consent_config` to override defaults. Geo controls are grouped under `geo`: + +```php +add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['geo'] = array_merge( + $config['geo'], + array( + 'provider' => 'custom', + 'api_url' => 'https://example.com/geo/', + 'country_code_cookie' => 'shopper_country', + 'region_cookie' => 'shopper_region', + 'cookie_duration' => 6 * HOUR_IN_SECONDS, + 'gdpr_countries' => array( 'GB', 'FR' ), + 'ccpa_regions' => array( 'california' ), + 'show_on_error' => true, + ) + ); + + $config['event_prefix'] = 'woocommerceanalytics'; + + return $config; + } +); +``` + +The default geo provider is `wpcom`, which resolves shoppers through `https://public-api.wordpress.com/geo/`. Set `geo.provider` to `custom` and provide `geo.api_url` to use a different source. The endpoint is fetched client-side with `cache: 'no-store'`, must be reachable from the browser, and must return JSON with `country_short` as a two-letter country code and `region` as a region/state name. The configured `geo.country_code_cookie` and `geo.region_cookie` values are written as host-only cookies and ignored by Jetpack Boost's page-cache key. + +The Tracks event prefix defaults to `jetpack`; set it to `woocommerceanalytics` to keep continuity with the WooCommerce/Unified Analytics Tracks stream. User-facing banner, preferences modal, footer link, CCPA page, and CCPA snackbar strings are configured through the `copy` group. Package defaults are translated with the `jetpack-cookie-consent` text domain. Consumers that override strings should translate those overrides before returning them from the filter, using their own text domain: diff --git a/projects/packages/cookie-consent/changelog/add-configurable-geo-provider b/projects/packages/cookie-consent/changelog/add-configurable-geo-provider new file mode 100644 index 000000000000..d57d6d694637 --- /dev/null +++ b/projects/packages/cookie-consent/changelog/add-configurable-geo-provider @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Geo: Add configurable provider and region settings. diff --git a/projects/packages/cookie-consent/src/class-cookie-consent.php b/projects/packages/cookie-consent/src/class-cookie-consent.php index af2e949de312..07edef6f005e 100644 --- a/projects/packages/cookie-consent/src/class-cookie-consent.php +++ b/projects/packages/cookie-consent/src/class-cookie-consent.php @@ -277,8 +277,8 @@ public static function register_ccpa_page_setting() { */ public static function ignore_geo_cookies_in_page_cache( $cookies ) { $config = self::get_config(); - $country_code = $config['country_code_cookie'] ?? 'country_code'; - $region = $config['region_cookie'] ?? 'region'; + $country_code = $config['geo']['country_code_cookie']; + $region = $config['geo']['region_cookie']; $cookies[] = preg_quote( $country_code, '/' ); $cookies[] = preg_quote( $region, '/' ); @@ -762,91 +762,213 @@ private static function normalize_copy( $copy, $defaults ) { } /** - * Get configuration with filters + * Get default GDPR country list. * - * @return array Configuration array + * @return string[] Country codes where opt-in consent applies. */ - private static function get_config() { - $default_copy = self::get_default_copy(); - $default_config = array( - 'geo_api_url' => 'https://public-api.wordpress.com/geo/', - 'geo_cookie_duration' => 6 * HOUR_IN_SECONDS, // 6 hours. + private static function get_default_gdpr_countries() { + return array( + // European Member countries. + 'AT', // Austria. + 'BE', // Belgium. + 'BG', // Bulgaria. + 'CY', // Cyprus. + 'CZ', // Czech Republic. + 'DE', // Germany. + 'DK', // Denmark. + 'EE', // Estonia. + 'ES', // Spain. + 'FI', // Finland. + 'FR', // France. + 'GR', // Greece. + 'HR', // Croatia. + 'HU', // Hungary. + 'IE', // Ireland. + 'IT', // Italy. + 'LT', // Lithuania. + 'LU', // Luxembourg. + 'LV', // Latvia. + 'MT', // Malta. + 'NL', // Netherlands. + 'PL', // Poland. + 'PT', // Portugal. + 'RO', // Romania. + 'SE', // Sweden. + 'SI', // Slovenia. + 'SK', // Slovakia. + 'GB', // United Kingdom. + // Single Market Countries that GDPR applies to. + 'CH', // Switzerland. + 'IS', // Iceland. + 'LI', // Liechtenstein. + 'NO', // Norway. + ); + } + + /** + * Get default CCPA-style region list. + * + * @return string[] Lower-case region names where opt-out consent applies. + */ + private static function get_default_ccpa_regions() { + return array( + /* US regions/states that are treated like California for Do Not Sell requests. */ + 'california', + 'utah', + 'virginia', + 'colorado', + 'connecticut', + 'texas', + 'tennessee', + 'oregon', + 'new jersey', + 'montana', + 'iowa', + 'indiana', + 'delaware', + ); + } + + /** + * Get default geo provider configuration. + * + * @return array Geo configuration. + */ + private static function get_default_geo_config() { + return array( + 'provider' => 'wpcom', + 'api_url' => 'https://public-api.wordpress.com/geo/', 'country_code_cookie' => 'country_code', 'region_cookie' => 'region', - 'cookie_policy_url' => 'https://automattic.com/cookies/', - 'gdpr_countries' => array( - // European Member countries. - 'AT', // Austria. - 'BE', // Belgium. - 'BG', // Bulgaria. - 'CY', // Cyprus. - 'CZ', // Czech Republic. - 'DE', // Germany. - 'DK', // Denmark. - 'EE', // Estonia. - 'ES', // Spain. - 'FI', // Finland. - 'FR', // France. - 'GR', // Greece. - 'HR', // Croatia. - 'HU', // Hungary. - 'IE', // Ireland. - 'IT', // Italy. - 'LT', // Lithuania. - 'LU', // Luxembourg. - 'LV', // Latvia. - 'MT', // Malta. - 'NL', // Netherlands. - 'PL', // Poland. - 'PT', // Portugal. - 'RO', // Romania. - 'SE', // Sweden. - 'SI', // Slovenia. - 'SK', // Slovakia. - 'GB', // United Kingdom. - // Single Market Countries that GDPR applies to. - 'CH', // Switzerland. - 'IS', // Iceland. - 'LI', // Liechtenstein. - 'NO', // Norway. - ), - 'ccpa_regions' => array( - /* US regions/states that are treated like California for Do Not Sell requests. */ - 'california', - 'utah', - 'virginia', - 'colorado', - 'connecticut', - 'texas', - 'tennessee', - 'oregon', - 'new jersey', - 'montana', - 'iowa', - 'indiana', - 'delaware', - ), + 'cookie_duration' => 6 * HOUR_IN_SECONDS, // 6 hours. + 'gdpr_countries' => self::get_default_gdpr_countries(), + 'ccpa_regions' => self::get_default_ccpa_regions(), 'show_on_error' => true, // Show banner if geolocation fails. + ); + } + + /** + * Get default configuration. + * + * Legacy top-level geo keys are included so existing filters that append to the + * defaults keep working. normalize_config() folds them into the nested geo schema. + * + * @return array Configuration array. + */ + private static function get_default_config() { + $default_copy = self::get_default_copy(); + $geo_config = self::get_default_geo_config(); + + return array( + 'geo' => $geo_config, + 'geo_provider' => $geo_config['provider'], + 'geo_api_url' => $geo_config['api_url'], + 'geo_cookie_duration' => $geo_config['cookie_duration'], + 'country_code_cookie' => $geo_config['country_code_cookie'], + 'region_cookie' => $geo_config['region_cookie'], + 'gdpr_countries' => $geo_config['gdpr_countries'], + 'ccpa_regions' => $geo_config['ccpa_regions'], + 'show_on_error' => $geo_config['show_on_error'], 'gdpr_honors_gpc' => true, // Honor a Global Privacy Control signal as an opt-out in GDPR regions. + 'cookie_policy_url' => 'https://automattic.com/cookies/', 'event_prefix' => 'jetpack', // Tracks event name prefix; set to 'woocommerceanalytics' for Unified Analytics continuity. 'copy' => $default_copy, ); + } - /** - * Filter cookie consent configuration - * - * @param array $config Configuration array - */ - $config = apply_filters( 'jetpack_cookie_consent_config', $default_config ); + /** + * Normalize GDPR country codes for case-insensitive matching. + * + * @param string[] $countries Country codes. + * @return string[] Upper-case country codes. + */ + private static function normalize_gdpr_countries( $countries ) { + return array_map( 'strtoupper', array_values( $countries ) ); + } + + /** + * Normalize CCPA region names for case-insensitive matching. + * + * @param string[] $regions Region names. + * @return string[] Lower-case region names. + */ + private static function normalize_ccpa_regions( $regions ) { + return array_map( 'strtolower', array_values( $regions ) ); + } + + /** + * Normalize filtered configuration into the current schema. + * + * @param array $config Filtered configuration. + * @param array $default_config Default configuration. + * @return array Normalized configuration. + */ + private static function normalize_config( $config, $default_config ) { if ( ! is_array( $config ) ) { - $config = $default_config; + $config = array(); } - $config['copy'] = self::normalize_copy( $config['copy'] ?? array(), $default_copy ); + $nested_geo = isset( $config['geo'] ) && is_array( $config['geo'] ) ? $config['geo'] : array(); + $geo = array_merge( $default_config['geo'], $nested_geo ); + + $legacy_geo_keys = array( + 'geo_provider' => 'provider', + 'geo_api_url' => 'api_url', + 'geo_cookie_duration' => 'cookie_duration', + 'country_code_cookie' => 'country_code_cookie', + 'region_cookie' => 'region_cookie', + 'gdpr_countries' => 'gdpr_countries', + 'ccpa_regions' => 'ccpa_regions', + 'show_on_error' => 'show_on_error', + ); + + foreach ( $legacy_geo_keys as $legacy_key => $geo_key ) { + $has_nested_override = array_key_exists( $geo_key, $nested_geo ); + if ( ! $has_nested_override && array_key_exists( $legacy_key, $config ) && $config[ $legacy_key ] !== $default_config[ $legacy_key ] ) { + $geo[ $geo_key ] = $config[ $legacy_key ]; + } + } + + if ( ! in_array( $geo['provider'], array( 'wpcom', 'custom' ), true ) ) { + $geo['provider'] = 'wpcom'; + } + if ( ! is_string( $geo['api_url'] ) ) { + $geo['api_url'] = ''; + } + if ( 'wpcom' === $geo['provider'] && '' === $geo['api_url'] ) { + $geo['api_url'] = $default_config['geo']['api_url']; + } + $geo['country_code_cookie'] = is_string( $geo['country_code_cookie'] ) && '' !== $geo['country_code_cookie'] ? $geo['country_code_cookie'] : $default_config['geo']['country_code_cookie']; + $geo['region_cookie'] = is_string( $geo['region_cookie'] ) && '' !== $geo['region_cookie'] ? $geo['region_cookie'] : $default_config['geo']['region_cookie']; + $geo['cookie_duration'] = is_numeric( $geo['cookie_duration'] ) ? (int) $geo['cookie_duration'] : $default_config['geo']['cookie_duration']; + $geo['gdpr_countries'] = is_array( $geo['gdpr_countries'] ) ? self::normalize_gdpr_countries( $geo['gdpr_countries'] ) : $default_config['geo']['gdpr_countries']; + $geo['ccpa_regions'] = is_array( $geo['ccpa_regions'] ) ? self::normalize_ccpa_regions( $geo['ccpa_regions'] ) : $default_config['geo']['ccpa_regions']; + $geo['show_on_error'] = (bool) $geo['show_on_error']; + + $config['geo'] = $geo; + $config['cookie_policy_url'] = $config['cookie_policy_url'] ?? $default_config['cookie_policy_url']; + $config['event_prefix'] = $config['event_prefix'] ?? $default_config['event_prefix']; + $config['copy'] = self::normalize_copy( $config['copy'] ?? array(), $default_config['copy'] ); return $config; } + /** + * Get configuration with filters. + * + * @return array Configuration array. + */ + private static function get_config() { + $default_config = self::get_default_config(); + + /** + * Filter cookie consent configuration + * + * @param array $config Configuration array + */ + return self::normalize_config( apply_filters( 'jetpack_cookie_consent_config', $default_config ), $default_config ); + } + /** * Enqueue scripts and styles */ @@ -919,22 +1041,24 @@ public static function enqueue_assets() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $force_preview = isset( $_GET['preview_cookie_consent'] ) && '1' === $_GET['preview_cookie_consent']; - // Pass configuration to frontend using wp_interactivity_config. - wp_interactivity_config( - 'jetpack/cookie-consent', - array( - 'geoApiUrl' => $config['geo_api_url'], - 'geoCookieDuration' => $config['geo_cookie_duration'], - 'countryCodeCookie' => $config['country_code_cookie'], - 'regionCookie' => $config['region_cookie'], - 'cookiePolicyUrl' => $config['cookie_policy_url'], - 'gdprCountries' => $config['gdpr_countries'], - 'ccpaRegions' => $config['ccpa_regions'], - 'gdprHonorsGpc' => $config['gdpr_honors_gpc'] ?? true, - 'showOnError' => $config['show_on_error'], - 'forcePreview' => $force_preview, - ) + $frontend_config = array( + 'cookiePolicyUrl' => $config['cookie_policy_url'], + 'gdprHonorsGpc' => $config['gdpr_honors_gpc'] ?? true, + 'forcePreview' => $force_preview, + 'geo' => array( + 'provider' => $config['geo']['provider'], + 'apiUrl' => $config['geo']['api_url'], + 'countryCodeCookie' => $config['geo']['country_code_cookie'], + 'regionCookie' => $config['geo']['region_cookie'], + 'cookieDuration' => $config['geo']['cookie_duration'], + 'gdprCountries' => $config['geo']['gdpr_countries'], + 'ccpaRegions' => $config['geo']['ccpa_regions'], + 'showOnError' => $config['geo']['show_on_error'], + ), ); + + // Pass configuration to frontend using wp_interactivity_config. + wp_interactivity_config( 'jetpack/cookie-consent', $frontend_config ); } /** diff --git a/projects/packages/cookie-consent/src/modules/cookie-consent/utils.ts b/projects/packages/cookie-consent/src/modules/cookie-consent/utils.ts index 490beafc701e..993902890596 100644 --- a/projects/packages/cookie-consent/src/modules/cookie-consent/utils.ts +++ b/projects/packages/cookie-consent/src/modules/cookie-consent/utils.ts @@ -8,8 +8,15 @@ import type { ConsentType, ConsentEventType, ConsentEventChoices } from './types export const UNKNOWN_COUNTRY_CODE = 'UNKNOWN'; interface Config { - gdprCountries: string[]; - ccpaRegions: string[]; + geo?: Partial< GeoConfig >; + geoProvider?: 'wpcom' | 'custom'; + geoApiUrl?: string; + geoCookieDuration?: number; + countryCodeCookie?: string; + regionCookie?: string; + gdprCountries?: string[]; + ccpaRegions?: string[]; + showOnError?: boolean; // Whether a Global Privacy Control signal force-denies non-essential cookies in GDPR // regions. Conservative default (honor GPC) applies when the flag is omitted. gdprHonorsGpc?: boolean; @@ -21,6 +28,39 @@ interface Context { type SameSiteValue = 'Lax' | 'Strict' | 'None'; +export interface GeoConfig { + provider: 'wpcom' | 'custom'; + apiUrl: string; + countryCodeCookie: string; + regionCookie: string; + cookieDuration: number; + gdprCountries: string[]; + ccpaRegions: string[]; + showOnError: boolean; +} + +const DEFAULT_GEO_CONFIG: GeoConfig = { + provider: 'wpcom', + apiUrl: 'https://public-api.wordpress.com/geo/', + countryCodeCookie: 'country_code', + regionCookie: 'region', + cookieDuration: 6 * 60 * 60, + // PHP (class-cookie-consent.php) owns these lists and always sends them in the frontend geo + // config, so these empty fallbacks only apply to a config passed without lists; an empty GDPR + // list then reads as "not GDPR". Keep the server authoritative rather than duplicating it here. + gdprCountries: [], + ccpaRegions: [], + showOnError: true, +}; + +function normalizeGdprCountries( countries: string[] ): string[] { + return countries.map( country => country.toUpperCase() ); +} + +function normalizeCcpaRegions( regions: string[] ): string[] { + return regions.map( region => region.toLowerCase() ); +} + export function getCookie( name: string ): string | null { const value = `; ${ document.cookie }`; const parts = value.split( `; ${ name }=` ); @@ -105,13 +145,38 @@ export function setConsentType( consentType: ConsentType ): void { window.dispatchEvent( new CustomEvent( 'wp_consent_type_defined' ) ); } +export function getGeoConfig( config: Config ): GeoConfig { + return { + ...DEFAULT_GEO_CONFIG, + provider: config.geo?.provider ?? config.geoProvider ?? DEFAULT_GEO_CONFIG.provider, + apiUrl: config.geo?.apiUrl ?? config.geoApiUrl ?? DEFAULT_GEO_CONFIG.apiUrl, + countryCodeCookie: + config.geo?.countryCodeCookie ?? + config.countryCodeCookie ?? + DEFAULT_GEO_CONFIG.countryCodeCookie, + regionCookie: + config.geo?.regionCookie ?? config.regionCookie ?? DEFAULT_GEO_CONFIG.regionCookie, + cookieDuration: + config.geo?.cookieDuration ?? config.geoCookieDuration ?? DEFAULT_GEO_CONFIG.cookieDuration, + gdprCountries: normalizeGdprCountries( + config.geo?.gdprCountries ?? config.gdprCountries ?? DEFAULT_GEO_CONFIG.gdprCountries + ), + ccpaRegions: normalizeCcpaRegions( + config.geo?.ccpaRegions ?? config.ccpaRegions ?? DEFAULT_GEO_CONFIG.ccpaRegions + ), + showOnError: config.geo?.showOnError ?? config.showOnError ?? DEFAULT_GEO_CONFIG.showOnError, + }; +} + export function isGdprCountry( countryCode: string, config: Config ): boolean { - return countryCode === UNKNOWN_COUNTRY_CODE || config.gdprCountries.includes( countryCode ); + const geoConfig = getGeoConfig( config ); + return countryCode === UNKNOWN_COUNTRY_CODE || geoConfig.gdprCountries.includes( countryCode ); } export function pertainsToCCPA( countryCode: string, region: string, config: Config ): boolean { const _region = ( region || '' ).toLowerCase(); - return countryCode === 'US' && config.ccpaRegions.includes( _region ); + const geoConfig = getGeoConfig( config ); + return countryCode === 'US' && geoConfig.ccpaRegions.includes( _region ); } export function hasOptedOutViaGlobalPrivacyControl(): boolean { diff --git a/projects/packages/cookie-consent/src/modules/cookie-consent/view.ts b/projects/packages/cookie-consent/src/modules/cookie-consent/view.ts index af9e572494bd..ebcfbd809a1f 100644 --- a/projects/packages/cookie-consent/src/modules/cookie-consent/view.ts +++ b/projects/packages/cookie-consent/src/modules/cookie-consent/view.ts @@ -21,8 +21,10 @@ import { readConsentChoices, saveConsentChoices, isGdprCountry, + getGeoConfig, pertainsToCCPA, handleConsentByRegion, + type GeoConfig, } from './utils'; interface GeoState { @@ -52,15 +54,9 @@ interface GdprManageLinkContext { } interface StoreConfig { - geoApiUrl: string; - geoCookieDuration: number; - countryCodeCookie: string; - regionCookie: string; + geo: GeoConfig; cookiePolicyUrl: string; - gdprCountries: string[]; - ccpaRegions: string[]; gdprHonorsGpc: boolean; - showOnError: boolean; forcePreview: boolean; } @@ -90,7 +86,11 @@ let manageLinkConsentListenerRegistered = false; const gdprManageLinkContexts = new Set< GdprManageLinkContext >(); function shouldShowManagePreferencesLink( config: StoreConfig ): boolean { - return isGdprCountry( geoState.countryCode || UNKNOWN_COUNTRY_CODE, config ) && hasConsentSet(); + return ( + geoState.countryCode !== null && + isGdprCountry( geoState.countryCode, config ) && + hasConsentSet() + ); } function focusModal(): void { @@ -397,10 +397,11 @@ const { actions } = store( 'jetpack/cookie-consent', { // getConfig() is not typed, so we need to assert the type. const config = getConfig() as unknown as StoreConfig; + const geoConfig = getGeoConfig( config ); // Check if we already have country code from cookies - const cachedCountryCode = getCookie( config.countryCodeCookie ); - const cachedRegion = getCookie( config.regionCookie ); + const cachedCountryCode = getCookie( geoConfig.countryCodeCookie ); + const cachedRegion = getCookie( geoConfig.regionCookie ); if ( cachedCountryCode !== null && cachedRegion !== null ) { geoState = { @@ -414,7 +415,11 @@ const { actions } = store( 'jetpack/cookie-consent', { // If either the country_code or region cookie is missing, fetch geolocation. // `no-store` avoids using the browser HTTP cache for this visitor-specific response. try { - const response = ( yield fetch( config.geoApiUrl, { cache: 'no-store' } ) ) as Response; + if ( ! geoConfig.apiUrl ) { + throw new Error( 'Geolocation provider URL is not configured' ); + } + + const response = ( yield fetch( geoConfig.apiUrl, { cache: 'no-store' } ) ) as Response; if ( ! response.ok ) { throw new Error( 'Geolocation request failed' ); } @@ -424,8 +429,8 @@ const { actions } = store( 'jetpack/cookie-consent', { const region = data.region || ''; // Store country code and region in cookies - setCookie( config.countryCodeCookie, countryCode, config.geoCookieDuration ); - setCookie( config.regionCookie, region, config.geoCookieDuration ); + setCookie( geoConfig.countryCodeCookie, countryCode, geoConfig.cookieDuration ); + setCookie( geoConfig.regionCookie, region, geoConfig.cookieDuration ); geoState = { initialized: true, @@ -433,15 +438,26 @@ const { actions } = store( 'jetpack/cookie-consent', { region, }; } catch ( error: unknown ) { + // A custom geo provider URL can fail independently (typo, CORS, 5xx, non-JSON), so + // surface it at warn level to make a misconfigured endpoint diagnosable in production. // eslint-disable-next-line no-console - console.debug( error ); - // On error, set to unknown + console.warn( error ); + if ( ! geoConfig.showOnError ) { + geoState = { + initialized: true, + countryCode: null, + region: null, + }; + return geoState; + } + + // On error, set to unknown so the default path shows the banner. geoState = { initialized: true, countryCode: UNKNOWN_COUNTRY_CODE, region: '', }; - setCookie( config.countryCodeCookie, UNKNOWN_COUNTRY_CODE, config.geoCookieDuration ); + setCookie( geoConfig.countryCodeCookie, UNKNOWN_COUNTRY_CODE, geoConfig.cookieDuration ); } return geoState; @@ -462,6 +478,9 @@ const { actions } = store( 'jetpack/cookie-consent', { // Initialize geolocation (will use cache if already done) const geoData: GeoState = yield actions.initializeGeolocation(); + if ( null === geoData.countryCode ) { + return; + } // Update cookie banner context if present if ( 'showBanner' in context ) { diff --git a/projects/packages/cookie-consent/tests/php/Config_Normalization_Test.php b/projects/packages/cookie-consent/tests/php/Config_Normalization_Test.php new file mode 100644 index 000000000000..d369121661a7 --- /dev/null +++ b/projects/packages/cookie-consent/tests/php/Config_Normalization_Test.php @@ -0,0 +1,211 @@ +getMethod( $method ); + + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + return $method->invokeArgs( null, $args ); + } + + /** + * Geo country and region lists are normalized for matching. + */ + public function test_normalize_config_normalizes_geo_list_casing() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'geo' => array( + 'gdpr_countries' => array( 'ca', 'gB' ), + 'ccpa_regions' => array( 'California', 'NEW JERSEY' ), + ), + ), + $default_config + ); + + $this->assertSame( array( 'CA', 'GB' ), $config['geo']['gdpr_countries'] ); + $this->assertSame( array( 'california', 'new jersey' ), $config['geo']['ccpa_regions'] ); + } + + /** + * Legacy geo list aliases are normalized when used. + */ + public function test_normalize_config_normalizes_legacy_geo_list_casing() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'gdpr_countries' => array( 'fr', 'dE' ), + 'ccpa_regions' => array( 'Texas', 'NEW JERSEY' ), + ), + $default_config + ); + + $this->assertSame( array( 'FR', 'DE' ), $config['geo']['gdpr_countries'] ); + $this->assertSame( array( 'texas', 'new jersey' ), $config['geo']['ccpa_regions'] ); + } + + /** + * An unknown provider falls back to wpcom and restores the default endpoint. + */ + public function test_normalize_config_rejects_unknown_provider() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( 'geo' => array( 'provider' => 'bogus' ) ), + $default_config + ); + + $this->assertSame( 'wpcom', $config['geo']['provider'] ); + $this->assertSame( $default_config['geo']['api_url'], $config['geo']['api_url'] ); + } + + /** + * A custom provider keeps an explicitly blank api_url so the frontend can flag it. + */ + public function test_normalize_config_keeps_blank_custom_api_url() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'geo' => array( + 'provider' => 'custom', + 'api_url' => '', + ), + ), + $default_config + ); + + $this->assertSame( 'custom', $config['geo']['provider'] ); + $this->assertSame( '', $config['geo']['api_url'] ); + } + + /** + * A blank or non-string wpcom api_url is reset to the default endpoint. + */ + public function test_normalize_config_resets_blank_wpcom_api_url() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + + foreach ( array( '', 123, null ) as $bad_url ) { + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'geo' => array( + 'provider' => 'wpcom', + 'api_url' => $bad_url, + ), + ), + $default_config + ); + + $this->assertSame( $default_config['geo']['api_url'], $config['geo']['api_url'] ); + } + } + + /** + * A nested geo override wins over a legacy alias; a legacy-only key still folds in. + */ + public function test_normalize_config_nested_geo_wins_over_legacy() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'geo' => array( + 'provider' => 'custom', + 'api_url' => 'https://nested.example.test/geo', + ), + 'geo_api_url' => 'https://legacy.example.test/geo', + 'country_code_cookie' => 'legacy_cc', + ), + $default_config + ); + + $this->assertSame( 'https://nested.example.test/geo', $config['geo']['api_url'] ); + $this->assertSame( 'legacy_cc', $config['geo']['country_code_cookie'] ); + } + + /** + * A nested override is kept even when the legacy mirror still holds the default value. + */ + public function test_normalize_config_keeps_nested_override_with_default_legacy() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'geo' => array( 'region_cookie' => 'shopper_region' ), + 'region_cookie' => $default_config['region_cookie'], + ), + $default_config + ); + + $this->assertSame( 'shopper_region', $config['geo']['region_cookie'] ); + } + + /** + * Scalar geo values are coerced; a non-numeric duration falls back to the default. + */ + public function test_normalize_config_coerces_geo_scalars() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + + $config = $this->call_cookie_consent_method( + 'normalize_config', + array( + 'geo' => array( + 'cookie_duration' => '7200', + 'show_on_error' => 0, + ), + ), + $default_config + ); + $this->assertSame( 7200, $config['geo']['cookie_duration'] ); + $this->assertFalse( $config['geo']['show_on_error'] ); + + $fallback = $this->call_cookie_consent_method( + 'normalize_config', + array( 'geo' => array( 'cookie_duration' => 'nope' ) ), + $default_config + ); + $this->assertSame( $default_config['geo']['cookie_duration'], $fallback['geo']['cookie_duration'] ); + } + + /** + * A non-array filter result falls back to the default geo configuration. + */ + public function test_normalize_config_handles_non_array_input() { + $default_config = $this->call_cookie_consent_method( 'get_default_config' ); + + foreach ( array( null, '', 'x', 5 ) as $bad_config ) { + $config = $this->call_cookie_consent_method( 'normalize_config', $bad_config, $default_config ); + $this->assertSame( $default_config['geo'], $config['geo'] ); + } + } +} diff --git a/projects/packages/cookie-consent/tests/utils.test.ts b/projects/packages/cookie-consent/tests/utils.test.ts index ced3abcf4905..dcc3ff470160 100644 --- a/projects/packages/cookie-consent/tests/utils.test.ts +++ b/projects/packages/cookie-consent/tests/utils.test.ts @@ -4,7 +4,15 @@ * @jest-environment-options {"url": "https://shop.example.co.uk/"} */ -import { getCookie, setCookie, handleConsentByRegion } from '../src/modules/cookie-consent/utils'; +import { + UNKNOWN_COUNTRY_CODE, + getCookie, + getGeoConfig, + handleConsentByRegion, + isGdprCountry, + pertainsToCCPA, + setCookie, +} from '../src/modules/cookie-consent/utils'; describe( 'setCookie', () => { let writes: string[]; @@ -194,3 +202,98 @@ describe( 'handleConsentByRegion (GDPR + GPC)', () => { expect( wasDenied( 'statistics' ) ).toBe( true ); } ); } ); + +describe( 'geo configuration helpers', () => { + it( 'prefers the nested geo schema over legacy top-level keys', () => { + const config = getGeoConfig( { + geoApiUrl: 'https://legacy.example.test/geo', + countryCodeCookie: 'legacy_country', + geo: { + provider: 'custom', + apiUrl: 'https://geo.example.test/lookup', + countryCodeCookie: 'shopper_country', + regionCookie: 'shopper_region', + cookieDuration: 120, + gdprCountries: [ 'gb' ], + ccpaRegions: [ 'California' ], + showOnError: false, + }, + } ); + + expect( config ).toMatchObject( { + provider: 'custom', + apiUrl: 'https://geo.example.test/lookup', + countryCodeCookie: 'shopper_country', + regionCookie: 'shopper_region', + cookieDuration: 120, + gdprCountries: [ 'GB' ], + ccpaRegions: [ 'california' ], + showOnError: false, + } ); + } ); + + it( 'returns defaults for an empty config', () => { + expect( getGeoConfig( {} ) ).toEqual( { + provider: 'wpcom', + apiUrl: 'https://public-api.wordpress.com/geo/', + countryCodeCookie: 'country_code', + regionCookie: 'region', + cookieDuration: 6 * 60 * 60, + gdprCountries: [], + ccpaRegions: [], + showOnError: true, + } ); + } ); + + it( 'falls back to the remaining legacy top-level keys', () => { + const config = getGeoConfig( { + geoProvider: 'custom', + geoApiUrl: 'https://legacy.example.test/geo', + geoCookieDuration: 999, + regionCookie: 'legacy_region', + showOnError: false, + } ); + + expect( config ).toMatchObject( { + provider: 'custom', + apiUrl: 'https://legacy.example.test/geo', + cookieDuration: 999, + regionCookie: 'legacy_region', + showOnError: false, + } ); + } ); + + it( 'uses configured GDPR country lists', () => { + const config = { + geo: { + gdprCountries: [ 'ca' ], + }, + }; + + expect( isGdprCountry( 'CA', config ) ).toBe( true ); + expect( isGdprCountry( 'GB', config ) ).toBe( false ); + expect( isGdprCountry( UNKNOWN_COUNTRY_CODE, config ) ).toBe( true ); + } ); + + it( 'uses configured CCPA regions case-insensitively', () => { + const config = { + geo: { + ccpaRegions: [ 'Quebec' ], + }, + }; + + expect( pertainsToCCPA( 'US', 'Quebec', config ) ).toBe( true ); + expect( pertainsToCCPA( 'CA', 'Quebec', config ) ).toBe( false ); + expect( pertainsToCCPA( 'US', 'California', config ) ).toBe( false ); + } ); + + it( 'normalizes legacy geo list aliases', () => { + const config = getGeoConfig( { + gdprCountries: [ 'ca' ], + ccpaRegions: [ 'Quebec' ], + } ); + + expect( config.gdprCountries ).toEqual( [ 'CA' ] ); + expect( config.ccpaRegions ).toEqual( [ 'quebec' ] ); + } ); +} ); diff --git a/projects/packages/explat/changelog/update-babel-configs-prep-for-v8 b/projects/packages/explat/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/packages/explat/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/packages/my-jetpack/changelog/fix-my-jetpack-seo-optin-fatal b/projects/packages/my-jetpack/changelog/fix-my-jetpack-seo-optin-fatal new file mode 100644 index 000000000000..e829f42ef89c --- /dev/null +++ b/projects/packages/my-jetpack/changelog/fix-my-jetpack-seo-optin-fatal @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fix fatal error when an older bundled SEO package is loaded: guard the SEO opt-in card with method_exists instead of class_exists. diff --git a/projects/packages/my-jetpack/changelog/migrate-product-detail-card-text-wp-ui b/projects/packages/my-jetpack/changelog/migrate-product-detail-card-text-wp-ui new file mode 100644 index 000000000000..5ea0829d01b8 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/migrate-product-detail-card-text-wp-ui @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +My Jetpack: migrate ProductDetailCard typography from the in-house @automattic/jetpack-components Text to @wordpress/ui Text, adopting the closest design-system variants. diff --git a/projects/packages/my-jetpack/changelog/update-babel-configs-prep-for-v8 b/projects/packages/my-jetpack/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/packages/plugin-deactivation/changelog/update-babel-configs-prep-for-v8 b/projects/packages/plugin-deactivation/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..f6864c1db180 --- /dev/null +++ b/projects/packages/plugin-deactivation/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Update babel config in preparation for v8. Should be no change to functionality. + + diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-utm-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-utm-data.ts new file mode 100644 index 000000000000..b55b32de46d5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-utm-data.ts @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { calculateDelta } from './calculate-delta'; +import type { LeaderboardChartData } from '../components/chart-leaderboard'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +/** + * Builds leaderboard chart data for the Sales by UTM widget. + * + * Transforms order attribution data into the format required by LeaderboardChart. + * + * @param orderAttribution - Primary period order attribution data + * @param maxEntries - Maximum number of entries to include in the leaderboard + * @return Processed data ready for LeaderboardChart component + */ +export function buildSalesByUtmData( + orderAttribution: ReportDataMap[ 'order-attribution' ] | undefined, + maxEntries = 4 +): LeaderboardChartData { + if ( ! orderAttribution?.data || orderAttribution.data.length === 0 ) { + return []; + } + + const { data } = orderAttribution; + + // Find the max value for share calculation + const maxValue = Math.max( + ...data.map( item => + Math.max( item.current_period.value || 0, item.previous_period?.value || 0 ) + ), + 1 // Prevent division by zero + ); + + return data.slice( 0, maxEntries ).map( ( item, idx ) => { + const currentValue = item.current_period.value || 0; + const previousValue = item.previous_period?.value ?? 0; + const delta = calculateDelta( currentValue, previousValue ); + + return { + id: item.item ? String( item.item ) : String( idx ), + label: item.item || __( 'Unassigned', 'jetpack-premium-analytics' ), + currentValue, + previousValue, + currentShare: ( currentValue / maxValue ) * 100, + previousShare: ( previousValue / maxValue ) * 100, + delta, + }; + } ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/index.ts new file mode 100644 index 000000000000..81f8a17dc1df --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/index.ts @@ -0,0 +1 @@ +export { PaymentStatusWidget } from './payment-status-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/payment-status-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/payment-status-widget.tsx new file mode 100644 index 000000000000..c0ac049cc06a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/payment-status-widget.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import { useReportOrders } from '@jetpack-premium-analytics/data'; +import { payment } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { DonutChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildPaymentStatusData, PAYMENT_STATUS_FILTERS } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from '../common/donut-widget.module.scss'; + +/** + * Payment Status Widget Component + * + * Displays a donut chart comparing revenue from paid orders vs unpaid orders. + * Shows the total revenue in the center with a breakdown in the legend. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function PaymentStatusWidget() { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportOrders( { + ...reportParams, + filters: PAYMENT_STATUS_FILTERS, + } ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => buildPaymentStatusData( primary.data, comparison.data ), + [ primary.data, comparison.data ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/index.ts new file mode 100644 index 000000000000..28a1e191a8dd --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/index.ts @@ -0,0 +1 @@ +export { SalesByUtmWidget } from './sales-by-utm-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/sales-by-utm-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/sales-by-utm-widget.tsx new file mode 100644 index 000000000000..eecabb0cad4b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/sales-by-utm-widget.tsx @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { + useReportOrderAttribution, + ORDER_ATTRIBUTION_VIEWS, +} from '@jetpack-premium-analytics/data'; +import { megaphone, search, channel } from '@jetpack-premium-analytics/icons'; +import { useMemo } from 'react'; +import { LeaderboardChart } from '../../components/chart-leaderboard'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildSalesByUtmData, formatLegendLabels } from '../../helpers'; +import { useWidgetError } from '../../hooks'; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +type SalesByUtmWidgetProps = { + /** + * The order attribution view to display (source, channel, campaign, etc.) + */ + view: OrderAttributionView; +}; + +/** + * Sales by UTM Widget Component + * + * Displays order attribution data in a leaderboard chart, showing how sales are + * distributed across different UTM parameters (source, channel, or campaign). + * + * Features: + * - Multiple views: source, channel, campaign + * - Displays data for all product types + * - Comparison support (current vs previous period) + * - Formatted legend labels with date ranges + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.view - The order attribution view (source, channel, campaign) + * + * @example + * + * + * + */ +export function SalesByUtmWidget( { view }: SalesByUtmWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + const params = useMemo( + () => ( { + ...reportParams, + view, + } ), + [ reportParams, view ] + ); + + const { primary, hasComparison, isLoading, isFetching, hasData, isError, error, refetch } = + useReportOrderAttribution( params ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const chartData = useMemo( () => buildSalesByUtmData( primary.data ), [ primary.data ] ); + + const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); + + const emptyStateIcon = useMemo( () => { + switch ( view ) { + case 'source': + return search; + case 'channel': + return channel; + case 'campaign': + return megaphone; + default: + return search; + } + }, [ view ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/stories/sales-by-utm-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/stories/sales-by-utm-widget.stories.tsx new file mode 100644 index 000000000000..da105a5d2895 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/stories/sales-by-utm-widget.stories.tsx @@ -0,0 +1,121 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { SalesByUtmWidget } from '../sales-by-utm-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof SalesByUtmWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SalesByUtm', + component: SalesByUtmWidget, + tags: [ 'autodocs' ], + decorators: [ withWidgetRoot( getDefaultQueryParams( true ) ) ], + argTypes: { + view: { + control: 'select', + options: [ 'source', 'channel', 'campaign' ], + description: 'The order attribution view to display', + }, + }, + parameters: { + docs: { + description: { + component: + 'Displays a leaderboard chart showing sales breakdown by UTM parameters. Supports three views: source (referral source), channel (marketing channel), and campaign (campaign name).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj< typeof SalesByUtmWidget >; + +/** + * Sales by Source - shows referral sources driving sales + */ +export const Source: Story = { + args: { + view: 'source', + }, +}; + +/** + * Sales by Channel - shows marketing channels driving sales + */ +export const Channel: Story = { + args: { + view: 'channel', + }, +}; + +/** + * Sales by Campaign - shows campaign performance + */ +export const Campaign: Story = { + args: { + view: 'campaign', + }, +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Uses source view with comparison enabled. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://linear.app/a8c/issue/WOOA7S-869 + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container. + * Context is provided by the meta-level decorator. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( +
+ +
+ ); +}; + +/** + * Small container (280px / xs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeSmall: Story = { + args: { + view: 'source', + }, + decorators: [ createSizeDecorator( '280px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + args: { + view: 'source', + }, + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (640px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + args: { + view: 'source', + }, + decorators: [ createSizeDecorator( '640px' ) ], +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/index.ts new file mode 100644 index 000000000000..9ee968c1e3bc --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/index.ts @@ -0,0 +1 @@ +export { VisitorMetricWidget } from './widget-visitor-metric'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/widget-visitor-metric.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/widget-visitor-metric.tsx new file mode 100644 index 000000000000..84fbcee4627d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/widget-visitor-metric.tsx @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { useReportVisitors } from '@jetpack-premium-analytics/data'; +/** + * Internal dependencies + */ +import { ReportMetricWidget } from '../../components/report-metric'; +import { useWidgetRootContext } from '../../components/widget-root'; + +/** + * Visitor Metric Widget Component + * + * A widget that displays visitor metrics over time with comparison support. + * This component must be used within a WidgetRoot which provides reportParams + * via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function VisitorMetricWidget() { + const { reportParams } = useWidgetRootContext(); + + return ( + + ); +} diff --git a/projects/packages/publicize/changelog/fix-social-modernized-non-admins b/projects/packages/publicize/changelog/fix-social-modernized-non-admins new file mode 100644 index 000000000000..4fd09c72905b --- /dev/null +++ b/projects/packages/publicize/changelog/fix-social-modernized-non-admins @@ -0,0 +1,5 @@ +Significance: patch +Type: fixed +Comment: Social modernized dashboard (behind rsm_jetpack_ui_modernization_social): hide the Overview stats chart and the Settings tab from non-admins, leaving only the connection-management UI. + + diff --git a/projects/packages/publicize/changelog/fix-social-modernized-viewport b/projects/packages/publicize/changelog/fix-social-modernized-viewport index 312a7fdf685b..daad6824a4b0 100644 --- a/projects/packages/publicize/changelog/fix-social-modernized-viewport +++ b/projects/packages/publicize/changelog/fix-social-modernized-viewport @@ -1,5 +1,3 @@ Significance: patch Type: fixed Comment: Social modernized dashboard (behind rsm_jetpack_ui_modernization_social): keep the connections modal within the content column so it no longer renders behind the wp-admin sidebar at some viewport widths (works on both wp-admin and WP.com nav-unification). - - diff --git a/projects/packages/publicize/changelog/renovate-@wordpressbuild b/projects/packages/publicize/changelog/renovate-@wordpressbuild new file mode 100644 index 000000000000..45028352a802 --- /dev/null +++ b/projects/packages/publicize/changelog/renovate-@wordpressbuild @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update package dependencies. diff --git a/projects/packages/publicize/changelog/update-babel-configs-prep-for-v8 b/projects/packages/publicize/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/packages/publicize/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/packages/wp-build-polyfills/changelog/renovate-@wordpressbuild b/projects/packages/wp-build-polyfills/changelog/renovate-@wordpressbuild new file mode 100644 index 000000000000..45028352a802 --- /dev/null +++ b/projects/packages/wp-build-polyfills/changelog/renovate-@wordpressbuild @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update package dependencies. diff --git a/projects/plugins/boost/changelog/force-a-release b/projects/plugins/boost/changelog/force-a-release new file mode 100644 index 000000000000..d4ad6c7cc337 --- /dev/null +++ b/projects/plugins/boost/changelog/force-a-release @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update dependencies. diff --git a/projects/plugins/boost/changelog/update-babel-configs-prep-for-v8 b/projects/plugins/boost/changelog/update-babel-configs-prep-for-v8 new file mode 100644 index 000000000000..9319090be651 --- /dev/null +++ b/projects/plugins/boost/changelog/update-babel-configs-prep-for-v8 @@ -0,0 +1,5 @@ +Significance: patch +Type: removed +Comment: Remove unused `@babel/` package deps. + + diff --git a/projects/plugins/premium-analytics/changelog/fix-composer-lock-cookie-consent-dev-deps b/projects/plugins/premium-analytics/changelog/fix-composer-lock-cookie-consent-dev-deps new file mode 100644 index 000000000000..28298d55a94d --- /dev/null +++ b/projects/plugins/premium-analytics/changelog/fix-composer-lock-cookie-consent-dev-deps @@ -0,0 +1,3 @@ +Significance: patch +Type: changed +Comment: Update composer.lock for cookie-consent dev dependencies.