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
+ *