diff --git a/package-lock.json b/package-lock.json
index 69ac4a2d..08a560f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,6 +53,267 @@
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz",
+ "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
+ "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.26.0",
+ "@babel/generator": "^7.26.0",
+ "@babel/helper-compilation-targets": "^7.25.9",
+ "@babel/helper-module-transforms": "^7.26.0",
+ "@babel/helpers": "^7.26.0",
+ "@babel/parser": "^7.26.0",
+ "@babel/template": "^7.25.9",
+ "@babel/traverse": "^7.25.9",
+ "@babel/types": "^7.26.0",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz",
+ "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.26.2",
+ "@babel/types": "^7.26.0",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz",
+ "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.25.9",
+ "@babel/helper-validator-option": "^7.25.9",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
+ "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.25.9",
+ "@babel/types": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
+ "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz",
+ "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
+ "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+ "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
+ "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
+ "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.25.9",
+ "@babel/types": "^7.26.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz",
+ "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.26.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz",
+ "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz",
+ "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz",
@@ -64,6 +325,64 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/template": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
+ "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.25.9",
+ "@babel/parser": "^7.25.9",
+ "@babel/types": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz",
+ "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.25.9",
+ "@babel/generator": "^7.25.9",
+ "@babel/parser": "^7.25.9",
+ "@babel/template": "^7.25.9",
+ "@babel/types": "^7.25.9",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse/node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz",
+ "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -448,14 +767,64 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.11.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz",
- "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==",
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
+ "node_modules/@eslint/config-array": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.0.tgz",
+ "integrity": "sha512-zdHg2FPIFNKPdcHWtiNT+jEFCHYVplAXRDlQDyqy0zGx/q2parwh7brGJSiTxRk/TSMkbM//zt/f5CHgyTyaSQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.4",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.0.tgz",
+ "integrity": "sha512-7ATR9F0e4W85D/0w7cU0SNj7qkAexMG+bAHEZOjo9akvGuhHE2m7umzWzfnpa0XAg5Kxc1BWmtPMV67jJ+9VUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/@eslint/eslintrc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
@@ -510,6 +879,29 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
+ "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz",
+ "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz",
@@ -544,6 +936,44 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz",
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig=="
},
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.6",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
+ "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
+ "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -601,6 +1031,20 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz",
+ "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@icons/material": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
@@ -2672,20 +3116,22 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.56.2",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.56.2.tgz",
- "integrity": "sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==",
+ "version": "5.60.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.60.5.tgz",
+ "integrity": "sha512-jiS1aC3XI3BJp83ZiTuDLerTmn9P3U95r6p+6/SNauLJaYxfIC4dMuWygwnBHIZxjn2zJqEpj3nysmPieoxfPQ==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.56.2",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.56.2.tgz",
- "integrity": "sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==",
+ "version": "5.60.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.60.5.tgz",
+ "integrity": "sha512-M77bOsPwj1wYE56gk7iJvxGAr4IC12NWdIDhT+Eo8ldkWRHMvIR8I/rufIvT1OXoV/bl7EECwuRuMlxxWtvW2Q==",
+ "license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.56.2"
+ "@tanstack/query-core": "5.60.5"
},
"funding": {
"type": "github",
@@ -2715,14 +3161,59 @@
"integrity": "sha512-BV9NplVgLmSi4mwKzD8BD/NQ8erOY/nUE/GpgWe2ckx+wIQF5RyRirn/QsSSCPeulVpc3RA/iJt6DpfTIZps0Q==",
"dev": true
},
- "node_modules/@types/cacheable-request": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
- "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@types/http-cache-semantics": "*",
- "@types/keyv": "^3.1.4",
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.6.8",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
+ "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.20.6",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
+ "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.20.7"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "dev": true,
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
"@types/node": "*",
"@types/responselike": "^1.0.0"
}
@@ -3473,6 +3964,26 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true
},
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz",
+ "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/plugin-transform-react-jsx-self": "^7.24.7",
+ "@babel/plugin-transform-react-jsx-source": "^7.24.7",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.14.2"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0"
+ }
+ },
"node_modules/@vitejs/plugin-react-swc": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.0.tgz",
@@ -3486,10 +3997,11 @@
}
},
"node_modules/acorn": {
- "version": "8.12.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
- "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
+ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
+ "license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@@ -3906,9 +4418,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.23.3",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
- "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
+ "version": "4.24.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
+ "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
"dev": true,
"funding": [
{
@@ -3924,11 +4436,12 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "caniuse-lite": "^1.0.30001646",
- "electron-to-chromium": "^1.5.4",
+ "caniuse-lite": "^1.0.30001669",
+ "electron-to-chromium": "^1.5.41",
"node-releases": "^2.0.18",
- "update-browserslist-db": "^1.1.0"
+ "update-browserslist-db": "^1.1.1"
},
"bin": {
"browserslist": "cli.js"
@@ -4036,9 +4549,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001660",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz",
- "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==",
+ "version": "1.0.30001680",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz",
+ "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==",
"dev": true,
"funding": [
{
@@ -4053,7 +4566,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
- ]
+ ],
+ "license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
@@ -4307,11 +4821,19 @@
"node": ">= 0.6"
}
},
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
+ "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -4753,10 +5275,11 @@
"dev": true
},
"node_modules/electron-to-chromium": {
- "version": "1.5.24",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.24.tgz",
- "integrity": "sha512-0x0wLCmpdKFCi9ulhvYZebgcPmHTkFVUfU2wzDykadkslKwT4oAmDTHEKLnlrDsMGZe4B+ksn8quZfZjYsBetA==",
- "dev": true
+ "version": "1.5.62",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.62.tgz",
+ "integrity": "sha512-t8c+zLmJHa9dJy96yBZRXGQYoiCEnHYgFwn1asvSPZSUdVxnB62A4RASd7k41ytG3ErFBA0TpHlKg9D9SQBmLg==",
+ "dev": true,
+ "license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
@@ -5432,9 +5955,10 @@
}
},
"node_modules/framer-motion": {
- "version": "11.5.4",
- "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.5.4.tgz",
- "integrity": "sha512-E+tb3/G6SO69POkdJT+3EpdMuhmtCh9EWuK4I1DnIC23L7tFPrl8vxP+LSovwaw6uUr73rUbpb4FgK011wbRJQ==",
+ "version": "11.11.17",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.17.tgz",
+ "integrity": "sha512-O8QzvoKiuzI5HSAHbcYuL6xU+ZLXbrH7C8Akaato4JzQbX2ULNeniqC2Vo5eiCtFktX9XsJ+7nUhxcl2E2IjpA==",
+ "license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
},
@@ -5456,9 +5980,9 @@
}
},
"node_modules/framer-plugin": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-2.0.2.tgz",
- "integrity": "sha512-Sm/7D1SisMp5JfDFb4Tb9omgbsqxNFNrOAMgJj6beIFAEPW7saSgc2sAWM00g1J/jdolOPVtD61S6z3EgqJFDg==",
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-2.0.4.tgz",
+ "integrity": "sha512-gsIyg4npci2jTcC0j931PchdUII7rT69RMqvCe2Vdhhd2Bzm2RNIQBnLmRhm1wuEWIzOJo85xsaQFr27WWhjSA==",
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -5499,6 +6023,16 @@
"node": ">=10"
}
},
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -5987,6 +6521,19 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsesc": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+ "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -6010,6 +6557,19 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true
},
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7067,9 +7627,10 @@
"link": true
},
"node_modules/picocolors": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
- "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -7483,9 +8044,10 @@
}
},
"node_modules/react-error-boundary": {
- "version": "4.0.13",
- "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz",
- "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz",
+ "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==",
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
@@ -7523,6 +8085,16 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/react-refresh": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
+ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz",
@@ -8057,6 +8629,10 @@
}
]
},
+ "node_modules/salesforce": {
+ "resolved": "plugins/salesforce",
+ "link": true
+ },
"node_modules/sander": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz",
@@ -8760,33 +9336,34 @@
}
},
"node_modules/tailwindcss": {
- "version": "3.4.12",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz",
- "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==",
+ "version": "3.4.15",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz",
+ "integrity": "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
- "chokidar": "^3.5.3",
+ "chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
- "fast-glob": "^3.3.0",
+ "fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
- "jiti": "^1.21.0",
+ "jiti": "^1.21.6",
"lilconfig": "^2.1.0",
- "micromatch": "^4.0.5",
+ "micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
- "picocolors": "^1.0.0",
- "postcss": "^8.4.23",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
- "postcss-load-config": "^4.0.1",
- "postcss-nested": "^6.0.1",
- "postcss-selector-parser": "^6.0.11",
- "resolve": "^1.22.2",
- "sucrase": "^3.32.0"
+ "postcss-load-config": "^4.0.2",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
@@ -9062,6 +9639,220 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-eslint": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.14.0.tgz",
+ "integrity": "sha512-K8fBJHxVL3kxMmwByvz8hNdBJ8a0YqKzKDX6jRlrjMuNXyd5T2V02HIq37+OiWXvUUOXgOOGiSSOh26Mh8pC3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.14.0",
+ "@typescript-eslint/parser": "8.14.0",
+ "@typescript-eslint/utils": "8.14.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.14.0.tgz",
+ "integrity": "sha512-tqp8H7UWFaZj0yNO6bycd5YjMwxa6wIHOLZvWPkidwbgLCsBMetQoGj7DPuAlWa2yGO3H48xmPwjhsSPPCGU5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.14.0",
+ "@typescript-eslint/type-utils": "8.14.0",
+ "@typescript-eslint/utils": "8.14.0",
+ "@typescript-eslint/visitor-keys": "8.14.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.3.1",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
+ "eslint": "^8.57.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.14.0.tgz",
+ "integrity": "sha512-2p82Yn9juUJq0XynBXtFCyrBDb6/dJombnz6vbo6mgQEtWHfvHbQuEa9kAOVIt1c9YFwi7H6WxtPj1kg+80+RA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.14.0",
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/typescript-estree": "8.14.0",
+ "@typescript-eslint/visitor-keys": "8.14.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz",
+ "integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/visitor-keys": "8.14.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.14.0.tgz",
+ "integrity": "sha512-Xcz9qOtZuGusVOH5Uk07NGs39wrKkf3AxlkK79RBK6aJC1l03CobXjJbwBPSidetAOV+5rEVuiT1VSBUOAsanQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "8.14.0",
+ "@typescript-eslint/utils": "8.14.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz",
+ "integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz",
+ "integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/visitor-keys": "8.14.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz",
+ "integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "8.14.0",
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/typescript-estree": "8.14.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz",
+ "integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.14.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@@ -9077,9 +9868,9 @@
"link": true
},
"node_modules/update-browserslist-db": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
- "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
+ "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
"dev": true,
"funding": [
{
@@ -9095,9 +9886,10 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "escalade": "^3.1.2",
- "picocolors": "^1.0.1"
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.0"
},
"bin": {
"update-browserslist-db": "cli.js"
@@ -11522,6 +12314,278 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "plugins/salesforce": {
+ "version": "0.0.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.59.20",
+ "classnames": "^2.5.1",
+ "framer-motion": "^11.11.11",
+ "framer-plugin": "^2.0.4",
+ "react": "^18",
+ "react-dom": "^18",
+ "react-error-boundary": "^4.1.2",
+ "usehooks-ts": "^3.1.0",
+ "vite-plugin-mkcert": "^1",
+ "wouter": "^3.3.5"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "@vitejs/plugin-react": "^4.3.1",
+ "@vitejs/plugin-react-swc": "^3",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.9.0",
+ "eslint-plugin-react-hooks": "^5.1.0-rc.0",
+ "eslint-plugin-react-refresh": "^0.4.9",
+ "globals": "^15.9.0",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.14",
+ "typescript": "^5.3",
+ "typescript-eslint": "^8.0.1",
+ "vite": "^5",
+ "vite-plugin-framer": "^1"
+ }
+ },
+ "plugins/salesforce/node_modules/@eslint/eslintrc": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
+ "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "plugins/salesforce/node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "plugins/salesforce/node_modules/@eslint/js": {
+ "version": "9.15.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.15.0.tgz",
+ "integrity": "sha512-tMTqrY+EzbXmKJR5ToI8lxu7jaN5EdmrBFJpQk5JmSlyLsx6o4t27r883K5xsLuCYCpfKBCGswMSWXsM+jB7lg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "plugins/salesforce/node_modules/@types/estree": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "plugins/salesforce/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "plugins/salesforce/node_modules/eslint": {
+ "version": "9.15.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.15.0.tgz",
+ "integrity": "sha512-7CrWySmIibCgT1Os28lUU6upBshZ+GxybLOrmRzi08kS8MBuO8QA7pXEgYgY5W8vK3e74xv0lpjo9DbaGU9Rkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.19.0",
+ "@eslint/core": "^0.9.0",
+ "@eslint/eslintrc": "^3.2.0",
+ "@eslint/js": "9.15.0",
+ "@eslint/plugin-kit": "^0.2.3",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.1",
+ "@types/estree": "^1.0.6",
+ "@types/json-schema": "^7.0.15",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.5",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.2.0",
+ "eslint-visitor-keys": "^4.2.0",
+ "espree": "^10.3.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "plugins/salesforce/node_modules/eslint-plugin-react-hooks": {
+ "version": "5.1.0-rc-fb9a90fa48-20240614",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz",
+ "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "plugins/salesforce/node_modules/eslint-scope": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz",
+ "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "plugins/salesforce/node_modules/eslint-visitor-keys": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+ "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "plugins/salesforce/node_modules/espree": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
+ "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.14.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "plugins/salesforce/node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "plugins/salesforce/node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "plugins/salesforce/node_modules/globals": {
+ "version": "15.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz",
+ "integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "plugins/salesforce/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"plugins/threshold": {
"version": "0.0.0",
"dependencies": {
diff --git a/plugins/salesforce/.gitignore b/plugins/salesforce/.gitignore
new file mode 100644
index 00000000..25519452
--- /dev/null
+++ b/plugins/salesforce/.gitignore
@@ -0,0 +1,33 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+
+/node_modules
+/.pnp
+.pnp.js
+.yarn
+
+# misc
+
+.DS_Store
+\*.pem
+
+# files
+
+my-plugin
+dev-plugin
+dist
+
+# debug
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log\*
+
+# local env files
+
+.env\*.local
+
+# Packed plugin
+
+plugin.zip
diff --git a/plugins/salesforce/README.md b/plugins/salesforce/README.md
new file mode 100644
index 00000000..8c659b80
--- /dev/null
+++ b/plugins/salesforce/README.md
@@ -0,0 +1,19 @@
+# Framer Plugin Template
+
+This is a template for using the Framer Plugin API in a TypeScript project.
+
+## Quickstart
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Learn more: https://www.framer.com/developers/plugins/introduction
diff --git a/plugins/salesforce/eslint.config.js b/plugins/salesforce/eslint.config.js
new file mode 100644
index 00000000..6e64b68b
--- /dev/null
+++ b/plugins/salesforce/eslint.config.js
@@ -0,0 +1,25 @@
+import js from "@eslint/js"
+import globals from "globals"
+import reactHooks from "eslint-plugin-react-hooks"
+import reactRefresh from "eslint-plugin-react-refresh"
+import tseslint from "typescript-eslint"
+
+export default tseslint.config(
+ { ignores: ["dist"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2022,
+ globals: globals.browser,
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ },
+ }
+)
diff --git a/plugins/salesforce/framer.json b/plugins/salesforce/framer.json
new file mode 100644
index 00000000..78fa0d77
--- /dev/null
+++ b/plugins/salesforce/framer.json
@@ -0,0 +1,6 @@
+{
+ "id": "0fb97d",
+ "name": "Salesforce",
+ "modes": ["canvas", "configureManagedCollection", "syncManagedCollection"],
+ "icon": "/salesforce.svg"
+}
diff --git a/plugins/salesforce/index.html b/plugins/salesforce/index.html
new file mode 100644
index 00000000..c491ce02
--- /dev/null
+++ b/plugins/salesforce/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Salesforce
+
+
+
+
+
+
+
diff --git a/plugins/salesforce/package.json b/plugins/salesforce/package.json
new file mode 100644
index 00000000..e25f2caa
--- /dev/null
+++ b/plugins/salesforce/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "salesforce",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "pack": "npx framer-plugin-tools@latest pack"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.59.20",
+ "classnames": "^2.5.1",
+ "framer-motion": "^11.11.11",
+ "framer-plugin": "^2.0.4",
+ "react": "^18",
+ "react-dom": "^18",
+ "react-error-boundary": "^4.1.2",
+ "usehooks-ts": "^3.1.0",
+ "vite-plugin-mkcert": "^1",
+ "wouter": "^3.3.5"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "@vitejs/plugin-react": "^4.3.1",
+ "@vitejs/plugin-react-swc": "^3",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.9.0",
+ "eslint-plugin-react-hooks": "^5.1.0-rc.0",
+ "eslint-plugin-react-refresh": "^0.4.9",
+ "globals": "^15.9.0",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.14",
+ "typescript": "^5.3",
+ "typescript-eslint": "^8.0.1",
+ "vite": "^5",
+ "vite-plugin-framer": "^1"
+ }
+}
diff --git a/plugins/salesforce/postcss.config.js b/plugins/salesforce/postcss.config.js
new file mode 100644
index 00000000..d41ad635
--- /dev/null
+++ b/plugins/salesforce/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/plugins/salesforce/public/salesforce.svg b/plugins/salesforce/public/salesforce.svg
new file mode 100644
index 00000000..bd3133a8
--- /dev/null
+++ b/plugins/salesforce/public/salesforce.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/plugins/salesforce/src/App.tsx b/plugins/salesforce/src/App.tsx
new file mode 100644
index 00000000..b16eb197
--- /dev/null
+++ b/plugins/salesforce/src/App.tsx
@@ -0,0 +1,211 @@
+import { framer } from "framer-plugin"
+import { useEffect, useState } from "react"
+import { useLocation } from "wouter"
+import { Router, Route } from "./router"
+import { getPluginContext, PluginContext, shouldSyncImmediately, syncAllRecords } from "./cms"
+import { assert } from "./utils"
+import auth from "./auth"
+import {
+ Account,
+ AccountEngagementForms,
+ AccountEngagementFormHandlers,
+ Auth,
+ BusinessUnitId,
+ Messaging,
+ Sync,
+ Menu,
+ ObjectSearch,
+ Tracking,
+ TrackingMID,
+ WebForm,
+ WebFormFields,
+ DomainConnection,
+} from "./pages"
+
+const routes: Route[] = [
+ {
+ path: "/",
+ element: Auth,
+ },
+ {
+ path: "/business-unit-id",
+ element: BusinessUnitId,
+ },
+ {
+ path: "/menu",
+ element: Menu,
+ size: {
+ height: 530,
+ },
+ },
+ {
+ path: "/account",
+ element: Account,
+ title: "Account",
+ size: {
+ height: 459,
+ },
+ children: [
+ {
+ path: "/domain-connection",
+ element: DomainConnection,
+ title: "Domain Connection",
+ size: {
+ height: 190,
+ },
+ },
+ ],
+ },
+ {
+ path: "/messaging",
+ element: Messaging,
+ title: "Messaging",
+ size: {
+ height: 361,
+ },
+ },
+ {
+ path: "/web-form",
+ element: WebForm,
+ title: () => `${history.state.title} Form`,
+ size: {
+ height: 233,
+ },
+ children: [
+ {
+ path: "/fields",
+ element: WebFormFields,
+ title: () => `${history.state.title} Fields`,
+ size: {
+ height: 387,
+ },
+ },
+ ],
+ },
+ {
+ path: "/account-engagement-forms",
+ title: "Account Engagement Forms",
+ element: AccountEngagementForms,
+ children: [
+ {
+ path: "/handlers",
+ element: AccountEngagementFormHandlers,
+ title: "Form Handlers",
+ },
+ ],
+ },
+ {
+ path: "/object-search",
+ element: ObjectSearch,
+ title: () => history.state?.title,
+ size: {
+ height: 400,
+ },
+ },
+ {
+ path: "/tracking",
+ title: "Tracking",
+ element: Tracking,
+ size: {
+ height: 170,
+ },
+ children: [
+ {
+ path: "/mid",
+ element: TrackingMID,
+ title: "Tracking MID",
+ size: {
+ height: 215,
+ },
+ },
+ ],
+ },
+ {
+ path: "/sync",
+ element: Sync,
+ size: {
+ width: 340,
+ height: 425,
+ },
+ },
+]
+
+export function App() {
+ const [, navigate] = useLocation()
+ const [isLoading, setIsLoading] = useState(true)
+ const [pluginContext, setPluginContext] = useState(null)
+
+ const isAuthenticated = auth.isAuthenticated()
+
+ useEffect(() => {
+ const mode = framer.mode
+ const isInCMSModes = mode === "syncManagedCollection" || mode === "configureManagedCollection"
+
+ async function handleCMSModes() {
+ const context = await getPluginContext()
+ const shouldSync = shouldSyncImmediately(context) && mode === "syncManagedCollection"
+
+ setPluginContext(context)
+
+ if (context.type === "update") {
+ const { objectId, objectLabel, includedFieldIds, collectionFields, slugFieldId, fieldConfigs } = context
+
+ if (shouldSync) {
+ assert(slugFieldId)
+
+ return syncAllRecords({
+ objectId,
+ objectLabel,
+ fields: collectionFields,
+ includedFieldIds,
+ slugFieldId,
+ fieldConfigs,
+ onProgress: () => {
+ // TODO: Progress indicator.
+ },
+ }).then(() => framer.closePlugin())
+ }
+
+ // Manage collection
+ navigate(`/sync?objectName=${objectId}&objectLabel=${objectLabel}`)
+ return
+ }
+
+ // Create collection
+ navigate("/object-search?redirect=/sync")
+ }
+
+ async function getContext() {
+ if (!isAuthenticated) {
+ navigate("/")
+ return
+ }
+
+ if (isInCMSModes) {
+ return handleCMSModes()
+ }
+
+ const businessUnitId = auth.getBusinessUnitId()
+
+ // Authenticated but has not completed the setup
+ if (businessUnitId === null) {
+ navigate("/business-unit-id")
+ return
+ }
+
+ navigate("/menu")
+ }
+
+ getContext()
+ .then(() => setIsLoading(false))
+ .catch(e => framer.closePlugin(e instanceof Error ? e.message : "Unknown error", { variant: "error" }))
+ }, [isAuthenticated, navigate])
+
+ if (isLoading) return null
+
+ return (
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/PluginError.ts b/plugins/salesforce/src/PluginError.ts
new file mode 100644
index 00000000..5d99a088
--- /dev/null
+++ b/plugins/salesforce/src/PluginError.ts
@@ -0,0 +1,15 @@
+export type ErrorType = "auth"
+
+export class PluginError extends Error {
+ title: string
+ type?: ErrorType
+
+ constructor(title: string, message: string, type?: ErrorType) {
+ super(message)
+
+ Object.setPrototypeOf(this, PluginError.prototype)
+
+ this.title = title
+ this.type = type
+ }
+}
diff --git a/plugins/salesforce/src/api.ts b/plugins/salesforce/src/api.ts
new file mode 100644
index 00000000..52307c2f
--- /dev/null
+++ b/plugins/salesforce/src/api.ts
@@ -0,0 +1,606 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import auth from "./auth"
+import { API_URL } from "./constants"
+import { PluginError } from "./PluginError"
+
+/**
+ * Salesforce Core
+ */
+export interface SFQueryResult {
+ size: number
+ totalSize: number
+ done: boolean
+ records: T[]
+ nextRecordsUrl?: string
+}
+
+type SFApiErrorResponse = Array<{
+ message: string
+ errorCode: string
+}>
+
+export interface SFSite {
+ Id: string
+ Name: string
+}
+
+export interface SFUser {
+ sub: string
+ name: string
+ email: string
+ organization_id: string
+ user_id: string
+ urls: {
+ custom_domain: string
+ // Add more as needed
+ }
+ // Add more as needed
+}
+
+export interface SFOrg {
+ Id: string
+ Name: string
+ LanguageLocaleKey: string // e.g. "en_US"
+ OrganizationType: string // e.g. "Developer Edition"
+ // Add more as needed
+}
+
+export type SFTrustedSitesList = SFQueryResult<{
+ Id: string
+ EndpointUrl: string
+}>
+
+export type SFCorsWhitelist = SFQueryResult<{
+ Id: string
+ UrlPattern: string
+}>
+
+export interface EmbeddedService {
+ Id: string
+ DurableId: string
+ Site: string
+}
+
+export interface SFObject {
+ custom: boolean
+ label: string
+ labelPlural: string
+ name: string
+ keyPrefix: string
+ // Add more fields as needed
+}
+
+export interface SFObjectConfig {
+ fields: SFFieldConfig[]
+}
+
+export interface SFObjects {
+ maxBatchSize: number
+ sobjects: SFObject[]
+}
+
+export interface SFFieldConfig {
+ name: string
+ label: string
+ updateable: boolean
+ createable: boolean
+ picklistValues?: Array<{
+ value: string
+ label: string
+ }>
+ referenceTo: string[] // e.g. ["User"]
+ relationshipName: string // e.g. "Owner"
+ type:
+ | "id"
+ | "boolean"
+ | "currency"
+ | "date"
+ | "datetime"
+ | "double"
+ | "email"
+ | "int"
+ | "long"
+ | "phone"
+ | "multipicklist"
+ | "picklist"
+ | "reference"
+ | "string"
+ | "textarea"
+ | "url"
+ | "richtext"
+ | "base64"
+ | "time"
+}
+
+export type SFRecordFieldValue = string | number | null
+
+export type SFRecord = Record
+
+/**
+ * Account Engagement
+ */
+export interface AEQueryResult {
+ nextPageToken: string | null
+ nextPageUrl: string | null
+ values: T[]
+}
+
+export interface AEErrorResponse {
+ code: number
+ message: string
+}
+
+export interface AEForm {
+ id: string
+ name: string
+ url: string
+ embedCode: string
+ // Add more as needed
+}
+
+/**
+ * Framer Salesforce Plugin Backend API
+ */
+export interface NewForm {
+ webhook: string
+}
+
+export interface FramerSalesforceAPIErrorResponse {
+ error: {
+ message: string
+ }
+ details?: Array<{
+ code: number
+ message: string
+ }>
+}
+
+type QueryParams = Record | URLSearchParams
+
+interface RequestOptions {
+ path: string
+ method?: string
+ query?: QueryParams
+ service?: "core" | "mcae" | null
+ // eslint-disable-next-line
+ body?: any
+}
+
+const SF_CORE_API_VERSION = "v62.0"
+const ACCOUNT_ENGAGEMENT_API_VERSION = "v5"
+const PROXY_URL = "https://framer-cors-proxy.framer-team.workers.dev/?"
+
+// eslint-disable-next-line
+const isSFApiErrorResponse = (data: any): data is SFApiErrorResponse => {
+ return data && typeof data.errorCode === "string" && typeof data.message === "string"
+}
+
+// eslint-disable-next-line
+const isAEErrorResponse = (data: any): data is AEErrorResponse => {
+ return data && typeof data.code === "number" && typeof data.message === "string"
+}
+
+export const request = async (
+ { path, method, query, body, service = "core" }: RequestOptions,
+ retries = 1
+): Promise => {
+ try {
+ const { instanceUrl, accessToken } = auth.tokens.getOrThrow()
+ let businessUnitId: string | null
+ let baseUrl: string
+
+ const bodyAsJsonString = !body || Object.entries(body).length === 0 ? undefined : JSON.stringify(body)
+ const headers: Record = {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: "application/json",
+ "X-Prettyprint": "1",
+ }
+
+ if (bodyAsJsonString !== undefined) {
+ headers["content-type"] = "application/json"
+ }
+
+ switch (service) {
+ case "core": {
+ baseUrl = `${instanceUrl}/services/data/${SF_CORE_API_VERSION}${path}`
+ break
+ }
+ case "mcae": {
+ const isDevOrg = instanceUrl.includes("dev-ed")
+ const domain = isDevOrg ? "pi.demo.pardot.com" : "pi.pardot.com"
+ baseUrl = `https://${domain}/api/${ACCOUNT_ENGAGEMENT_API_VERSION}${path}`
+
+ businessUnitId = auth.getBusinessUnitId()
+ if (!businessUnitId) {
+ throw new PluginError(
+ "Access Denied",
+ "You do not have access to the Salesforce Account Engagement feature."
+ )
+ }
+
+ headers["Pardot-Business-Unit-Id"] = businessUnitId
+
+ break
+ }
+ default: {
+ baseUrl = `${instanceUrl}${path}`
+ }
+ }
+
+ const url = new URL(baseUrl)
+ if (query) {
+ Object.entries(query).forEach(([key, value]) => {
+ if (value !== undefined) {
+ url.searchParams.append(key, value.toString())
+ }
+ })
+ }
+
+ const proxyUrl = `${PROXY_URL}${url.toString()}`
+
+ const res = await fetch(proxyUrl, {
+ method: method?.toUpperCase() ?? "GET",
+ body: bodyAsJsonString,
+ headers,
+ })
+
+ if (res.status === 204) return null as T
+
+ const data: SFApiErrorResponse | AEErrorResponse | T = await res.json()
+
+ if (res.ok) return data as T
+
+ if (res.status === 403) {
+ throw new PluginError(
+ "Access Denied",
+ "You either have insufficient permissions or your Org does not have access to this Salesforce feature"
+ )
+ }
+
+ if (res.status === 401 && retries > 0) {
+ try {
+ // Refresh token and retry once
+ await auth.refreshTokens()
+ return request({ path, method, query, body }, retries - 1)
+ } catch {
+ throw new PluginError("Auth Error", "Failed to refresh tokens")
+ }
+ }
+
+ if (isSFApiErrorResponse(data)) {
+ throw new PluginError("Salesforce API Error", data[0].message)
+ }
+
+ if (isAEErrorResponse(data)) {
+ throw new PluginError("Account Engagement API Error", data.message)
+ }
+
+ throw new PluginError("Something went wrong", JSON.stringify(data))
+ } catch (e) {
+ if (e instanceof PluginError) throw e
+
+ throw new PluginError("Something went wrong", e instanceof Error ? e.message : JSON.stringify(e))
+ }
+}
+
+/**
+ * Retrieve an object's configuration (fields, relations, etc...)
+ */
+export const fetchObjectConfig = (objectName: string) => {
+ return request({
+ path: `/sobjects/${objectName}/describe`,
+ })
+}
+
+/**
+ * Retrieve ALL of an object's records
+ */
+export const fetchObjectRecords = async (
+ objectName: string,
+ fields: string[],
+ maxRecords?: number
+): Promise => {
+ const allRecords: SFRecord[] = []
+ let nextUrl: string | null = `/query/?q=SELECT+${fields.join(",")}+FROM+${objectName}`
+
+ while (nextUrl && (!maxRecords || allRecords.length < maxRecords)) {
+ const result: SFQueryResult = await request({
+ path: nextUrl,
+ })
+
+ const remainingRecords = maxRecords ? maxRecords - allRecords.length : result.records.length
+
+ allRecords.push(...result.records.slice(0, remainingRecords))
+
+ nextUrl = result.done || (maxRecords && allRecords.length >= maxRecords) ? null : result.nextRecordsUrl || null
+ }
+
+ return allRecords
+}
+
+/**
+ * Retrieve information about the the Salesforce user
+ */
+export const useUserQuery = () => {
+ return useQuery({
+ queryKey: ["user"],
+ queryFn: () => {
+ return request({
+ path: "/services/oauth2/userinfo",
+ service: null,
+ })
+ },
+ })
+}
+
+/**
+ * Retrieve information about the Org
+ */
+export const useOrgQuery = (orgId: string) => {
+ return useQuery({
+ queryKey: ["org"],
+ enabled: !!orgId,
+ queryFn: () => {
+ return request({
+ path: `/sobjects/Organization/${orgId}`,
+ })
+ },
+ })
+}
+
+/**
+ * Retrieve all of the Org's trusted domains
+ */
+export const useTrustedSitesQuery = () => {
+ return useQuery({
+ queryKey: ["trustedSites"],
+ queryFn: () => {
+ return request({
+ path: `/tooling/query`,
+ query: {
+ q: "SELECT Id,EndpointUrl FROM CspTrustedSite",
+ },
+ })
+ },
+ })
+}
+
+/**
+ * Add a new trusted site to the Org - allowing Salesforce components
+ * to be embedded onto external sites.
+ */
+export const useTrustedSiteMutation = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ url, description }: { url: string; description: string }) => {
+ return request({
+ method: "post",
+ path: `/tooling/sobjects/CspTrustedSite`,
+ body: {
+ EndpointUrl: url,
+ Description: description,
+ Context: "All",
+ DeveloperName: url
+ .replace(/^https?:\/\//, "")
+ .replace(/[^a-zA-Z0-9]+/g, "_")
+ .replace(/^-+|-+$/g, "")
+ .toLowerCase(),
+ },
+ })
+ },
+ onSuccess: () => queryClient.refetchQueries({ queryKey: ["trustedSites"] }),
+ })
+}
+
+/**
+ * Retrieve all the domains from the Org's CORS whitelist
+ */
+export const useCorsWhitelistQuery = () => {
+ return useQuery({
+ queryKey: ["corsWhitelist"],
+ queryFn: () => {
+ return request({
+ path: "/query",
+ query: {
+ q: "SELECT Id, UrlPattern FROM CorsWhitelistEntry",
+ },
+ })
+ },
+ })
+}
+
+/**
+ * Add a new domain to the Org's CORS whitelist
+ */
+export const useCorsWhitelistMutation = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (urlPattern: string) => {
+ return request({
+ method: "post",
+ path: "/sobjects/CorsWhitelistEntry",
+ body: {
+ UrlPattern: urlPattern,
+ },
+ })
+ },
+ onSuccess: () => queryClient.refetchQueries({ queryKey: ["corsWhitelist"] }),
+ })
+}
+
+/**
+ * Remove a CORS whitelist entry from the Org
+ */
+export const useRemoveCorsWhitelistMutation = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (id: string) => {
+ return request({
+ method: "delete",
+ path: `/sobjects/CorsWhitelistEntry/${id}`,
+ })
+ },
+ onSuccess: () => queryClient.refetchQueries({ queryKey: ["corsWhitelist"] }),
+ })
+}
+
+/**
+ * Remove a trusted site entry from the Org
+ */
+export const useRemoveTrustedSiteMutation = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (id: string) => {
+ return request({
+ method: "delete",
+ path: `/tooling/sobjects/CspTrustedSite/${id}`,
+ })
+ },
+ onSuccess: () => queryClient.refetchQueries({ queryKey: ["trustedSites"] }),
+ })
+}
+
+/**
+ * Retrieve information about all of the Org's objects
+ */
+export const useObjectsQuery = () => {
+ return useQuery({
+ queryKey: ["objects"],
+ queryFn: () => {
+ return request({
+ path: "/sobjects",
+ })
+ },
+ })
+}
+
+/**
+ * Retrieve or generate a webhook endpoint to create/update a new
+ * Salesforce object
+ */
+export const useWebFormWebhookQuery = (objectName: string) => {
+ return useQuery({
+ queryKey: ["web-form-webhook", objectName],
+ enabled: !!objectName,
+ queryFn: async () => {
+ const tokens = auth.tokens.getOrThrow()
+ const res = await fetch(`${API_URL}/api/forms/web/create`, {
+ method: "POST",
+ body: JSON.stringify({ objectName }),
+ headers: {
+ Authorization: `Bearer ${tokens.accessToken}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ })
+
+ if (!res.ok) {
+ const data: FramerSalesforceAPIErrorResponse = await res.json()
+ throw new PluginError("Webhook Creation Failed", data.error.message)
+ }
+
+ return res.json()
+ },
+ })
+}
+
+/**
+ * Retrieve all messaging for web embedded services
+ */
+export const useMessagingEmbeddedServices = () => {
+ return useQuery({
+ queryKey: ["embedded-services"],
+ queryFn: () => {
+ // IsFieldServiceEnabled=True indicates 'Appointment Embed' - we ignore that as we
+ // only want 'Messaging for In-App and Web'
+ return request>({
+ path: "/query",
+ query: {
+ q: "SELECT Id,DurableId,Site FROM EmbeddedServiceDetail WHERE Site != null AND IsFieldServiceEnabled = False",
+ },
+ })
+ },
+ select: data => data.records,
+ })
+}
+
+/**
+ * Retrieve details about an Org's connected domain - connected means the domain
+ * is referenced in some way, whether it be listed in the Org's allow list, in a
+ * bot, etc...
+ */
+export const useSiteQuery = (siteId: string) => {
+ return useQuery({
+ queryKey: ["site", siteId],
+ enabled: !!siteId,
+ queryFn: () => {
+ return request({
+ path: `/sobjects/Site/${siteId}`,
+ })
+ },
+ })
+}
+
+/**
+ * Retrieve an object's configuration (fields, relations, etc...)
+ */
+export const useObjectConfigQuery = (objectName: string) => {
+ return useQuery({
+ queryKey: ["object-config", objectName],
+ enabled: !!objectName,
+ queryFn: () => fetchObjectConfig(objectName),
+ })
+}
+
+/**
+ * Retrieve ALL of an object's records
+ */
+export const useObjectRecordsQuery = (objectName: string, fields: string[]) => {
+ return useQuery({
+ queryKey: ["object-records", objectName, ...fields],
+ enabled: !!objectName,
+ queryFn: () => fetchObjectRecords(objectName, fields),
+ })
+}
+
+/**
+ * Retrieve all Account Engagement form handlers
+ */
+export const useAccountEngagementFormHandlers = () => {
+ return useQuery({
+ queryKey: ["ae-form-handlers"],
+ queryFn: () => {
+ return request>({
+ service: "mcae",
+ path: "/objects/form-handlers",
+ query: {
+ fields: "id,name,url,embedCode",
+ },
+ })
+ },
+ select: data => data.values,
+ })
+}
+
+/**
+ * Retrieve all Account Engagement forms
+ */
+export const useAccountEngagementForms = () => {
+ return useQuery({
+ queryKey: ["ae-forms"],
+ queryFn: () => {
+ return request>({
+ service: "mcae",
+ path: "/objects/forms",
+ query: {
+ fields: "id,name,url,embedCode",
+ },
+ })
+ },
+ select: data => data.values,
+ })
+}
diff --git a/plugins/salesforce/src/auth.ts b/plugins/salesforce/src/auth.ts
new file mode 100644
index 00000000..d151af76
--- /dev/null
+++ b/plugins/salesforce/src/auth.ts
@@ -0,0 +1,154 @@
+import { API_URL, BUSINESS_UNIT_ID_KEY } from "./constants"
+import { PluginError } from "./PluginError"
+
+export interface Tokens {
+ access_token: string
+ refresh_token: string
+ instance_url: string
+ id: string
+ issued_at: string
+ scope: string
+ token_type: "Bearer"
+ id_token?: string
+ signature?: string
+}
+
+export interface StoredTokens {
+ createdAt: number
+ accessToken: string
+ refreshToken: string
+ instanceUrl: string
+ id: string
+}
+
+export interface Authorize {
+ url: string
+ writeKey: string
+ readKey: string
+}
+
+const PLUGIN_TOKENS_KEY = "salesforceTokens"
+
+class Auth {
+ storedTokens?: StoredTokens | null
+
+ async refreshTokens() {
+ try {
+ const tokens = this.tokens.getOrThrow()
+
+ const res = await fetch(`${API_URL}/auth/refresh?code=${tokens.refreshToken}`, {
+ method: "POST",
+ })
+
+ if (res.status !== 200) {
+ throw new PluginError("Auth Error", "Failed to refresh tokens. Please sign in again.")
+ }
+
+ const json = await res.json()
+ const newTokens = json as Tokens
+
+ return this.tokens.save(newTokens)
+ } catch (e) {
+ this.tokens.clear()
+ throw e
+ }
+ }
+
+ async fetchTokens(readKey: string) {
+ const res = await fetch(`${API_URL}/auth/poll?readKey=${readKey}`, {
+ method: "POST",
+ })
+
+ if (!res.ok) {
+ throw new PluginError("Auth Error", "Failed to fetch tokens")
+ }
+
+ const tokens = (await res.json()) as Tokens
+
+ this.tokens.save(tokens)
+
+ return tokens
+ }
+
+ async authorize() {
+ const response = await fetch(`${API_URL}/auth/authorize`, {
+ method: "POST",
+ })
+
+ if (response.status !== 200) {
+ throw new PluginError("Auth Error", "Failed to generate OAuth URL.")
+ }
+
+ const authorize = (await response.json()) as Authorize
+
+ return authorize
+ }
+
+ async logout() {
+ const response = await fetch(`${API_URL}/auth/logout`, {
+ method: "POST",
+ })
+
+ if (response.status !== 200) {
+ throw new PluginError("Auth Error", "Failed to logout.")
+ }
+
+ this.tokens.clear()
+ localStorage.removeItem(BUSINESS_UNIT_ID_KEY)
+ }
+
+ getBusinessUnitId() {
+ const businessUnitId = localStorage.getItem(BUSINESS_UNIT_ID_KEY)
+ return businessUnitId
+ }
+
+ isAuthenticated() {
+ const tokens = this.tokens.get()
+ if (!tokens) return false
+
+ return true
+ }
+
+ public readonly tokens = {
+ save: (tokens: Tokens) => {
+ const storedTokens: StoredTokens = {
+ createdAt: parseInt(tokens.issued_at),
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token,
+ instanceUrl: tokens.instance_url,
+ id: tokens.id,
+ }
+
+ this.storedTokens = storedTokens
+ window.localStorage.setItem(PLUGIN_TOKENS_KEY, JSON.stringify(storedTokens))
+
+ return storedTokens
+ },
+ get: (): StoredTokens | null => {
+ if (this.storedTokens) return this.storedTokens
+
+ const serializedTokens = window.localStorage.getItem(PLUGIN_TOKENS_KEY)
+ if (!serializedTokens) return null
+
+ const storedTokens = JSON.parse(serializedTokens) as StoredTokens
+ this.storedTokens = storedTokens
+
+ return storedTokens
+ },
+ getOrThrow: (): StoredTokens => {
+ const tokens = this.tokens.get()
+ if (!tokens) {
+ this.tokens.clear()
+ throw new PluginError("Auth Error", "Salesforce API token missing from localstorage")
+ }
+
+ return tokens
+ },
+ clear: () => {
+ this.storedTokens = null
+ window.localStorage.removeItem(PLUGIN_TOKENS_KEY)
+ },
+ }
+}
+
+export default new Auth()
diff --git a/plugins/salesforce/src/cms.ts b/plugins/salesforce/src/cms.ts
new file mode 100644
index 00000000..df6ff0b1
--- /dev/null
+++ b/plugins/salesforce/src/cms.ts
@@ -0,0 +1,511 @@
+import { framer, ManagedCollection, ManagedCollectionField } from "framer-plugin"
+import { computeFieldSets, createFieldSetHash, isDefined, slugify } from "./utils"
+import { fetchObjectConfig, fetchObjectRecords, SFFieldConfig, SFRecord, SFRecordFieldValue } from "./api"
+import { logSyncResult } from "./debug"
+import { useMutation } from "@tanstack/react-query"
+
+interface ItemResult {
+ fieldName?: string
+ message: string
+}
+
+interface SyncStatus {
+ errors: ItemResult[]
+ warnings: ItemResult[]
+ info: ItemResult[]
+}
+
+const PLUGIN_OBJECT_ID_KEY = "salesforcePluginObjectId"
+const PLUGIN_OBJECT_LABEL_KEY = "salesforcePluginObjectLabel"
+const PLUGIN_INCLUDED_FIELD_HASH_KEY = "salesforcePluginIncludedFieldHash"
+const PLUGIN_LAST_SYNCED_KEY = "salesforcePluginLastSynced"
+const PLUGIN_SLUG_ID_KEY = "salesforcePluginSlugId"
+
+const EXCLUDED_FIELD_IDS = ["attributes", "Id"]
+const MAX_CMS_ITEMS = 10_000
+
+/**
+ * Get the value of a Salesforce object field in a format compatible with a collection field
+ */
+function getObjectFieldValue(fieldConfig: SFFieldConfig, fieldValue: SFRecordFieldValue): unknown | null {
+ switch (fieldConfig.type) {
+ case "textarea":
+ case "richtext":
+ case "id":
+ case "url":
+ case "picklist":
+ case "phone":
+ case "email":
+ case "base64":
+ return typeof fieldValue === "string" ? fieldValue : null
+
+ case "currency":
+ case "double":
+ case "int":
+ case "long":
+ return typeof fieldValue === "number" ? fieldValue : null
+
+ case "date":
+ case "datetime":
+ return typeof fieldValue === "string" ? new Date(fieldValue).toISOString() : fieldValue
+
+ case "reference":
+ // fieldValue is the Id of the foreign object
+ return typeof fieldValue === "string" ? fieldValue : null
+
+ default:
+ return fieldValue
+ }
+}
+
+/**
+ * Get the collection field schema for a Salesforce field
+ * Returns `null` for fields that are supported but can't be synced yet
+ * Returns `undefined` for fields that are not supported outright
+ */
+export function getCollectionFieldForSalesforceField(
+ fieldConfig: SFFieldConfig,
+ objectIdMap: ObjectIdMap
+): ManagedCollectionField | null | undefined {
+ const fieldMetadata = {
+ id: fieldConfig.name,
+ name: fieldConfig.label,
+ userEditable: false,
+ }
+
+ switch (fieldConfig.type) {
+ case "id":
+ case "string":
+ case "email":
+ case "phone":
+ return { ...fieldMetadata, type: "string" }
+
+ case "boolean":
+ return { ...fieldMetadata, type: "boolean" }
+
+ case "currency":
+ case "double":
+ case "int":
+ case "long":
+ return { ...fieldMetadata, type: "number" }
+
+ case "date":
+ case "datetime":
+ return { ...fieldMetadata, type: "date" }
+
+ case "picklist": {
+ return {
+ ...fieldMetadata,
+ type: "enum",
+ cases:
+ fieldConfig.picklistValues?.map(picklistValue => ({
+ id: picklistValue.value,
+ name: picklistValue.label,
+ })) || [],
+ }
+ }
+
+ case "url":
+ return { ...fieldMetadata, type: "link" }
+
+ case "textarea":
+ case "richtext":
+ return { ...fieldMetadata, type: "formattedText" }
+
+ case "base64":
+ return { ...fieldMetadata, type: "file", allowedFileTypes: [] }
+
+ case "reference": {
+ // Objects can relate to multiple fields as one field e.g.
+ // ["Lead", "Contact"]
+ let refCollectionId: string | null = null
+ for (const objectId of fieldConfig.referenceTo) {
+ const collectionId = objectIdMap.get(objectId)
+
+ // Relation does not exist, check the next possible
+ // reference
+ if (!collectionId) continue
+
+ refCollectionId = collectionId
+ }
+
+ // Object includes a relation to an object that hasn't been synced
+ // to Framer
+ if (!refCollectionId) {
+ return null
+ }
+
+ return {
+ ...fieldMetadata,
+ type: "collectionReference",
+ collectionId: refCollectionId,
+ }
+ }
+
+ default:
+ return undefined
+ }
+}
+
+export type ObjectIdMap = Map
+
+type FieldsById = Map
+
+interface ProcessAllRecordsParams {
+ fields: ManagedCollectionField[]
+ fieldConfigs: SFFieldConfig[]
+ fieldsById: FieldsById
+ slugFieldId: string
+ unsyncedItemIds: Set
+}
+
+interface ProcessRecordParams extends ProcessAllRecordsParams {
+ record: SFRecord
+ status: SyncStatus
+}
+
+export interface SyncProgress {
+ totalCount: number
+ completedCount: number
+ completedPercent: number
+}
+
+type OnProgressHandler = (progress: SyncProgress) => void
+
+function processRecord({
+ record,
+ fieldConfigs,
+ slugFieldId,
+ fieldsById,
+ status,
+ unsyncedItemIds,
+}: ProcessRecordParams) {
+ let slugValue: string | null = null
+
+ const fieldData: Record = {}
+
+ if (typeof record.Id !== "string") {
+ throw new Error("Expected record.id to be of type string")
+ }
+
+ unsyncedItemIds.delete(record.Id)
+
+ for (const [fieldId, fieldValue] of Object.entries(record)) {
+ const fieldConfig = fieldConfigs.find(config => config.name === fieldId)
+
+ if (!fieldConfig) continue
+
+ const collectionFieldValue = getObjectFieldValue(fieldConfig, fieldValue)
+
+ if (fieldId === slugFieldId) {
+ if (typeof fieldValue !== "string") continue
+
+ slugValue = slugify(fieldValue)
+ }
+
+ // These fields are included in the request regardless of the requested properties
+ // in the params
+ if (EXCLUDED_FIELD_IDS.includes(fieldId)) {
+ continue
+ }
+
+ const field = fieldsById.get(fieldId)
+
+ // Not included in the field mapping, skip
+ if (!field) continue
+
+ if (!fieldValue) {
+ status.warnings.push({
+ fieldName: fieldId,
+ message: `Value is missing for field ${field.name}`,
+ })
+ }
+
+ fieldData[fieldId] = collectionFieldValue
+ }
+
+ if (!slugValue) {
+ status.warnings.push({
+ message: "Slug missing. Skipping item.",
+ })
+
+ return null
+ }
+
+ return {
+ id: record.Id,
+ slug: slugValue,
+ fieldData,
+ }
+}
+
+function processAllRecords(
+ records: SFRecord[],
+ onProgress: OnProgressHandler,
+ processRecordParams: ProcessAllRecordsParams
+) {
+ const seenItemIds = new Set()
+ const status: SyncStatus = {
+ info: [],
+ warnings: [],
+ errors: [],
+ }
+
+ const totalCount = records.length
+ let completedCount = 0
+
+ onProgress({ totalCount, completedCount, completedPercent: 0 })
+
+ const collectionItems = records
+ .map(record => {
+ const result = processRecord({
+ ...processRecordParams,
+ record,
+ status,
+ })
+
+ completedCount++
+ onProgress({
+ totalCount,
+ completedCount,
+ completedPercent: Math.round((completedCount / totalCount) * 100),
+ })
+
+ return result
+ })
+ .filter(isDefined)
+
+ return {
+ collectionItems,
+ status,
+ seenItemIds,
+ }
+}
+
+interface SyncMutationOptions {
+ objectId: string
+ objectLabel: string
+ fields: ManagedCollectionField[]
+ includedFieldIds: string[]
+ slugFieldId: string
+ fieldConfigs: SFFieldConfig[]
+ onProgress: OnProgressHandler
+}
+
+export interface SyncResult extends SyncStatus {
+ status: "success" | "completed_with_errors"
+}
+
+export async function syncAllRecords({
+ objectId,
+ objectLabel,
+ fields,
+ includedFieldIds,
+ slugFieldId,
+ fieldConfigs,
+ onProgress,
+}: SyncMutationOptions): Promise {
+ const collection = await framer.getManagedCollection()
+ await collection.setFields(fields)
+
+ const fieldsById = new Map(fields.map(field => [field.id, field]))
+ const unsyncedItemIds = new Set(await collection.getItemIds())
+ // Always include the slug field and Id
+ const records = await fetchObjectRecords(
+ objectId,
+ Array.from(new Set([...includedFieldIds, slugFieldId, "Id"])),
+ MAX_CMS_ITEMS
+ )
+ const { collectionItems, status } = processAllRecords(records, onProgress, {
+ fields,
+ fieldConfigs,
+ fieldsById,
+ slugFieldId,
+ unsyncedItemIds,
+ })
+
+ await collection.addItems(collectionItems)
+
+ const itemsToDelete = Array.from(unsyncedItemIds)
+ await collection.removeItems(itemsToDelete)
+
+ await Promise.all([
+ collection.setPluginData(PLUGIN_INCLUDED_FIELD_HASH_KEY, createFieldSetHash(includedFieldIds)),
+ collection.setPluginData(PLUGIN_OBJECT_ID_KEY, objectId),
+ collection.setPluginData(PLUGIN_OBJECT_LABEL_KEY, objectLabel),
+ collection.setPluginData(PLUGIN_SLUG_ID_KEY, slugFieldId),
+ collection.setPluginData(PLUGIN_LAST_SYNCED_KEY, new Date().toISOString()),
+ ])
+
+ const result: SyncResult = {
+ status: status.errors.length === 0 ? "success" : "completed_with_errors",
+ errors: status.errors,
+ info: status.info,
+ warnings: status.warnings,
+ }
+
+ logSyncResult(result, collectionItems)
+
+ return result
+}
+
+export async function getObjectIdMap(): Promise {
+ const objectIdMap: ObjectIdMap = new Map()
+
+ for (const collection of await framer.getCollections()) {
+ const collectionTableId = await collection.getPluginData(PLUGIN_OBJECT_ID_KEY)
+ if (!collectionTableId) continue
+
+ objectIdMap.set(collectionTableId, collection.id)
+ }
+
+ return objectIdMap
+}
+
+/*
+ * Given a set of Salesforce object field configs, returns a list of possible
+ * fields that can be used as slugs.
+ */
+export function getPossibleSlugFields(fieldConfigs: SFFieldConfig[]) {
+ const options: SFFieldConfig[] = []
+
+ for (const fieldConfig of fieldConfigs) {
+ switch (fieldConfig.type) {
+ case "string":
+ options.push(fieldConfig)
+ }
+ }
+
+ return options
+}
+
+/**
+ * Determines whether the field configuration of the currently managed collection
+ * fields differ from the Salesforce object field configuration
+ */
+function hasFieldConfigurationChanged(
+ currentManagedCollectionFields: ManagedCollectionField[],
+ fields: SFFieldConfig[],
+ includedFieldIds: string[],
+ objectIdMap: ObjectIdMap
+): boolean {
+ const currentFieldsById = new Map(currentManagedCollectionFields.map(field => [field.id, field]))
+
+ // Consider currently included fields only
+ const includedObjectFields = fields.filter(field => includedFieldIds.includes(field.name))
+
+ if (includedObjectFields.length !== currentManagedCollectionFields.length) {
+ return true
+ }
+
+ for (const objectField of includedObjectFields) {
+ const collectionField = currentFieldsById.get(objectField.name)
+ const expectedField = getCollectionFieldForSalesforceField(objectField, objectIdMap)
+
+ if (!collectionField) {
+ return true
+ }
+
+ if (!expectedField || collectionField.type !== expectedField.type) {
+ return true
+ }
+ }
+
+ return false
+}
+
+export function shouldSyncImmediately(pluginContext: PluginContext): pluginContext is PluginContextUpdate {
+ if (pluginContext.type !== "update") return false
+
+ if (!pluginContext.slugFieldId) return false
+ if (pluginContext.hasChangedFields) return false
+
+ return true
+}
+
+export interface PluginContextNew {
+ type: "new"
+ collection: ManagedCollection
+ objectIdMap: ObjectIdMap
+}
+
+export interface PluginContextUpdate {
+ type: "update"
+ objectId: string
+ objectLabel: string
+ fieldConfigs: SFFieldConfig[]
+ collection: ManagedCollection
+ collectionFields: ManagedCollectionField[]
+ hasChangedFields: boolean
+ includedFieldIds: string[]
+ slugFieldId: string | null
+ objectIdMap: ObjectIdMap
+}
+
+export type PluginContext = PluginContextNew | PluginContextUpdate
+
+export async function getPluginContext(): Promise {
+ const collection = await framer.getManagedCollection()
+ const collectionFields = await collection.getFields()
+
+ const [objectId, objectLabel, slugFieldId, rawIncludedFieldsHash] = await Promise.all([
+ collection.getPluginData(PLUGIN_OBJECT_ID_KEY),
+ collection.getPluginData(PLUGIN_OBJECT_LABEL_KEY),
+ collection.getPluginData(PLUGIN_SLUG_ID_KEY),
+ collection.getPluginData(PLUGIN_INCLUDED_FIELD_HASH_KEY),
+ ])
+
+ const objectIdMap = await getObjectIdMap()
+
+ if (!objectId || !objectLabel || !rawIncludedFieldsHash) {
+ return {
+ type: "new",
+ collection,
+ objectIdMap,
+ }
+ }
+
+ const { fields } = await fetchObjectConfig(objectId)
+
+ const { includedFieldIds, hasHashChanged } = computeFieldSets({
+ currentFields: collectionFields,
+ allPossibleFieldIds: fields.map(field => field.name).filter(isDefined),
+ storedHash: rawIncludedFieldsHash,
+ })
+
+ let hasChangedFields: boolean
+ // Skip doing full check since we already know it differs
+ if (hasHashChanged) {
+ hasChangedFields = true
+ } else {
+ // Do full check
+ hasChangedFields = hasFieldConfigurationChanged(collectionFields, fields, includedFieldIds, objectIdMap)
+ }
+
+ return {
+ type: "update",
+ hasChangedFields,
+ objectId,
+ objectLabel,
+ fieldConfigs: fields,
+ collection,
+ collectionFields,
+ includedFieldIds,
+ slugFieldId,
+ objectIdMap,
+ }
+}
+
+export const useSyncRecordsMutation = ({
+ onSuccess,
+ onError,
+}: {
+ onSuccess?: (result: SyncResult) => void
+ onError?: (e: Error) => void
+}) => {
+ return useMutation({
+ mutationFn: (args: SyncMutationOptions) => syncAllRecords(args),
+ onSuccess,
+ onError,
+ })
+}
diff --git a/plugins/salesforce/src/components/Button.tsx b/plugins/salesforce/src/components/Button.tsx
new file mode 100644
index 00000000..313ef979
--- /dev/null
+++ b/plugins/salesforce/src/components/Button.tsx
@@ -0,0 +1,13 @@
+import cx from "classnames"
+import { Spinner } from "./Spinner"
+
+interface Props extends React.ButtonHTMLAttributes {
+ variant?: "primary" | "secondary" | "destructive"
+ isLoading?: boolean
+}
+
+export const Button = ({ variant = "primary", children, className, isLoading = false, disabled, ...rest }: Props) => (
+
+ {isLoading ? : children}
+
+)
diff --git a/plugins/salesforce/src/components/CenteredSpinner.tsx b/plugins/salesforce/src/components/CenteredSpinner.tsx
new file mode 100644
index 00000000..73dc91bf
--- /dev/null
+++ b/plugins/salesforce/src/components/CenteredSpinner.tsx
@@ -0,0 +1,8 @@
+import { Spinner, SpinnerProps } from "./Spinner"
+import classNames from "classnames"
+
+export const CenteredSpinner = ({ className }: SpinnerProps) => (
+
+
+
+)
diff --git a/plugins/salesforce/src/components/CheckboxTextField.tsx b/plugins/salesforce/src/components/CheckboxTextField.tsx
new file mode 100644
index 00000000..0d79b892
--- /dev/null
+++ b/plugins/salesforce/src/components/CheckboxTextField.tsx
@@ -0,0 +1,45 @@
+import { useState } from "react"
+import cx from "classnames"
+
+interface Props {
+ value: string
+ disabled: boolean
+ checked: boolean
+ onChange: () => void
+}
+
+export function CheckboxTextfield({ value, disabled, checked: initialChecked, onChange }: Props) {
+ const [checked, setChecked] = useState(initialChecked)
+
+ const toggle = () => {
+ if (disabled) return
+
+ setChecked(!checked)
+ onChange()
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/components/CopyInput.tsx b/plugins/salesforce/src/components/CopyInput.tsx
new file mode 100644
index 00000000..b5681cbe
--- /dev/null
+++ b/plugins/salesforce/src/components/CopyInput.tsx
@@ -0,0 +1,33 @@
+import { useState } from "react"
+import { CopyIcon, TickIcon } from "./Icons"
+import { Spinner } from "./Spinner"
+import { framer } from "framer-plugin"
+
+interface Props {
+ value: string
+ isLoading?: boolean
+ message?: string
+}
+
+export const CopyInput = ({ value, isLoading, message }: Props) => {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+
+ if (message) {
+ framer.notify(message)
+ }
+ }
+
+ return (
+
+
+
+ {isLoading ? : copied ? : }
+
+
+ )
+}
diff --git a/plugins/salesforce/src/components/FieldMapper.tsx b/plugins/salesforce/src/components/FieldMapper.tsx
new file mode 100644
index 00000000..23f19c53
--- /dev/null
+++ b/plugins/salesforce/src/components/FieldMapper.tsx
@@ -0,0 +1,122 @@
+import { CheckboxTextfield } from "@/components/CheckboxTextField"
+import { IconChevron } from "@/components/Icons"
+import { ScrollFadeContainer } from "@/components/ScrollFadeContainer"
+import { assert } from "@/utils"
+import { ManagedCollectionField } from "framer-plugin"
+import { Fragment, useMemo } from "react"
+import cx from "classnames"
+
+export interface ManagedCollectionFieldConfig {
+ field: ManagedCollectionField | null | undefined
+ originalFieldName: string
+}
+
+interface FieldMapperProps {
+ collectionFieldConfig: ManagedCollectionFieldConfig[]
+ fieldNameOverrides: Record
+ isFieldSelected: (fieldId: string) => boolean
+ onFieldToggle: (fieldId: string) => void
+ onFieldNameChange: (fieldId: string, value: string) => void
+ fromLabel?: string
+ toLabel?: string
+ className?: string
+ height?: number
+}
+
+const getInitialSortedFields = (
+ fields: ManagedCollectionFieldConfig[],
+ isFieldSelected: (fieldId: string) => boolean
+): ManagedCollectionFieldConfig[] => {
+ return [...fields].sort((a, b) => {
+ const aIsSelected = a.field && isFieldSelected(a.field.id)
+ const bIsSelected = b.field && isFieldSelected(b.field.id)
+
+ // Sort based on whether the fields are selected
+ if (aIsSelected && !bIsSelected) return -1
+ if (!aIsSelected && bIsSelected) return 1
+
+ // Sort by whether they are supported fields
+ if (a.field !== null && a.field !== undefined && (b.field === null || b.field === undefined)) return -1
+ if ((a.field === null || a.field === undefined) && b.field !== null && b.field !== undefined) return 1
+
+ // Sort by whether they are null (missing reference)
+ if (a.field === null && b.field !== null) return -1
+ if (a.field !== null && b.field === null) return 1
+
+ // Sort by whether they are undefined (unsupported fields)
+ if (a.field === undefined && b.field !== undefined) return 1
+ if (a.field !== undefined && b.field === undefined) return -1
+
+ return 0
+ })
+}
+
+export const FieldMapper = ({
+ collectionFieldConfig,
+ fieldNameOverrides,
+ isFieldSelected,
+ onFieldToggle,
+ onFieldNameChange,
+ fromLabel = "Column",
+ toLabel = "Field",
+ height,
+ className,
+}: FieldMapperProps) => {
+ // We only want to sort on initial render
+ const sortedCollectionFieldConfig = useMemo(
+ () => getInitialSortedFields(collectionFieldConfig, isFieldSelected),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [collectionFieldConfig]
+ )
+
+ return (
+
+
+
{fromLabel}
+
{toLabel}
+ {sortedCollectionFieldConfig.map((fieldConfig, i) => {
+ const isUnsupported = !fieldConfig.field
+ const isSelected = fieldConfig.field ? isFieldSelected(fieldConfig.field.id) : false
+
+ return (
+
+ {
+ assert(fieldConfig.field)
+ onFieldToggle(fieldConfig.field.id)
+ }}
+ />
+
+
+
+ {
+ assert(fieldConfig.field)
+ onFieldNameChange(fieldConfig.field.id, e.target.value)
+ }}
+ />
+
+ )
+ })}
+
+
+ )
+}
diff --git a/plugins/salesforce/src/components/Icons.tsx b/plugins/salesforce/src/components/Icons.tsx
new file mode 100644
index 00000000..19e8fe6b
--- /dev/null
+++ b/plugins/salesforce/src/components/Icons.tsx
@@ -0,0 +1,110 @@
+import classNames from "classnames"
+
+export const CaretLeftIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const GlobeIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const FormsIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const MessagesIcon = ({ className }: { className?: string }) => (
+
+
+
+
+)
+
+export const ChartsIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const PersonIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const SyncIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const CopyIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const TickIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const PlusIcon = ({ className }: { className?: string }) => (
+
+
+
+
+)
+
+export const HomeIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const SearchIcon = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const IconChevron = ({ className }: { className?: string }) => (
+
+
+
+)
+
+export const InfoIcon = ({ className }: { className?: string }) => (
+
+
+
+)
diff --git a/plugins/salesforce/src/components/Layout.tsx b/plugins/salesforce/src/components/Layout.tsx
new file mode 100644
index 00000000..830b0cb7
--- /dev/null
+++ b/plugins/salesforce/src/components/Layout.tsx
@@ -0,0 +1,58 @@
+import React from "react"
+import { motion } from "framer-motion"
+import cx from "classnames"
+import { CaretLeftIcon } from "./Icons"
+
+const PageDivider = () => (
+
+
+
+)
+
+interface TitleProps {
+ title: string
+ animateForward?: boolean
+}
+
+const Title = ({ title, animateForward }: TitleProps) => (
+
+
+
+
+)
+
+interface Props {
+ children: React.ReactNode
+ className?: string
+ title?: string
+ animateForward?: boolean
+}
+
+export const Layout = ({ children, className, title, animateForward }: Props) => (
+
+ {title &&
}
+
+
{children}
+
+)
diff --git a/plugins/salesforce/src/components/Link.tsx b/plugins/salesforce/src/components/Link.tsx
new file mode 100644
index 00000000..cdecbcb4
--- /dev/null
+++ b/plugins/salesforce/src/components/Link.tsx
@@ -0,0 +1,31 @@
+import { Link, useLocation } from "wouter"
+import classNames from "classnames"
+
+interface LinkProps {
+ href: string
+ children?: React.ReactNode
+ className?: string
+ // eslint-disable-next-line
+ state?: any
+}
+
+export const InternalLink = ({ href, children, className, state }: LinkProps) => {
+ const [_, navigate] = useLocation()
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault()
+ navigate(href, { state })
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const ExternalLink = ({ href, children, className }: LinkProps) => (
+
+ {children}
+
+)
diff --git a/plugins/salesforce/src/components/Logo.tsx b/plugins/salesforce/src/components/Logo.tsx
new file mode 100644
index 00000000..84bd42e2
--- /dev/null
+++ b/plugins/salesforce/src/components/Logo.tsx
@@ -0,0 +1 @@
+export const Logo = () =>
diff --git a/plugins/salesforce/src/components/Message.tsx b/plugins/salesforce/src/components/Message.tsx
new file mode 100644
index 00000000..568be1bf
--- /dev/null
+++ b/plugins/salesforce/src/components/Message.tsx
@@ -0,0 +1,16 @@
+import cx from "classnames"
+
+interface Props {
+ title: string
+ children: React.ReactNode
+ className?: string
+}
+
+export const Message = ({ title, className, children }: Props) => (
+
+
{title}
+
+ {children}
+
+
+)
diff --git a/plugins/salesforce/src/components/PageErrorBoundaryFallback.tsx b/plugins/salesforce/src/components/PageErrorBoundaryFallback.tsx
new file mode 100644
index 00000000..6e81f2b3
--- /dev/null
+++ b/plugins/salesforce/src/components/PageErrorBoundaryFallback.tsx
@@ -0,0 +1,50 @@
+import { PropsWithChildren } from "react"
+import { framer } from "framer-plugin"
+import { ErrorBoundary } from "react-error-boundary"
+import { QueryErrorResetBoundary } from "@tanstack/react-query"
+import { PluginError } from "../PluginError"
+import auth from "@/auth"
+import { Message } from "./Message"
+
+export const PageErrorBoundaryFallback = ({ children }: PropsWithChildren) => (
+
+ {({ reset }) => (
+ {
+ return (
+
+ )
+ }}
+ >
+ {children}
+
+ )}
+
+)
diff --git a/plugins/salesforce/src/components/ScrollFadeContainer.tsx b/plugins/salesforce/src/components/ScrollFadeContainer.tsx
new file mode 100644
index 00000000..03f33d8f
--- /dev/null
+++ b/plugins/salesforce/src/components/ScrollFadeContainer.tsx
@@ -0,0 +1,84 @@
+import React, { useEffect, useRef, useState, useCallback } from "react"
+
+interface Props {
+ children: React.ReactNode
+ className?: string
+ fadeHeight?: number
+ height: number
+}
+
+export const ScrollFadeContainer = ({ children, className = "", fadeHeight = 45, height }: Props) => {
+ const [showTopFade, setShowTopFade] = useState(false)
+ const [showBottomFade, setShowBottomFade] = useState(false)
+ const containerRef = useRef(null)
+ const scrollTimeout = useRef()
+
+ // Debounced scroll handler to prevent excessive calculations
+ const checkScroll = useCallback(() => {
+ const element = containerRef.current
+ if (!element) return
+
+ if (scrollTimeout.current) {
+ clearTimeout(scrollTimeout.current)
+ }
+
+ scrollTimeout.current = setTimeout(() => {
+ requestAnimationFrame(() => {
+ if (!element) return
+ const { scrollTop, scrollHeight, clientHeight } = element
+ const scrollBottom = scrollHeight - scrollTop - clientHeight
+
+ setShowTopFade(scrollTop > 10)
+ setShowBottomFade(scrollBottom > 10)
+ })
+ }, 50)
+ }, [])
+
+ useEffect(() => {
+ const element = containerRef.current
+ if (!element) return
+
+ // Initial check using requestAnimationFrame
+ requestAnimationFrame(() => {
+ if (!element) return
+ const { scrollHeight, clientHeight } = element
+ setShowBottomFade(scrollHeight > clientHeight)
+ checkScroll()
+ })
+
+ element.addEventListener("scroll", checkScroll, { passive: true })
+
+ return () => {
+ if (scrollTimeout.current) {
+ clearTimeout(scrollTimeout.current)
+ }
+
+ // Use the captured element reference in cleanup
+ element.removeEventListener("scroll", checkScroll)
+ }
+ }, [checkScroll])
+
+ return (
+
+ )
+}
diff --git a/plugins/salesforce/src/components/Spinner.tsx b/plugins/salesforce/src/components/Spinner.tsx
new file mode 100644
index 00000000..594ad9d9
--- /dev/null
+++ b/plugins/salesforce/src/components/Spinner.tsx
@@ -0,0 +1,40 @@
+import cx from "classnames"
+import styles from "./spinner.module.css"
+
+export interface SpinnerProps {
+ /** Size of the spinner */
+ size?: "normal" | "medium" | "large"
+ /** Set the spinner to have a static position inline with other content */
+ inline?: boolean
+ className?: string
+ inheritColor?: boolean
+}
+
+function styleForSize(size: SpinnerProps["size"]) {
+ switch (size) {
+ case "normal":
+ return styles.normalStyle
+ case "medium":
+ return styles.mediumStyle
+ case "large":
+ return styles.largeStyle
+ }
+}
+
+function spinnerClassNames(size: SpinnerProps["size"] = "normal") {
+ return cx(styles.spin, styles.baseStyle, styleForSize(size))
+}
+
+export const Spinner = ({ size, inline = false, inheritColor, className, ...rest }: SpinnerProps) => {
+ return (
+
+ )
+}
diff --git a/plugins/salesforce/src/components/spinner.module.css b/plugins/salesforce/src/components/spinner.module.css
new file mode 100644
index 00000000..a0d1a7ac
--- /dev/null
+++ b/plugins/salesforce/src/components/spinner.module.css
@@ -0,0 +1,60 @@
+.baseStyle {
+ --spinner-translate: 0;
+ background-color: #fff;
+}
+
+.buttonWithDepthSpinner {
+ background-color: currentColor;
+}
+
+.normalStyle {
+ width: 12px;
+ height: 12px;
+ -webkit-mask: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAApNJREFUSA2tlUtLlFEYgEe7mtFFQ8NLFBG1SJAwahtiLVy5ceVSEPQH1LZf0Lp9FO1bdYNyI7gyBFcS2kAzilYq3sfxeYZ55Zv6FGfohWfOd97vnPd23u9MXeZoaeb1Q7gPHXAFzsAS5GACxiEPqVKXqs1kmtAPQi+cAtedToz1PJ8ExxPwDl7CAlRImoN7rBiF86ABHTiGAzMIh8n3O+ifwWc4EL0n5TGTYQijvjNSxbUadozInUeGZ3nuh1WYgpIkM+hGMwYajAgdrfUn+AaLoLTCAxiAOxCZxL4RdB+gZMjxEjwHo4hS8Jh5DR9hz0mKaNizegoNEI7Wee6FXJTISG6AUgDr+QImoAiHie+mwZJow/PRps4uwnsnRj8Epqc4voVJJ8eULOtW4BFYYjPpgjc+3IWI2sh/wFeoVl6xYQ48dDGLfjOwc/ygQqz595hUMVouS9QHBm4l6k3HWpnBLqicgVrlCxujSbR1Swd+UDpQYRS/oVaZZ2MWwlZBB9Zdxf+QTYw8SRgq6sAvz7vH6JULEB9USVHFjzZskgPRQR4ayxoXXIdaHdg9HrB2rErByWxZERnYtrWUzD0esDbtTmXPhzXwHrKLxEUbYGbVyDkWWwkdadfqbPmwDXZSCyh2VAf8BM/nOOJH5Y0QopMt2I5U/KO4DZZJB47XwEX+ex0mGroMV8sLwp56gyuGwiyW4SZ4c8bt2cZzO7jBNeqtsRHbeZ1g9krsMbhfYPv/c5gecA+4yAVuitFszE5Hjva872OMdXagF19JIoOYWyozMTI3J0tmA6gLvXPfO1cc/XOqOLe/HbjwD8yCXRD3VNJROEgatiRZMJsKsbZHia3nAdphtq8fkiWyXJbBe8uM1aXKPouNnz2Bm1kwAAAAAElFTkSuQmCC");
+ mask: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAApNJREFUSA2tlUtLlFEYgEe7mtFFQ8NLFBG1SJAwahtiLVy5ceVSEPQH1LZf0Lp9FO1bdYNyI7gyBFcS2kAzilYq3sfxeYZ55Zv6FGfohWfOd97vnPd23u9MXeZoaeb1Q7gPHXAFzsAS5GACxiEPqVKXqs1kmtAPQi+cAtedToz1PJ8ExxPwDl7CAlRImoN7rBiF86ABHTiGAzMIh8n3O+ifwWc4EL0n5TGTYQijvjNSxbUadozInUeGZ3nuh1WYgpIkM+hGMwYajAgdrfUn+AaLoLTCAxiAOxCZxL4RdB+gZMjxEjwHo4hS8Jh5DR9hz0mKaNizegoNEI7Wee6FXJTISG6AUgDr+QImoAiHie+mwZJow/PRps4uwnsnRj8Epqc4voVJJ8eULOtW4BFYYjPpgjc+3IWI2sh/wFeoVl6xYQ48dDGLfjOwc/ygQqz595hUMVouS9QHBm4l6k3HWpnBLqicgVrlCxujSbR1Swd+UDpQYRS/oVaZZ2MWwlZBB9Zdxf+QTYw8SRgq6sAvz7vH6JULEB9USVHFjzZskgPRQR4ayxoXXIdaHdg9HrB2rErByWxZERnYtrWUzD0esDbtTmXPhzXwHrKLxEUbYGbVyDkWWwkdadfqbPmwDXZSCyh2VAf8BM/nOOJH5Y0QopMt2I5U/KO4DZZJB47XwEX+ex0mGroMV8sLwp56gyuGwiyW4SZ4c8bt2cZzO7jBNeqtsRHbeZ1g9krsMbhfYPv/c5gecA+4yAVuitFszE5Hjva872OMdXagF19JIoOYWyozMTI3J0tmA6gLvXPfO1cc/XOqOLe/HbjwD8yCXRD3VNJROEgatiRZMJsKsbZHia3nAdphtq8fkiWyXJbBe8uM1aXKPouNnz2Bm1kwAAAAAElFTkSuQmCC");
+ -webkit-mask-size: 12px;
+ mask-size: 12px;
+}
+
+.mediumStyle {
+ width: 24px;
+ height: 24px;
+ -webkit-mask: url("data:image/png;base64,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");
+ mask: url("data:image/png;base64,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");
+ -webkit-mask-size: 24px;
+ mask-size: 24px;
+}
+
+.largeStyle {
+ width: 30px;
+ height: 30px;
+ -webkit-mask: url("data:image/png;base64,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");
+ mask: url("data:image/png;base64,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");
+ -webkit-mask-size: 30px;
+ mask-size: 30px;
+}
+
+.centeredStyle {
+ --spinner-translate: -50%;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(var(--spinner-translate), var(--spinner-translate));
+}
+
+.spin {
+ animation-duration: 800ms;
+ animation-iteration-count: infinite;
+ animation-name: spin;
+ animation-timing-function: linear;
+}
+
+@keyframes spin {
+ 0% {
+ transform: translate(var(--spinner-translate), var(--spinner-translate)) rotate(0deg);
+ }
+
+ 100% {
+ transform: translate(var(--spinner-translate), var(--spinner-translate)) rotate(360deg);
+ }
+}
diff --git a/plugins/salesforce/src/constants.ts b/plugins/salesforce/src/constants.ts
new file mode 100644
index 00000000..2ea94af5
--- /dev/null
+++ b/plugins/salesforce/src/constants.ts
@@ -0,0 +1,7 @@
+const isLocal = () => window.location.hostname.includes("localhost")
+
+export const API_URL = isLocal()
+ ? "https://framer-salesforce-api.sakibulislam25800.workers.dev"
+ : "https://localhost:8787"
+
+export const BUSINESS_UNIT_ID_KEY = "businessUnitId"
diff --git a/plugins/salesforce/src/debug.ts b/plugins/salesforce/src/debug.ts
new file mode 100644
index 00000000..1125f689
--- /dev/null
+++ b/plugins/salesforce/src/debug.ts
@@ -0,0 +1,28 @@
+import { SyncResult } from "./cms"
+
+export const PLUGIN_LOG_SYNC_KEY = "salesforceLogSyncResult"
+
+const isLoggingEnabled = () => {
+ return localStorage.getItem(PLUGIN_LOG_SYNC_KEY) === "true"
+}
+
+export function logSyncResult(result: SyncResult, collectionItems?: Record[]) {
+ if (!isLoggingEnabled()) return
+
+ if (collectionItems) {
+ console.table(collectionItems)
+ }
+
+ if (result.errors.length > 0) {
+ console.log("Completed errors:")
+ console.table(result.errors)
+ }
+
+ if (result.warnings.length > 0) {
+ console.log("Completed warnings:")
+ console.table(result.warnings)
+ }
+
+ console.log("Completed info:")
+ console.table(result.info)
+}
diff --git a/plugins/salesforce/src/globals.css b/plugins/salesforce/src/globals.css
new file mode 100644
index 00000000..6c1cb4a2
--- /dev/null
+++ b/plugins/salesforce/src/globals.css
@@ -0,0 +1,94 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ h6 {
+ @apply font-semibold text-primary leading-[1.2];
+ }
+}
+
+@layer components {
+ .row {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ }
+
+ .col {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .row-lg {
+ display: flex;
+ flex-direction: row;
+ gap: 15px;
+ }
+
+ .col-lg {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .segment-control-shadow {
+ box-shadow:
+ 0px 2px 4px 0px rgba(0, 0, 0, 0.1),
+ 0px 1px 0px 0px rgba(0, 0, 0, 0.05);
+ }
+
+ .framer-button-destructive {
+ @apply bg-framer-red text-white;
+ }
+
+ .framer-button-destructive:hover,
+ .framer-button-destructive:focus {
+ @apply bg-framer-red-dimmed;
+ }
+
+ .tile {
+ @apply bg-tertiaryDimmedLight dark:bg-tertiaryDimmedDark dark:hover:bg-tertiary hover:bg-tertiary;
+ }
+
+ .tile:disabled {
+ @apply bg-tertiaryDimmedLight dark:bg-tertiaryDimmedDark;
+ }
+
+ .panel-row {
+ @apply row items-center justify-between pl-[15px] h-[30px] text-tertiary;
+ }
+
+ .panel-row > p {
+ @apply max-w-[134px] truncate text-primary;
+ }
+
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Hide scrollbar for IE, Edge and Firefox */
+ .no-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+ }
+}
+
+body,
+html,
+#root {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+main {
+ padding: 15px;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
diff --git a/plugins/salesforce/src/hooks/useCustomCode.ts b/plugins/salesforce/src/hooks/useCustomCode.ts
new file mode 100644
index 00000000..5816e6d8
--- /dev/null
+++ b/plugins/salesforce/src/hooks/useCustomCode.ts
@@ -0,0 +1,10 @@
+import { CustomCode, framer } from "framer-plugin"
+import { useState, useEffect } from "react"
+
+export const useCustomCode = () => {
+ const [customCode, setCustomCode] = useState(null)
+
+ useEffect(() => framer.subscribeToCustomCode(setCustomCode), [])
+
+ return customCode
+}
diff --git a/plugins/salesforce/src/hooks/usePublishInfo.ts b/plugins/salesforce/src/hooks/usePublishInfo.ts
new file mode 100644
index 00000000..964e2559
--- /dev/null
+++ b/plugins/salesforce/src/hooks/usePublishInfo.ts
@@ -0,0 +1,10 @@
+import { PublishInfo, framer } from "framer-plugin"
+import { useState, useEffect } from "react"
+
+export const usePublishInfo = () => {
+ const [publishInfo, setPublishInfo] = useState()
+
+ useEffect(() => framer.subscribeToPublishInfo(setPublishInfo), [])
+
+ return publishInfo
+}
diff --git a/plugins/salesforce/src/hooks/useSearchParams.ts b/plugins/salesforce/src/hooks/useSearchParams.ts
new file mode 100644
index 00000000..f8e47940
--- /dev/null
+++ b/plugins/salesforce/src/hooks/useSearchParams.ts
@@ -0,0 +1,8 @@
+import { useSearch } from "wouter"
+
+export const useSearchParams = () => {
+ const searchString = useSearch()
+ const searchParams = new URLSearchParams(searchString)
+
+ return searchParams
+}
diff --git a/plugins/salesforce/src/main.tsx b/plugins/salesforce/src/main.tsx
new file mode 100644
index 00000000..87500905
--- /dev/null
+++ b/plugins/salesforce/src/main.tsx
@@ -0,0 +1,29 @@
+import "./globals.css"
+import "framer-plugin/framer.css"
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import React from "react"
+import ReactDOM from "react-dom/client"
+import { App } from "./App.tsx"
+
+const root = document.getElementById("root")
+if (!root) throw new Error("Root element not found")
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ staleTime: 1000 * 60 * 5,
+ refetchOnWindowFocus: false,
+ throwOnError: true,
+ },
+ },
+})
+
+ReactDOM.createRoot(root).render(
+
+
+
+
+
+)
diff --git a/plugins/salesforce/src/pages/Auth.tsx b/plugins/salesforce/src/pages/Auth.tsx
new file mode 100644
index 00000000..a6f2b209
--- /dev/null
+++ b/plugins/salesforce/src/pages/Auth.tsx
@@ -0,0 +1,68 @@
+import { useRef, useState } from "react"
+import { framer } from "framer-plugin"
+import auth from "@/auth"
+import { Button } from "@/components/Button"
+import { Logo } from "@/components/Logo"
+import { useLocation } from "wouter"
+
+export default function Auth() {
+ const pollInterval = useRef>()
+ const [, navigate] = useLocation()
+ const [isLoading, setIsLoading] = useState(false)
+ const [message, setMessage] = useState("Sign in to Salesforce to access forms, enable tracking, and more.")
+
+ const pollForTokens = (readKey: string) => {
+ if (pollInterval.current) {
+ clearInterval(pollInterval.current)
+ }
+
+ return new Promise(
+ resolve =>
+ (pollInterval.current = setInterval(
+ () =>
+ auth.fetchTokens(readKey).then(tokens => {
+ clearInterval(pollInterval.current)
+ resolve(tokens)
+ }),
+ 1500
+ ))
+ )
+ }
+
+ const login = async () => {
+ setIsLoading(true)
+
+ try {
+ setMessage("Complete the authentication and return to Framer.")
+
+ const authorize = await auth.authorize()
+
+ // Open Salesforce authorization window
+ window.open(authorize.url)
+
+ // Fetch tokens
+ await pollForTokens(authorize.readKey)
+
+ navigate("/business-unit-id")
+ } catch (e) {
+ framer.notify(e instanceof Error ? e.message : "An unknown error occurred.", { variant: "error" })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
Connect to Salesforce
+
{message}
+
+
+
+ Log In
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/BusinessUnitId.tsx b/plugins/salesforce/src/pages/BusinessUnitId.tsx
new file mode 100644
index 00000000..2d8f355d
--- /dev/null
+++ b/plugins/salesforce/src/pages/BusinessUnitId.tsx
@@ -0,0 +1,82 @@
+import { request } from "@/api"
+import { Button } from "@/components/Button"
+import { ExternalLink } from "@/components/Link"
+import { BUSINESS_UNIT_ID_KEY } from "@/constants"
+import { framer } from "framer-plugin"
+import { useState } from "react"
+import { useLocation } from "wouter"
+
+export default function BusinessUnitId() {
+ const [, navigate] = useLocation()
+ const [isLoading, setIsLoading] = useState(false)
+ const [unitId, setUnitId] = useState("")
+
+ const handleSkip = () => {
+ // Empty string so we know we have at least been through this stage
+ localStorage.setItem(BUSINESS_UNIT_ID_KEY, "")
+ navigate("/menu")
+ }
+
+ const handleAddBusinessUnit = async () => {
+ // Set Id here so request has access to it
+ localStorage.setItem(BUSINESS_UNIT_ID_KEY, unitId)
+ setIsLoading(true)
+
+ try {
+ // Small request to validate the business unit
+ await request({
+ service: "mcae",
+ path: "/objects/forms",
+ query: {
+ fields: "id",
+ limit: 1,
+ },
+ })
+
+ navigate("/menu")
+ } catch (e) {
+ console.error(e)
+ framer.notify("Invalid Business Unit Id", { variant: "error" })
+ // Failed to validate it, set it back to empty string
+ localStorage.setItem(BUSINESS_UNIT_ID_KEY, "")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+
+
+
Account Engagement
+
+ Enter your Business Unit ID. Need help? Learn more{" "}
+
+ here
+
+ .
+
+
+
+
setUnitId(e.target.value)}
+ className="w-full"
+ />
+
+
+ Skip
+
+
+ Connect
+
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/Menu.tsx b/plugins/salesforce/src/pages/Menu.tsx
new file mode 100644
index 00000000..69ca6979
--- /dev/null
+++ b/plugins/salesforce/src/pages/Menu.tsx
@@ -0,0 +1,62 @@
+import { useLocation } from "wouter"
+import classNames from "classnames"
+import { Logo } from "../components/Logo"
+import { ChartsIcon, MessagesIcon, FormsIcon, GlobeIcon, PersonIcon, SyncIcon } from "../components/Icons"
+import { framer } from "framer-plugin"
+
+interface MenuOptionProps {
+ icon: React.ReactElement
+ title: string
+ to: string
+ className?: string
+ state?: Record
+ onClick?: () => void
+}
+
+const MenuOption = ({ icon, title, to, className, state, onClick }: MenuOptionProps) => {
+ const [, navigate] = useLocation()
+
+ return (
+ (onClick ? onClick() : navigate(to, { state }))}
+ >
+ {icon}
+ {title}
+
+ )
+}
+
+export default function Menu() {
+ return (
+
+
+
+
+
Welcome to Salesforce
+
+ View forms, monitor site traffic, embed chats, and much more.
+
+
+
+
+ }
+ state={{ title: "Web Forms" }}
+ />
+ } />
+ } className="gap-[7px]" />
+ } />
+ }
+ onClick={() => framer.notify("Launch the plugin via the CMS to sync objects")}
+ />
+ } />
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/Messaging.tsx b/plugins/salesforce/src/pages/Messaging.tsx
new file mode 100644
index 00000000..7d3c4e9f
--- /dev/null
+++ b/plugins/salesforce/src/pages/Messaging.tsx
@@ -0,0 +1,130 @@
+import { framer } from "framer-plugin"
+import { useState } from "react"
+import { request, useMessagingEmbeddedServices, useOrgQuery, useUserQuery } from "@/api"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import { ScrollFadeContainer } from "@/components/ScrollFadeContainer"
+import auth from "@/auth"
+import { Message } from "@/components/Message"
+
+interface BuildEmbedScriptParams {
+ instanceUrl: string
+ orgId: string
+ durableId: string
+ urlPathPrefix: string
+ languageLocaleKey: string
+}
+
+const buildEmbedScript = ({
+ instanceUrl,
+ orgId,
+ durableId,
+ urlPathPrefix,
+ languageLocaleKey,
+}: BuildEmbedScriptParams) => `
+
+`
+
+export default function Messaging() {
+ const [copiedIndex, setCopiedIndex] = useState(null)
+ const { data: embeds, isLoading: isLoadingMessaging } = useMessagingEmbeddedServices()
+ const { data: user, isLoading: isLoadingUser } = useUserQuery()
+ const { data: org, isLoading: isLoadingorg } = useOrgQuery(user?.organization_id || "")
+
+ if (isLoadingMessaging || isLoadingUser || isLoadingorg) return
+
+ if (!embeds || !user || !org) return null
+
+ const handleCopyEmbed = async (copyIndex: number, siteId: string, durableId: string) => {
+ setCopiedIndex(copyIndex)
+
+ try {
+ const site = await request<{ Id: string; Name: string; UrlPathPrefix: string }>({
+ path: `/sobjects/Site/${siteId}`,
+ })
+
+ await navigator.clipboard.writeText(
+ buildEmbedScript({
+ instanceUrl: auth.tokens.getOrThrow().instanceUrl,
+ orgId: user.organization_id,
+ durableId: durableId,
+ urlPathPrefix: site.UrlPathPrefix,
+ languageLocaleKey: org.LanguageLocaleKey,
+ })
+ )
+ } catch {
+ framer.notify("Something went wrong fetching site data", { variant: "error" })
+ }
+
+ setTimeout(() => setCopiedIndex(null), 1000)
+
+ framer.notify("Paste the embed at the end of the tag on the pages where you want it to appear")
+ }
+
+ if (embeds.length === 0) {
+ return (
+ Create an embedded service in Salesforce to add it to your Framer site"
+ )
+ }
+
+ return (
+
+
+
+ {embeds.map((service, i) => {
+ const name = service.DurableId.replace(/_/g, " ")
+
+ return (
+
+
+ {name}
+
+
handleCopyEmbed(i, service.Site, service.DurableId)}
+ >
+ {copiedIndex === i ? "Copied" : "Copy"}
+
+
+ )
+ })}
+
+
+
+
+ window.open(
+ `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/EmbeddedServiceDeployments/home`,
+ "_blank"
+ )
+ }
+ >
+ View Embeds
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/ObjectSearch.tsx b/plugins/salesforce/src/pages/ObjectSearch.tsx
new file mode 100644
index 00000000..8b39619a
--- /dev/null
+++ b/plugins/salesforce/src/pages/ObjectSearch.tsx
@@ -0,0 +1,136 @@
+import { useState } from "react"
+import { useLocation } from "wouter"
+import { ScrollFadeContainer } from "../components/ScrollFadeContainer"
+import { SearchIcon, IconChevron } from "../components/Icons"
+import { SFObject, useObjectsQuery } from "@/api"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import auth from "@/auth"
+import { useSearchParams } from "@/hooks/useSearchParams"
+import { PluginError } from "@/PluginError"
+import { framer } from "framer-plugin"
+
+const searchObjects = (objects: SFObject[], query: string) => {
+ if (!query.trim() || !objects) return objects || []
+
+ const searchTerm = query.toLowerCase().trim()
+
+ const exactMatches: SFObject[] = []
+ const startsWithMatches: SFObject[] = []
+ const containsMatches: SFObject[] = []
+
+ objects.forEach(object => {
+ const name = object.name.toLowerCase()
+ const label = object.label.toLowerCase()
+
+ // Exact matches (highest priority)
+ if (name === searchTerm || label === searchTerm) {
+ exactMatches.push(object)
+ }
+ // Starts with matches (medium priority)
+ else if (name.startsWith(searchTerm) || label.startsWith(searchTerm)) {
+ startsWithMatches.push(object)
+ }
+ // Contains matches (lowest priority)
+ else if (name.includes(searchTerm) || label.includes(searchTerm)) {
+ containsMatches.push(object)
+ }
+ })
+
+ return [...exactMatches, ...startsWithMatches, ...containsMatches]
+}
+
+export default function ObjectSearch() {
+ const [, navigate] = useLocation()
+ const [hoveredIndex, setHoveredIndex] = useState(null)
+ const [searchQuery, setSearchQuery] = useState("")
+ const { data: objects, isLoading } = useObjectsQuery()
+
+ const params = useSearchParams()
+ const redirect = params.get("redirect")
+ const requiredFields = params.get("requiredFields")?.split(",") || []
+
+ const handleNavigateToObject = (object: SFObject) => {
+ navigate(`${redirect}?objectName=${object.name}&objectLabel=${object.label}`, {
+ state: { title: object.label },
+ })
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ // Pressing enter will go ahead with the first object shown
+ if (e.key === "Enter" && filteredObjects?.length > 0) {
+ e.preventDefault()
+ handleNavigateToObject(filteredObjects[0])
+ }
+ }
+
+ if (!redirect) {
+ throw new PluginError("Param Error", "Expected 'redirect' query param")
+ }
+
+ if (isLoading) return
+
+ if (!objects) return null
+
+ const allowedObjects = objects.sobjects.filter(object =>
+ requiredFields.every(field => object[field as keyof SFObject] === true)
+ )
+ const filteredObjects = searchObjects(allowedObjects, searchQuery)
+
+ return (
+
+
+
+ setSearchQuery(e.target.value)}
+ value={searchQuery}
+ onKeyDown={handleKeyDown}
+ />
+
+
+ {filteredObjects.length > 0 ? (
+ filteredObjects.map((object, index) => (
+ setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ onClick={() => handleNavigateToObject(object)}
+ >
+
+ {object.label} ({object.name})
+
+ {hoveredIndex === index && }
+
+ ))
+ ) : (
+
+
No Results
+
+ Try using different keywords and search again
+
+
+ )}
+
+
+
+
+ window.open(
+ `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/ObjectManager/home`,
+ "_blank"
+ )
+ }
+ >
+ View Objects
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/Sync.tsx b/plugins/salesforce/src/pages/Sync.tsx
new file mode 100644
index 00000000..a3a0c9f6
--- /dev/null
+++ b/plugins/salesforce/src/pages/Sync.tsx
@@ -0,0 +1,212 @@
+import { framer } from "framer-plugin"
+import {
+ getCollectionFieldForSalesforceField,
+ getPossibleSlugFields,
+ ObjectIdMap,
+ PluginContext,
+ SyncProgress,
+ useSyncRecordsMutation,
+} from "@/cms"
+import { SFFieldConfig, useObjectConfigQuery } from "@/api"
+import { useEffect, useMemo, useState } from "react"
+import { useSearchParams } from "@/hooks/useSearchParams"
+import { assert, isDefined } from "@/utils"
+import { logSyncResult, PLUGIN_LOG_SYNC_KEY } from "@/debug"
+import { Button } from "@/components/Button"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import { PageProps } from "@/router"
+import { FieldMapper, ManagedCollectionFieldConfig } from "@/components/FieldMapper"
+import { PluginError } from "@/PluginError"
+
+// Due to the number of fields an object can have, we deprioritize some system created fields by
+// default, as they're unlikely to be used
+const LOW_PRIORITY_FIELDS = [
+ "Id",
+ "OwnerId",
+ "IsDeleted",
+ "CreatedDate",
+ "CreatedById",
+ "LastModifiedDate",
+ "LastModifiedById",
+ "SystemModstamp",
+]
+
+const useLoggingToggle = () => {
+ useEffect(() => {
+ const isLoggingEnabled = () => localStorage.getItem(PLUGIN_LOG_SYNC_KEY) === "true"
+
+ const toggle = () => {
+ const newState = !isLoggingEnabled()
+ localStorage.setItem(PLUGIN_LOG_SYNC_KEY, newState ? "true" : "false")
+ framer.notify(`Logging ${newState ? "enabled" : "disabled"}`, { variant: "info" })
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.ctrlKey && e.shiftKey && e.key === "L") {
+ e.preventDefault()
+ toggle()
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown)
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [])
+}
+
+const getInitialSlugFieldId = (context: PluginContext): string | null => {
+ if (context.type === "update" && context.slugFieldId) return context.slugFieldId
+
+ // All objects have this property
+ return "Id"
+}
+
+const createFieldConfig = (fieldConfigs: SFFieldConfig[], objectIdMap: ObjectIdMap): ManagedCollectionFieldConfig[] => {
+ const includedFields = fieldConfigs.filter(field => !LOW_PRIORITY_FIELDS.includes(field.name))
+ const excludedFields = fieldConfigs.filter(field => LOW_PRIORITY_FIELDS.includes(field.name))
+ const fields = [...includedFields, ...excludedFields]
+
+ return fields.map(fieldConfig => ({
+ field: getCollectionFieldForSalesforceField(fieldConfig, objectIdMap),
+ originalFieldName: fieldConfig.label,
+ }))
+}
+
+const getFieldNameOverrides = (pluginContext: PluginContext): Record => {
+ if (pluginContext.type !== "update") return {}
+
+ return Object.fromEntries(pluginContext.collectionFields.map(field => [field.id, field.name]))
+}
+
+export default function Sync({ pluginContext }: PageProps) {
+ useLoggingToggle()
+
+ const params = useSearchParams()
+ const objectName = params.get("objectName")
+ const objectLabel = params.get("objectLabel")
+
+ const { data: objectConfig, isLoading: isLoadingObjectConfig } = useObjectConfigQuery(objectName || "")
+ const slugFields = useMemo(() => getPossibleSlugFields(objectConfig?.fields || []), [objectConfig?.fields])
+
+ const [slugFieldId, setSlugFieldId] = useState(null)
+ const [collectionFieldConfig, setCollectionFieldConfig] = useState([])
+ const [includedFieldIds, setIncludedFieldIds] = useState(new Set())
+ const [fieldNameOverrides, setFieldNameOverrides] = useState>({})
+ const [, setProgress] = useState(null)
+
+ useEffect(() => {
+ if (!pluginContext || !objectConfig) return
+
+ const newIncludedFieldNames = new Set(
+ pluginContext.type === "update" ? pluginContext.includedFieldIds : []
+ )
+
+ setSlugFieldId(getInitialSlugFieldId(pluginContext))
+ setCollectionFieldConfig(createFieldConfig(objectConfig.fields || [], pluginContext.objectIdMap))
+ setIncludedFieldIds(newIncludedFieldNames)
+ setFieldNameOverrides(getFieldNameOverrides(pluginContext))
+ }, [pluginContext, objectConfig])
+
+ const { mutate: sync, isPending: isSyncing } = useSyncRecordsMutation({
+ onSuccess: result => {
+ logSyncResult(result)
+ if (result.status === "success") {
+ framer.closePlugin("Synchronization successful")
+ return
+ }
+ },
+ onError: e => framer.notify(e.message, { variant: "error" }),
+ })
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+
+ const allFields = collectionFieldConfig
+ .filter(fieldConfig => fieldConfig.field && includedFieldIds.has(fieldConfig.field.id))
+ .map(fieldConfig => fieldConfig.field)
+ .filter(isDefined)
+ .map(field => {
+ if (fieldNameOverrides[field.id]) {
+ field.name = fieldNameOverrides[field.id]
+ }
+
+ return field
+ })
+
+ assert(slugFieldId)
+ assert(objectConfig)
+ assert(typeof objectLabel === "string")
+ assert(typeof objectName === "string")
+ setProgress(null)
+
+ sync({
+ objectId: objectName,
+ objectLabel,
+ onProgress: setProgress,
+ includedFieldIds: Array.from(includedFieldIds),
+ fieldConfigs: objectConfig.fields,
+ fields: allFields,
+ slugFieldId,
+ })
+ }
+
+ if (!objectName || !objectLabel) {
+ throw new PluginError("Invalid Params", "Expected 'objectName' and 'objectLabel' query params")
+ }
+
+ if (!pluginContext || isLoadingObjectConfig) {
+ return
+ }
+
+ return (
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/account-engagement/AccountEngagementFormHandlers.tsx b/plugins/salesforce/src/pages/account-engagement/AccountEngagementFormHandlers.tsx
new file mode 100644
index 00000000..0e02e402
--- /dev/null
+++ b/plugins/salesforce/src/pages/account-engagement/AccountEngagementFormHandlers.tsx
@@ -0,0 +1,71 @@
+import { useAccountEngagementFormHandlers } from "@/api"
+import { ScrollFadeContainer } from "@/components/ScrollFadeContainer"
+import auth from "@/auth"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import { CopyIcon, TickIcon } from "@/components/Icons"
+import { useState } from "react"
+import { API_URL } from "@/constants"
+import { framer } from "framer-plugin"
+import { Message } from "@/components/Message"
+
+export default function AccountEngagementFormHandlers() {
+ const { data: handlers, isLoading } = useAccountEngagementFormHandlers()
+ const [copiedIndex, setCopiedIndex] = useState(null)
+
+ const handleCopy = (handler: string, index: number) => {
+ navigator.clipboard.writeText(`${API_URL}/api/forms/account-engagement/forward?handler=${handler}`)
+ framer.notify("Paste the webhook into your Framer Form webhook settings")
+ setCopiedIndex(index)
+ setTimeout(() => setCopiedIndex(null), 2000)
+ }
+
+ if (isLoading) return
+
+ if (!handlers) return null
+
+ if (handlers.length === 0) {
+ return (
+
+ Create a form handler in Account Engagement to add it to your Framer site
+
+ )
+ }
+
+ return (
+
+
+ Create a Framer form with field names matching the form handler fields, then add the webhook URL below.
+
+
+ {handlers.map((form, index) => (
+
+
{form.name}
+
handleCopy(form.url, index)}
+ >
+ {copiedIndex === index ? : }
+
+
+ ))}
+
+
+
+
+ window.open(
+ `${auth.tokens.getOrThrow().instanceUrl}/lightning/page/pardot/form%2Fforms`,
+ "_blank"
+ )
+ }
+ >
+ View Forms
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/account-engagement/index.tsx b/plugins/salesforce/src/pages/account-engagement/index.tsx
new file mode 100644
index 00000000..ebf4ae24
--- /dev/null
+++ b/plugins/salesforce/src/pages/account-engagement/index.tsx
@@ -0,0 +1,75 @@
+import { useAccountEngagementForms } from "@/api"
+import { ScrollFadeContainer } from "@/components/ScrollFadeContainer"
+import auth from "@/auth"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import { InternalLink } from "@/components/Link"
+import { Draggable, framer } from "framer-plugin"
+import { Message } from "@/components/Message"
+
+export default function AccountEngagementForms() {
+ const { data: forms, isLoading } = useAccountEngagementForms()
+
+ if (isLoading) return
+
+ if (!forms) return null
+
+ if (forms.length === 0) {
+ return Create a form in Account Engagement to add it to your Framer site
+ }
+
+ return (
+
+
+ For further customization, use{" "}
+ form handlers .
+
+
+ {forms.map((form, index) => (
+
+
+ framer.addComponentInstance({
+ url: "https://framer.com/m/Salesforce-Form-jh2p.js",
+ attributes: {
+ controls: {
+ html: form.embedCode,
+ },
+ },
+ })
+ }
+ >
+ {form.name}
+
+
+ ))}
+
+
+
+
+ window.open(
+ `${auth.tokens.getOrThrow().instanceUrl}/lightning/page/pardot/form%2Fforms`,
+ "_blank"
+ )
+ }
+ >
+ View Forms
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/account/DomainConnection.tsx b/plugins/salesforce/src/pages/account/DomainConnection.tsx
new file mode 100644
index 00000000..6cf9e5f4
--- /dev/null
+++ b/plugins/salesforce/src/pages/account/DomainConnection.tsx
@@ -0,0 +1,24 @@
+import { ExternalLink } from "../../components/Link"
+
+export default function DomainConnection() {
+ return (
+
+
+ Adds your domains to the Trusted Sites and{" "}
+ CORS Whitelist in your Salesforce Org to ensure
+ both forms and bots can communicate with Salesforce.
+
+
+ window.open(
+ "https://help.salesforce.com/s/articleView?id=sf.security_overview.htm&type=5",
+ "_blank"
+ )
+ }
+ >
+ Learn More
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/account/index.tsx b/plugins/salesforce/src/pages/account/index.tsx
new file mode 100644
index 00000000..a80bb3b9
--- /dev/null
+++ b/plugins/salesforce/src/pages/account/index.tsx
@@ -0,0 +1,204 @@
+import { framer } from "framer-plugin"
+import auth from "@/auth"
+import {
+ SFCorsWhitelist,
+ SFTrustedSitesList,
+ useCorsWhitelistMutation,
+ useCorsWhitelistQuery,
+ useOrgQuery,
+ useRemoveCorsWhitelistMutation,
+ useRemoveTrustedSiteMutation,
+ useTrustedSiteMutation,
+ useTrustedSitesQuery,
+ useUserQuery,
+} from "@/api"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import { Button } from "@/components/Button"
+import { usePublishInfo } from "@/hooks/usePublishInfo"
+import { useState } from "react"
+import { InfoIcon } from "@/components/Icons"
+import { InternalLink } from "@/components/Link"
+
+interface DomainButtonProps {
+ domain: string
+ trustedSites: string[]
+ corsWhitelist: string[]
+ trustedSitesData?: SFTrustedSitesList
+ corsWhitelistData?: SFCorsWhitelist
+ disabled?: boolean
+ isLoading?: boolean
+}
+
+const DomainButton = ({
+ domain,
+ trustedSites,
+ corsWhitelist,
+ trustedSitesData,
+ corsWhitelistData,
+ disabled,
+ isLoading,
+}: DomainButtonProps) => {
+ const [isLoadingLocal, setIsLoadingLocal] = useState(false)
+
+ const { mutateAsync: addTrustedSite } = useTrustedSiteMutation()
+ const { mutateAsync: addCorsWhitelist } = useCorsWhitelistMutation()
+ const { mutateAsync: removeTrustedSite } = useRemoveTrustedSiteMutation()
+ const { mutateAsync: removeCorsWhitelist } = useRemoveCorsWhitelistMutation()
+
+ // If we don't have the required data yet, show disabled
+ if (!trustedSitesData || !corsWhitelistData) {
+ return
+ }
+
+ const isConnectedToTrustedSites = trustedSites.includes(domain)
+ const isConnectedToCors = corsWhitelist.includes(domain)
+ const isFullyConnected = isConnectedToTrustedSites && isConnectedToCors
+
+ const handleConnect = async () => {
+ if (!trustedSitesData || !corsWhitelistData) return
+
+ try {
+ setIsLoadingLocal(true)
+ if (!trustedSites.includes(domain)) {
+ await addTrustedSite({
+ url: domain,
+ description: `Framer domain: ${domain}`,
+ })
+ }
+
+ if (!corsWhitelist.includes(domain)) {
+ await addCorsWhitelist(domain)
+ }
+ } finally {
+ setIsLoadingLocal(false)
+ }
+ }
+
+ const handleDisconnect = async () => {
+ if (!trustedSitesData || !corsWhitelistData) return
+
+ try {
+ setIsLoadingLocal(true)
+
+ const trustedSite = trustedSitesData.records.find(site => site.EndpointUrl === domain)
+ const corsEntry = corsWhitelistData.records.find(entry => entry.UrlPattern === domain)
+
+ if (trustedSite?.Id) {
+ await removeTrustedSite(trustedSite.Id)
+ }
+
+ if (corsEntry?.Id) {
+ await removeCorsWhitelist(corsEntry.Id)
+ }
+ } finally {
+ setIsLoadingLocal(false)
+ }
+ }
+
+ return (
+ (isFullyConnected ? handleDisconnect() : handleConnect())}
+ >
+ {isFullyConnected ? "Disconnect" : "Connect"}
+
+ )
+}
+
+export default function Account() {
+ const publishInfo = usePublishInfo()
+ const stagingUrl = publishInfo?.staging?.url
+ const prodUrl = publishInfo?.production?.url
+
+ const { data: trustedSites, isLoading: isLoadingTrustedSites } = useTrustedSitesQuery()
+ const { data: corsWhitelist, isLoading: isLoadingCorsWhitelist } = useCorsWhitelistQuery()
+ const { data: user, isLoading: isLoadingUser } = useUserQuery()
+ const { data: org, isLoading: isLoadingOrg } = useOrgQuery(user?.organization_id || "")
+
+ const trustedSiteUrls = trustedSites?.records.map(site => site.EndpointUrl) || []
+ const corsWhitelistUrls = corsWhitelist?.records.map(entry => entry.UrlPattern) || []
+
+ const handleLogout = async () => {
+ auth.logout().then(() =>
+ framer.closePlugin(
+ "To completely remove Framer, uninstall the app from the Salesforce Setup > Installed Packages page"
+ )
+ )
+ }
+
+ if (isLoadingUser || isLoadingOrg) return
+
+ if (!user || !org) return null
+
+ return (
+
+ User
+
+ Org
+
+
+
+
Type
+
{org.OrganizationType}
+
+
+
BUID
+
{auth.getBusinessUnitId() || "N/A"}
+
+
+
+ Domain
+
+
+
+
+ Staging
+ {stagingUrl ? (
+
+ ) : (
+
+ N/A
+
+ )}
+
+
+ Production
+ {stagingUrl ? (
+
+ ) : (
+
+ N/A
+
+ )}
+
+
+
+
+ Logout
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/index.tsx b/plugins/salesforce/src/pages/index.tsx
new file mode 100644
index 00000000..87789182
--- /dev/null
+++ b/plugins/salesforce/src/pages/index.tsx
@@ -0,0 +1,14 @@
+export { default as Tracking } from "./tracking"
+export { default as TrackingMID } from "./tracking/TrackingMID"
+export { default as ObjectSearch } from "./ObjectSearch"
+export { default as Menu } from "./Menu"
+export { default as Auth } from "./Auth"
+export { default as BusinessUnitId } from "./BusinessUnitId"
+export { default as AccountEngagementForms } from "./account-engagement"
+export { default as AccountEngagementFormHandlers } from "./account-engagement/AccountEngagementFormHandlers"
+export { default as Account } from "./account"
+export { default as DomainConnection } from "./account/DomainConnection"
+export { default as Messaging } from "./Messaging"
+export { default as Sync } from "./Sync"
+export { default as WebForm } from "./web-form"
+export { default as WebFormFields } from "./web-form/WebFormFields"
diff --git a/plugins/salesforce/src/pages/tracking/TrackingMID.tsx b/plugins/salesforce/src/pages/tracking/TrackingMID.tsx
new file mode 100644
index 00000000..8a389c51
--- /dev/null
+++ b/plugins/salesforce/src/pages/tracking/TrackingMID.tsx
@@ -0,0 +1,32 @@
+import { useSavedMarketingId } from "./hooks/useSavedMarketingId"
+import { useState } from "react"
+
+export default function TrackingMID() {
+ const [, setSavedMarketingId] = useSavedMarketingId()
+ const [inputMarketingId, setInputMarketingId] = useState("")
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!inputMarketingId) return
+
+ setSavedMarketingId(inputMarketingId)
+ }
+
+ return (
+
+ Log in to Marketing Cloud and hover over your account name in the upper-right corner.
+
+
+ Save
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/tracking/hooks/useSavedMarketingId.ts b/plugins/salesforce/src/pages/tracking/hooks/useSavedMarketingId.ts
new file mode 100644
index 00000000..47d5ed36
--- /dev/null
+++ b/plugins/salesforce/src/pages/tracking/hooks/useSavedMarketingId.ts
@@ -0,0 +1,34 @@
+import { framer } from "framer-plugin"
+import { useEffect, useState } from "react"
+
+const MID_PLUGIN_KEY = "marketingId"
+
+export const useSavedMarketingId = () => {
+ const [savedMarketingId, setSavedMarketingId] = useState(null)
+
+ useEffect(() => {
+ const loadMarketingId = async () => {
+ const storedId = await framer.getPluginData(MID_PLUGIN_KEY)
+ if (storedId) {
+ setSavedMarketingId(storedId)
+ } else {
+ setSavedMarketingId("")
+ }
+ }
+
+ loadMarketingId()
+ }, [])
+
+ const setMarketingId = async (newId: string) => {
+ try {
+ await fetch(`https://${newId}.collect.igodigital.com/collect.js`, { method: "HEAD" })
+ setSavedMarketingId(newId)
+ await framer.setPluginData(MID_PLUGIN_KEY, newId)
+ framer.notify("MID saved", { variant: "success" })
+ } catch {
+ framer.notify("Invalid MID", { variant: "error" })
+ }
+ }
+
+ return [savedMarketingId, setMarketingId] as const
+}
diff --git a/plugins/salesforce/src/pages/tracking/index.tsx b/plugins/salesforce/src/pages/tracking/index.tsx
new file mode 100644
index 00000000..7bc5863e
--- /dev/null
+++ b/plugins/salesforce/src/pages/tracking/index.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react"
+import classNames from "classnames"
+import { useLocation } from "wouter"
+import { ExternalLink } from "@/components/Link"
+import { useSavedMarketingId } from "./hooks/useSavedMarketingId"
+import { useCustomCode } from "@/hooks/useCustomCode"
+import { framer } from "framer-plugin"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+
+const buildTrackingScript = (mid: string) => ({
+ headEndHTML: ``,
+ bodyEndHTML: ``,
+})
+
+export default function Tracking() {
+ const customCode = useCustomCode()
+ const [, navigate] = useLocation()
+ const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
+ const [savedMarketingId] = useSavedMarketingId()
+
+ useEffect(() => {
+ if (!savedMarketingId) return
+
+ const { headEndHTML, bodyEndHTML } = buildTrackingScript(savedMarketingId)
+ const isEnabled =
+ customCode?.headEnd.html?.includes(headEndHTML) && customCode?.bodyEnd.html?.includes(bodyEndHTML)
+
+ setIsTrackingEnabled(!!isEnabled)
+ }, [customCode, savedMarketingId])
+
+ const handleToggleTracking = async () => {
+ setIsTrackingEnabled(true)
+
+ if (!savedMarketingId) return
+
+ const { headEndHTML, bodyEndHTML } = buildTrackingScript(savedMarketingId)
+
+ await framer.setCustomCode({
+ location: "headEnd",
+ html: isTrackingEnabled ? null : headEndHTML,
+ })
+
+ await framer.setCustomCode({
+ location: "bodyEnd",
+ html: isTrackingEnabled ? null : bodyEndHTML,
+ })
+ }
+
+ if (savedMarketingId === null) return
+
+ return (
+
+
+ By installing the Collect Tracking Code, you can monitor visitor interactions and activities on your
+ site. Learn more{" "}
+
+ here
+
+ .
+
+ {savedMarketingId ? (
+
+ {isTrackingEnabled ? "Disable" : "Enable"}
+
+ ) : (
+ navigate("/tracking/mid")} className="framer-button-primary">
+ Get Started
+
+ )}
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/web-form/WebFormFields.tsx b/plugins/salesforce/src/pages/web-form/WebFormFields.tsx
new file mode 100644
index 00000000..2ad8cfb2
--- /dev/null
+++ b/plugins/salesforce/src/pages/web-form/WebFormFields.tsx
@@ -0,0 +1,66 @@
+import { useSearchParams } from "@/hooks/useSearchParams"
+import { useObjectConfigQuery } from "@/api"
+import { Button } from "@/components/Button"
+import { IconChevron } from "@/components/Icons"
+import { ScrollFadeContainer } from "@/components/ScrollFadeContainer"
+import { CenteredSpinner } from "@/components/CenteredSpinner"
+import auth from "@/auth"
+import { Message } from "@/components/Message"
+import { PluginError } from "@/PluginError"
+
+export default function WebFormFields() {
+ const params = useSearchParams()
+ const objectName = params.get("objectName")
+ const { data: objectConfig, isLoading } = useObjectConfigQuery(objectName || "")
+
+ if (isLoading) return
+
+ if (!objectName) {
+ throw new PluginError("Invalid Params", "Expected 'objectName' params")
+ }
+
+ if (!objectConfig) return null
+
+ const availableFormFields = objectConfig.fields.filter(
+ field =>
+ !field.name.toLowerCase().includes("utm_") && // Exclude GA fields
+ !field.name.toLowerCase().startsWith("pi__") && // Exclude AE fields
+ !field.name.toLowerCase().includes("pardot") // More AE fields (fka Pardot) use this
+ )
+
+ if (availableFormFields.length === 0) {
+ return This object does not have any configurable form fields
+ }
+
+ return (
+
+
+
+ {availableFormFields?.map((field, i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ window.open(
+ `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/ObjectManager/${objectName}/Details/view`,
+ "_blank"
+ )
+ }
+ >
+ {" "}
+ View Object
+
+
+
+ )
+}
diff --git a/plugins/salesforce/src/pages/web-form/index.tsx b/plugins/salesforce/src/pages/web-form/index.tsx
new file mode 100644
index 00000000..db3a6c1a
--- /dev/null
+++ b/plugins/salesforce/src/pages/web-form/index.tsx
@@ -0,0 +1,51 @@
+import { CopyInput } from "@/components/CopyInput"
+import { useSearchParams } from "@/hooks/useSearchParams"
+import { useWebFormWebhookQuery } from "@/api"
+import { Button } from "@/components/Button"
+import { InternalLink } from "@/components/Link"
+import auth from "@/auth"
+import { API_URL } from "@/constants"
+import { PluginError } from "@/PluginError"
+
+export default function WebForm() {
+ const params = useSearchParams()
+ const objectName = params.get("objectName")
+ const objectLabel = params.get("objectLabel")
+
+ const { data: form, isPending } = useWebFormWebhookQuery(objectName || "")
+
+ if (!objectName || !objectLabel) {
+ throw new PluginError("Invalid Params", "Expected 'objectName' and 'objectLabel' query params")
+ }
+
+ return (
+
+
+ Create a Framer form with field names matching the object's fields, then add the webhook URL below. See{" "}
+
+ available fields
+
+ .
+
+
+
+ window.open(
+ `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/ObjectManager/${objectName}/Details/view`,
+ "_blank"
+ )
+ }
+ >
+ View Object
+
+
+ )
+}
diff --git a/plugins/salesforce/src/router.tsx b/plugins/salesforce/src/router.tsx
new file mode 100644
index 00000000..73dfcdb2
--- /dev/null
+++ b/plugins/salesforce/src/router.tsx
@@ -0,0 +1,192 @@
+import { cloneElement, useEffect, useState } from "react"
+import { RouteComponentProps, useLocation, useRoute } from "wouter"
+import { AnimatePresence, MotionProps, motion } from "framer-motion"
+import { PageErrorBoundaryFallback } from "./components/PageErrorBoundaryFallback"
+import { Layout } from "./components/Layout"
+import { PluginContext } from "./cms"
+import { framer } from "framer-plugin"
+
+export interface PageProps extends RouteComponentProps {
+ pluginContext: PluginContext | null
+}
+
+export interface PluginSize {
+ width?: number
+ height?: number
+}
+
+export interface Route {
+ path: string
+ element: React.ComponentType
+ title?: string | (() => string)
+ children?: Route[]
+ size?: PluginSize
+}
+
+interface Match {
+ match: ReturnType
+ route: Route
+}
+
+interface UseRoutesProps {
+ routes: Route[]
+ pluginContext: PluginContext | null
+}
+
+const DEFAULT_PLUGIN_WIDTH = 260
+const DEFAULT_PLUGIN_HEIGHT = 345
+
+function useRoutes({ routes, pluginContext }: UseRoutesProps) {
+ const [location] = useLocation()
+ const [animationDirection, setAnimationDirection] = useState(1)
+ const [isFirstPage, setIsFirstPage] = useState(true)
+ // Save the length of the `routes` array that we receive on the first render
+ const [routesLen] = useState(() => routes.length)
+
+ // because we call `useRoute` inside a loop the number of routes can't be changed
+ if (routesLen !== routes.length) {
+ throw new Error("The length of `routes` array provided to `useRoutes` must be constant")
+ }
+
+ useEffect(() => {
+ setIsFirstPage(false)
+ }, [])
+
+ useEffect(() => {
+ const originalHistoryBack = history.back
+
+ history.back = () => {
+ setAnimationDirection(-1)
+ originalHistoryBack.call(history)
+ }
+
+ return () => {
+ history.back = originalHistoryBack
+ }
+ }, [])
+
+ useEffect(() => {
+ setAnimationDirection(1)
+ }, [location])
+
+ const matches: Match[] = []
+
+ const addToMatch = (route: Route, parentPath = "") => {
+ const fullPath = parentPath + route.path
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const match = useRoute(fullPath)
+ matches.push({ match, route: { ...route, path: fullPath } })
+
+ if (route.children) {
+ for (const child of route.children) {
+ addToMatch(child, fullPath)
+ }
+ }
+ }
+
+ for (const route of routes) {
+ addToMatch(route)
+ }
+
+ for (const { match, route } of matches) {
+ const [isMatch, params] = match
+ const { title, size, element: Element } = route
+
+ if (!isMatch) continue
+
+ const pageTitle = title ? (typeof title === "function" ? title() : title) : undefined
+
+ const animationProps = isFirstPage
+ ? {}
+ : {
+ initial: {
+ x: `${animationDirection * 50}vw`,
+ opacity: 0,
+ position: "absolute",
+ zIndex: 2,
+ },
+ animate: {
+ x: 0,
+ opacity: 1,
+ position: "relative",
+ zIndex: 1,
+ },
+ exit: {
+ x: `${animationDirection * -30}vw`,
+ opacity: 0,
+ position: "absolute",
+ zIndex: 0,
+ },
+ transition: {
+ x: {
+ ease: [0.25, 0.1, 0.25, 1],
+ duration: 0.3,
+ },
+ opacity: {
+ duration: 0.2,
+ },
+ },
+ }
+
+ return {
+ page: (
+
+
+
+
+
+
+
+ ),
+ size,
+ }
+ }
+
+ return { page: 404. This should never happen.
}
+}
+
+interface RouterProps {
+ routes: Route[]
+ pluginContext: PluginContext | null
+}
+
+/**
+ * Prevents layout shifts by preserving each page's size during transitions.
+ */
+const SizePreserver = ({ size, children }: { size?: PluginSize; children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export function Router({ routes, pluginContext }: RouterProps) {
+ const { page, size } = useRoutes({ routes, pluginContext })
+
+ useEffect(() => {
+ framer.showUI({
+ width: size?.width ?? 260,
+ height: size?.height ?? 345,
+ })
+ }, [size])
+
+ return (
+
+
+ {page && (
+
+ {cloneElement(page, { key: location.pathname })}
+
+ )}
+
+
+ )
+}
diff --git a/plugins/salesforce/src/utils.ts b/plugins/salesforce/src/utils.ts
new file mode 100644
index 00000000..7dcb0882
--- /dev/null
+++ b/plugins/salesforce/src/utils.ts
@@ -0,0 +1,90 @@
+import { ManagedCollectionField } from "framer-plugin"
+
+export const FIELD_DELIMITER = "rfa4Emr21pUgs0in"
+
+export function assert(condition: unknown, ...msg: unknown[]): asserts condition {
+ if (condition) return
+
+ const e = Error("Assertion Error" + (msg.length > 0 ? ": " + msg.join(" ") : ""))
+ // Hack the stack so the assert call itself disappears. Works in jest and in chrome.
+ if (e.stack) {
+ try {
+ const lines = e.stack.split("\n")
+ if (lines[1]?.includes("assert")) {
+ lines.splice(1, 1)
+ e.stack = lines.join("\n")
+ } else if (lines[0]?.includes("assert")) {
+ lines.splice(0, 1)
+ e.stack = lines.join("\n")
+ }
+ } catch {
+ // nothing
+ }
+ }
+ throw e
+}
+
+// Match everything except for letters, numbers and parentheses.
+const nonSlugCharactersRegExp = /[^\p{Letter}\p{Number}()]+/gu
+// Match leading/trailing dashes, for trimming purposes.
+const trimSlugRegExp = /^-+|-+$/gu
+
+/**
+ * Takes a freeform string and removes all characters except letters, numbers,
+ * and parentheses. Also makes it lower case, and separates words by dashes.
+ * This makes the value URL safe.
+ */
+export function slugify(value: string): string {
+ return value.toLowerCase().replace(nonSlugCharactersRegExp, "-").replace(trimSlugRegExp, "")
+}
+
+export function isDefined(value: T): value is NonNullable {
+ return value !== undefined && value !== null
+}
+
+/**
+ * Generates an 8-character unique ID from a text using the djb2 hash function.
+ * Converts the 32-bit hash to an unsigned integer and then to a hex string.
+ */
+export function generateHash(text: string): string {
+ let hash = 5381
+ for (let i = 0, len = text.length; i < len; i++) {
+ hash = (hash * 33) ^ text.charCodeAt(i)
+ }
+ // Convert to unsigned 32-bit integer
+ const unsignedHash = hash >>> 0
+ return unsignedHash.toString(16).padStart(8, "0")
+}
+
+/**
+ * Creates a consistent hash from an array of field IDs
+ */
+export function createFieldSetHash(fieldIds: string[]): string {
+ // Ensure consistent ordering
+ const sortedIds = [...fieldIds].sort()
+ return generateHash(sortedIds.join(FIELD_DELIMITER))
+}
+
+/**
+ * Processes a field set to determine the complementary fields
+ */
+export function computeFieldSets(params: {
+ currentFields: ManagedCollectionField[]
+ allPossibleFieldIds: string[]
+ storedHash: string
+}) {
+ const { currentFields, allPossibleFieldIds, storedHash } = params
+ const currentFieldIds = currentFields.map(field => field.id)
+
+ const includedFieldIds = currentFieldIds
+
+ const excludedFieldIds = allPossibleFieldIds.filter(id => !currentFieldIds.includes(id))
+
+ const currentHash = createFieldSetHash(includedFieldIds)
+
+ return {
+ includedFieldIds,
+ excludedFieldIds,
+ hasHashChanged: storedHash !== currentHash,
+ }
+}
diff --git a/plugins/salesforce/src/vite-env.d.ts b/plugins/salesforce/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/plugins/salesforce/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/plugins/salesforce/tailwind.config.js b/plugins/salesforce/tailwind.config.js
new file mode 100644
index 00000000..544e1713
--- /dev/null
+++ b/plugins/salesforce/tailwind.config.js
@@ -0,0 +1,38 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class", "[data-framer-theme='dark']"],
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ backgroundColor: {
+ primary: "var(--framer-color-bg)",
+ secondary: "var(--framer-color-bg-secondary)",
+ tertiary: "var(--framer-color-bg-tertiary)",
+ tertiaryDimmedLight: "rgba(243, 243, 243, 0.75)",
+ tertiaryDimmedDark: "rgba(43, 43, 43, 0.75)",
+ divider: "var(--framer-color-divider)",
+ tint: "var(--framer-color-tint)",
+ tintDimmed: "var(--framer-color-tint-dimmed)",
+ tintDark: "var(--framer-color-tint-dark)",
+ blackDimmed: "rgba(0, 0, 0, 0.5)",
+ "framer-red": "#FF3366",
+ },
+ colors: {
+ primary: "var(--framer-color-text)",
+ secondary: "var(--framer-color-text-secondary)",
+ tertiary: "var(--framer-color-text-tertiary)",
+ inverted: "var(--framer-color-text-inverted)",
+ tint: "var(--framer-color-tint)",
+ "framer-red": "#FF3366",
+ "framer-red-dimmed": "#e15",
+ "salesforce-blue": "#0D9DDA",
+ },
+ borderColor: {
+ divider: "var(--framer-color-divider)",
+ },
+ gridTemplateColumns: {
+ fieldPicker: "1fr 8px 1fr",
+ },
+ },
+ },
+}
diff --git a/plugins/salesforce/tsconfig.json b/plugins/salesforce/tsconfig.json
new file mode 100644
index 00000000..8598d74b
--- /dev/null
+++ b/plugins/salesforce/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ES2022",
+
+ /* Paths */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/plugins/salesforce/vite.config.ts b/plugins/salesforce/vite.config.ts
new file mode 100644
index 00000000..a71f79bb
--- /dev/null
+++ b/plugins/salesforce/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite"
+import react from "@vitejs/plugin-react-swc"
+import mkcert from "vite-plugin-mkcert"
+import framer from "vite-plugin-framer"
+import path from "path"
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react(), mkcert(), framer()],
+ build: {
+ target: "ES2022",
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+})