diff --git a/packages/js/images/myyoast-logo.svg b/packages/js/images/myyoast-logo.svg
new file mode 100644
index 00000000000..f51bde0393d
--- /dev/null
+++ b/packages/js/images/myyoast-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/js/src/integrations-page.js b/packages/js/src/integrations-page.js
index e5e41620eed..db07f1e13a2 100644
--- a/packages/js/src/integrations-page.js
+++ b/packages/js/src/integrations-page.js
@@ -3,12 +3,15 @@ import domReady from "@wordpress/dom-ready";
import { Root } from "@yoast/ui-library";
import IntegrationsGrid from "./integrations-page/integrations-grid";
+import { registerMyyoastStore } from "./integrations-page/myyoast-connection/store";
import { registerReactComponent, renderReactRoot } from "./helpers/reactRoot";
window.YoastSEO = window.YoastSEO || {};
window.YoastSEO._registerReactComponent = registerReactComponent;
domReady( () => {
+ registerMyyoastStore();
+
const context = {
isRtl: Boolean( get( window, "wpseoScriptData.metabox.isRtl", false ) ),
};
diff --git a/packages/js/src/integrations-page/myyoast-connection/constants.js b/packages/js/src/integrations-page/myyoast-connection/constants.js
new file mode 100644
index 00000000000..7c4061c974e
--- /dev/null
+++ b/packages/js/src/integrations-page/myyoast-connection/constants.js
@@ -0,0 +1,9 @@
+/**
+ * Keep constants centralized to avoid circular dependency problems.
+ */
+
+/**
+ * The Redux store name of the MyYoast connection.
+ * @type {string}
+ */
+export const MYYOAST_STORE_NAME = "yoast-seo/myyoast-connection";
diff --git a/packages/js/src/integrations-page/myyoast-connection/myyoast-disconnect-modal.js b/packages/js/src/integrations-page/myyoast-connection/myyoast-disconnect-modal.js
new file mode 100644
index 00000000000..e5dc47884b1
--- /dev/null
+++ b/packages/js/src/integrations-page/myyoast-connection/myyoast-disconnect-modal.js
@@ -0,0 +1,60 @@
+import ExclamationIcon from "@heroicons/react/outline/ExclamationIcon";
+import { __ } from "@wordpress/i18n";
+import { Button, Modal, useSvgAria } from "@yoast/ui-library";
+import { noop } from "lodash";
+import PropTypes from "prop-types";
+
+/**
+ * Confirm modal for disconnecting the site from MyYoast.
+ *
+ * @param {boolean} isOpen Whether the modal is open.
+ * @param {function} onClose Cancel handler.
+ * @param {function} onConfirm Confirm handler.
+ * @returns {JSX.Element} The modal element.
+ */
+export const MyyoastConnectionDisconnectModal = ( {
+ isOpen,
+ onClose = noop,
+ onConfirm = noop,
+} ) => {
+ const svgAriaProps = useSvgAria();
+
+ return (
+
+
+
+
+
+
+
+
+ { __( "Disconnect this site from MyYoast?", "wordpress-seo" ) }
+
+
+ { __( "All connected users will be signed out and the site stops working with MyYoast until you connect it again.", "wordpress-seo" ) }
+
+
+
+
+
+
+
+
+
+ );
+};
+
+MyyoastConnectionDisconnectModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func,
+ onConfirm: PropTypes.func,
+};
diff --git a/packages/js/src/integrations-page/myyoast-connection/myyoast-integration.js b/packages/js/src/integrations-page/myyoast-connection/myyoast-integration.js
new file mode 100644
index 00000000000..5e80240e317
--- /dev/null
+++ b/packages/js/src/integrations-page/myyoast-connection/myyoast-integration.js
@@ -0,0 +1,522 @@
+import ArrowSmRightIcon from "@heroicons/react/solid/ArrowSmRightIcon";
+import CheckIcon from "@heroicons/react/solid/CheckIcon";
+import ExclamationCircleIcon from "@heroicons/react/solid/ExclamationCircleIcon";
+import ExclamationIcon from "@heroicons/react/solid/ExclamationIcon";
+import { dispatch, select, useSelect } from "@wordpress/data";
+import { useCallback, useEffect, useId, useRef, useState } from "@wordpress/element";
+import { __, _n, sprintf } from "@wordpress/i18n";
+import { addQueryArgs } from "@wordpress/url";
+import { Alert, Button, Link, Notifications, TooltipContainer, TooltipTrigger, TooltipWithContext, useSvgAria, useToggleState } from "@yoast/ui-library";
+import PropTypes from "prop-types";
+import { ReactComponent as MyYoastLogo } from "../../../images/myyoast-logo.svg";
+import { safeCreateInterpolateElement } from "../../helpers/i18n";
+import { MyyoastConnectionDisconnectModal } from "./myyoast-disconnect-modal";
+import { MYYOAST_STORE_NAME } from "./constants";
+import { Card } from "../tailwind-components/card";
+
+const LEARN_MORE_LINK = "https://yoa.st/integrations-myyoast";
+
+/**
+ * Resolves the user-facing message for a machine code the backend emits
+ * (`error_code` for errors, `message_key` for successes).
+ *
+ * A switch rather than a map so only the matched string is translated, and
+ * `__()` runs at call time rather than module load — keeping locale switching
+ * working. Unknown codes fall through to the generic error.
+ *
+ * @param {string} code The backend code.
+ * @returns {string} The translated message.
+ */
+// eslint-disable-next-line complexity
+const messageFor = ( code ) => {
+ switch ( code ) {
+ case "not_provisioned":
+ return __( "Your server doesn't support the MyYoast connection. Update Yoast SEO to the latest version. If the issue persists after updating, contact support.", "wordpress-seo" );
+ case "registration_gone":
+ return __( "MyYoast no longer recognizes this site. Connect this site to MyYoast again to restore the connection.", "wordpress-seo" );
+ case "rate_limited":
+ return __( "MyYoast has had a lot of connection attempts from this site or network. Please wait a few minutes and try again.", "wordpress-seo" );
+ case "server_capability":
+ return __( "MyYoast doesn't support a feature this version of Yoast SEO needs. Update Yoast SEO to the latest version. If the issue persists, contact support.", "wordpress-seo" );
+ case "myyoast_unreachable":
+ return __( "Couldn't reach MyYoast from this server. Check your server's outbound network access, then try again. If MyYoast is having issues, wait a few minutes and retry.", "wordpress-seo" );
+ case "token_request_failed_invalid_grant":
+ return __( "MyYoast rejected the credentials stored for this site. Disconnect and connect this site again to restore the connection.", "wordpress-seo" );
+ case "token_request_failed":
+ return __( "Something went wrong while talking to MyYoast. Try again in a moment. If the problem keeps happening, update Yoast SEO or contact support.", "wordpress-seo" );
+ case "token_storage_failed":
+ return __( "Couldn't save the new credentials on this site. Make sure your WordPress database is writable, then try again.", "wordpress-seo" );
+ case "invalid_resource":
+ return __( "Something went wrong. Refresh the page and try again. If the problem keeps happening, contact support.", "wordpress-seo" );
+ case "registration_failed":
+ return __( "Couldn't connect this site to MyYoast. Try again in a moment. If the problem keeps happening, update Yoast SEO or contact support.", "wordpress-seo" );
+ case "unknown_redirect_uri":
+ return __( "Couldn't verify this site because it's no longer recognized. Refresh the page and try again.", "wordpress-seo" );
+ case "invalid_user":
+ return __( "You need to be signed in to verify this site.", "wordpress-seo" );
+ case "connection_cancelled":
+ return __( "Connection cancelled. You can try again whenever you're ready.", "wordpress-seo" );
+ case "timeout":
+ return __( "Request to MyYoast timed out. Please try again.", "wordpress-seo" );
+ case "connect_success":
+ return __( "This site is now connected to MyYoast.", "wordpress-seo" );
+ case "update_success":
+ return __( "Connection updated to match this site's current URL.", "wordpress-seo" );
+ case "disconnect_success":
+ return __( "This site is no longer connected to MyYoast.", "wordpress-seo" );
+ case "verify_success":
+ // Emitted by the OAuth callback for both first-time setup and a
+ // standalone re-verify, so the copy describes the end state rather
+ // than the "verify" action.
+ return __( "Your MyYoast connection is now active.", "wordpress-seo" );
+ default:
+ return __( "Something went wrong. Try again in a moment. If the problem keeps happening, update Yoast SEO or contact support.", "wordpress-seo" );
+ }
+};
+
+// Success keys the backend may send. Used to gate success feedback so an
+// unrecognized key doesn't fall through to `messageFor`'s generic error string.
+const SUCCESS_MESSAGE_KEYS = new Set( [ "connect_success", "update_success", "disconnect_success", "verify_success" ] );
+
+/**
+ * Formats the rate-limit message in minutes or hours, with the correct
+ * singular/plural form. Sub-minute values round up to one minute.
+ *
+ * @param {number} seconds The retry-after value in seconds.
+ * @returns {string} The localised message.
+ */
+const formatRateLimitedMessage = ( seconds ) => {
+ const minutes = Math.ceil( seconds / 60 );
+ if ( minutes >= 60 ) {
+ const hours = Math.ceil( seconds / 3600 );
+ /* translators: %d is a number of hours. */
+ return sprintf( _n( "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d hour and try again.", "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d hours and try again.", hours, "wordpress-seo" ), hours );
+ }
+ /* translators: %d is a number of minutes. */
+ return sprintf( _n( "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d minute and try again.", "MyYoast has had a lot of connection attempts from this site or network. Please wait about %d minutes and try again.", minutes, "wordpress-seo" ), minutes );
+};
+
+const ACTION_DISPATCHERS = {
+ refreshStatus: "refreshMyyoastConnectionStatus",
+ connect: "connectMyyoastConnection",
+ update: "updateMyyoastConnection",
+ disconnect: "disconnectMyyoastConnection",
+};
+
+// Sentinel returned by runAction when another action is already in flight. Not a
+// real backend failure — the action was deliberately dropped, so callers ignore
+// it rather than surfacing it as an error.
+const ACTION_IN_FLIGHT = "action_in_flight";
+
+/**
+ * Resolves the user-facing message for a given error code.
+ *
+ * @param {string} code The backend error code.
+ * @param {Object} [details] Extra detail from the backend payload.
+ * @returns {string} The translated message.
+ */
+const resolveErrorMessage = ( code, details ) => {
+ if ( code === "rate_limited" ) {
+ const seconds = Number( details?.retry_after_seconds );
+ if ( Number.isFinite( seconds ) && seconds > 0 ) {
+ return formatRateLimitedMessage( seconds );
+ }
+ }
+ return messageFor( code );
+};
+
+/**
+ * Runs a MyYoast management action: dispatches the slice action and, unless
+ * silent, surfaces the outcome as a toast.
+ *
+ * @param {string} actionName The action (refreshStatus/connect/update/disconnect).
+ * @param {Object} [body] The request body.
+ * @param {Object} [options] Options.
+ * @param {boolean} [options.silent] When true, suppress feedback.
+ * @param {function} [options.onFeedback] Receives `{ variant, message }` to show as a toast.
+ * @returns {Promise