From b1bd42d593e2e428e71a89d76796672952383941 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 28 Nov 2024 21:53:58 +0000 Subject: [PATCH] Add Salesforce plugin --- package-lock.json | 1197 ++++++++++++++++- plugins/salesforce/.gitignore | 33 + plugins/salesforce/README.md | 19 + plugins/salesforce/eslint.config.js | 25 + plugins/salesforce/framer.json | 6 + plugins/salesforce/index.html | 14 + plugins/salesforce/package.json | 43 + plugins/salesforce/postcss.config.js | 6 + plugins/salesforce/public/salesforce.svg | 4 + plugins/salesforce/src/App.tsx | 211 +++ plugins/salesforce/src/PluginError.ts | 15 + plugins/salesforce/src/api.ts | 606 +++++++++ plugins/salesforce/src/auth.ts | 154 +++ plugins/salesforce/src/cms.ts | 511 +++++++ plugins/salesforce/src/components/Button.tsx | 13 + .../src/components/CenteredSpinner.tsx | 8 + .../src/components/CheckboxTextField.tsx | 45 + .../salesforce/src/components/CopyInput.tsx | 33 + .../salesforce/src/components/FieldMapper.tsx | 122 ++ plugins/salesforce/src/components/Icons.tsx | 110 ++ plugins/salesforce/src/components/Layout.tsx | 58 + plugins/salesforce/src/components/Link.tsx | 31 + plugins/salesforce/src/components/Logo.tsx | 1 + plugins/salesforce/src/components/Message.tsx | 16 + .../components/PageErrorBoundaryFallback.tsx | 50 + .../src/components/ScrollFadeContainer.tsx | 84 ++ plugins/salesforce/src/components/Spinner.tsx | 40 + .../src/components/spinner.module.css | 60 + plugins/salesforce/src/constants.ts | 7 + plugins/salesforce/src/debug.ts | 28 + plugins/salesforce/src/globals.css | 94 ++ plugins/salesforce/src/hooks/useCustomCode.ts | 10 + .../salesforce/src/hooks/usePublishInfo.ts | 10 + .../salesforce/src/hooks/useSearchParams.ts | 8 + plugins/salesforce/src/main.tsx | 29 + plugins/salesforce/src/pages/Auth.tsx | 68 + .../salesforce/src/pages/BusinessUnitId.tsx | 82 ++ plugins/salesforce/src/pages/Menu.tsx | 62 + plugins/salesforce/src/pages/Messaging.tsx | 130 ++ plugins/salesforce/src/pages/ObjectSearch.tsx | 136 ++ plugins/salesforce/src/pages/Sync.tsx | 212 +++ .../AccountEngagementFormHandlers.tsx | 71 + .../src/pages/account-engagement/index.tsx | 75 ++ .../src/pages/account/DomainConnection.tsx | 24 + .../salesforce/src/pages/account/index.tsx | 204 +++ plugins/salesforce/src/pages/index.tsx | 14 + .../src/pages/tracking/TrackingMID.tsx | 32 + .../tracking/hooks/useSavedMarketingId.ts | 34 + .../salesforce/src/pages/tracking/index.tsx | 75 ++ .../src/pages/web-form/WebFormFields.tsx | 66 + .../salesforce/src/pages/web-form/index.tsx | 51 + plugins/salesforce/src/router.tsx | 192 +++ plugins/salesforce/src/utils.ts | 90 ++ plugins/salesforce/src/vite-env.d.ts | 1 + plugins/salesforce/tailwind.config.js | 38 + plugins/salesforce/tsconfig.json | 29 + plugins/salesforce/vite.config.ts | 18 + 57 files changed, 5347 insertions(+), 58 deletions(-) create mode 100644 plugins/salesforce/.gitignore create mode 100644 plugins/salesforce/README.md create mode 100644 plugins/salesforce/eslint.config.js create mode 100644 plugins/salesforce/framer.json create mode 100644 plugins/salesforce/index.html create mode 100644 plugins/salesforce/package.json create mode 100644 plugins/salesforce/postcss.config.js create mode 100644 plugins/salesforce/public/salesforce.svg create mode 100644 plugins/salesforce/src/App.tsx create mode 100644 plugins/salesforce/src/PluginError.ts create mode 100644 plugins/salesforce/src/api.ts create mode 100644 plugins/salesforce/src/auth.ts create mode 100644 plugins/salesforce/src/cms.ts create mode 100644 plugins/salesforce/src/components/Button.tsx create mode 100644 plugins/salesforce/src/components/CenteredSpinner.tsx create mode 100644 plugins/salesforce/src/components/CheckboxTextField.tsx create mode 100644 plugins/salesforce/src/components/CopyInput.tsx create mode 100644 plugins/salesforce/src/components/FieldMapper.tsx create mode 100644 plugins/salesforce/src/components/Icons.tsx create mode 100644 plugins/salesforce/src/components/Layout.tsx create mode 100644 plugins/salesforce/src/components/Link.tsx create mode 100644 plugins/salesforce/src/components/Logo.tsx create mode 100644 plugins/salesforce/src/components/Message.tsx create mode 100644 plugins/salesforce/src/components/PageErrorBoundaryFallback.tsx create mode 100644 plugins/salesforce/src/components/ScrollFadeContainer.tsx create mode 100644 plugins/salesforce/src/components/Spinner.tsx create mode 100644 plugins/salesforce/src/components/spinner.module.css create mode 100644 plugins/salesforce/src/constants.ts create mode 100644 plugins/salesforce/src/debug.ts create mode 100644 plugins/salesforce/src/globals.css create mode 100644 plugins/salesforce/src/hooks/useCustomCode.ts create mode 100644 plugins/salesforce/src/hooks/usePublishInfo.ts create mode 100644 plugins/salesforce/src/hooks/useSearchParams.ts create mode 100644 plugins/salesforce/src/main.tsx create mode 100644 plugins/salesforce/src/pages/Auth.tsx create mode 100644 plugins/salesforce/src/pages/BusinessUnitId.tsx create mode 100644 plugins/salesforce/src/pages/Menu.tsx create mode 100644 plugins/salesforce/src/pages/Messaging.tsx create mode 100644 plugins/salesforce/src/pages/ObjectSearch.tsx create mode 100644 plugins/salesforce/src/pages/Sync.tsx create mode 100644 plugins/salesforce/src/pages/account-engagement/AccountEngagementFormHandlers.tsx create mode 100644 plugins/salesforce/src/pages/account-engagement/index.tsx create mode 100644 plugins/salesforce/src/pages/account/DomainConnection.tsx create mode 100644 plugins/salesforce/src/pages/account/index.tsx create mode 100644 plugins/salesforce/src/pages/index.tsx create mode 100644 plugins/salesforce/src/pages/tracking/TrackingMID.tsx create mode 100644 plugins/salesforce/src/pages/tracking/hooks/useSavedMarketingId.ts create mode 100644 plugins/salesforce/src/pages/tracking/index.tsx create mode 100644 plugins/salesforce/src/pages/web-form/WebFormFields.tsx create mode 100644 plugins/salesforce/src/pages/web-form/index.tsx create mode 100644 plugins/salesforce/src/router.tsx create mode 100644 plugins/salesforce/src/utils.ts create mode 100644 plugins/salesforce/src/vite-env.d.ts create mode 100644 plugins/salesforce/tailwind.config.js create mode 100644 plugins/salesforce/tsconfig.json create mode 100644 plugins/salesforce/vite.config.ts diff --git a/package-lock.json b/package-lock.json index c8fae637..f47c305c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,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", @@ -65,6 +326,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", @@ -449,14 +768,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", @@ -511,6 +880,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", @@ -585,6 +977,44 @@ "node": ">= 0.12" } }, + "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", @@ -642,6 +1072,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", @@ -2756,20 +3200,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.61.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.61.5.tgz", + "integrity": "sha512-iG5vqurEOEbv+paP6kW3zPENa99kSIrd1THISJMaTwVlJ+N5yjVDNOUwp9McK2DWqWCXM3v13ubBbAyhxT78UQ==", + "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.61.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.61.5.tgz", + "integrity": "sha512-rjy8aqPgBBEz/rjJnpnuhi8TVkVTorMUsJlM3lMvrRb5wK6yzfk34Er0fnJ7w/4qyF01SnXsLB/QsTBsLF5PaQ==", + "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.56.2" + "@tanstack/query-core": "5.61.5" }, "funding": { "type": "github", @@ -2800,6 +3246,51 @@ "dev": true, "license": "MIT" }, + "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": { + "@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", @@ -3560,6 +4051,26 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@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 || ^6.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", @@ -3573,10 +4084,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" }, @@ -4004,9 +4516,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": [ { @@ -4022,11 +4534,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" @@ -4135,9 +4648,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.30001684", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz", + "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==", "dev": true, "funding": [ { @@ -4152,7 +4665,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -4406,11 +4920,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.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4854,10 +5376,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.67", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.67.tgz", + "integrity": "sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -5536,9 +6059,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.12.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.12.0.tgz", + "integrity": "sha512-gZaZeqFM6pX9kMVti60hYAa75jGpSsGYWAHbBfIkuHN7DkVHVkxSxeNYnrGmHuM0zPkWTzQx10ZT+fDjn7N4SA==", + "license": "MIT", "dependencies": { "tslib": "^2.4.0" }, @@ -5603,6 +6127,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", @@ -6092,6 +6626,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", @@ -6115,6 +6662,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", @@ -7185,9 +7745,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", @@ -7609,9 +8170,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" }, @@ -7649,6 +8211,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", @@ -8195,6 +8767,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", @@ -8905,33 +9481,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", @@ -9207,6 +9784,244 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.16.0.tgz", + "integrity": "sha512-wDkVmlY6O2do4V+lZd0GtRfbtXbeD0q9WygwXXSJnC1xorE8eqyC2L1tJimqpSeFrOzRlYtWnUp/uzgHQOgfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.16.0", + "@typescript-eslint/parser": "8.16.0", + "@typescript-eslint/utils": "8.16.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" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.16.0.tgz", + "integrity": "sha512-5YTHKV8MYlyMI6BaEG7crQ9BhSc8RxzshOReKwZwRWN0+XvvTOm+L/UYLCYxFpfwYuAAqhxiq4yae0CMFwbL7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.16.0", + "@typescript-eslint/type-utils": "8.16.0", + "@typescript-eslint/utils": "8.16.0", + "@typescript-eslint/visitor-keys": "8.16.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.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", + "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.16.0", + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/typescript-estree": "8.16.0", + "@typescript-eslint/visitor-keys": "8.16.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.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.16.0.tgz", + "integrity": "sha512-mwsZWubQvBki2t5565uxF0EYvG+FwdFb8bMtDuGQLdCCnGPrDEDvm1gtfynuKlnpzeBRqdFCkMf9jg1fnAK8sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/visitor-keys": "8.16.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.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.16.0.tgz", + "integrity": "sha512-IqZHGG+g1XCWX9NyqnI/0CX5LL8/18awQqmkZSl2ynn8F76j579dByc0jhfVSnSnhf7zv76mKBQv9HQFKvDCgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.16.0", + "@typescript-eslint/utils": "8.16.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" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.16.0.tgz", + "integrity": "sha512-NzrHj6thBAOSE4d9bsuRNMvk+BvaQvmY4dDglgkgGC0EW/tB3Kelnp3tAKH87GEwzoxgeQn9fNGRyFJM/xd+GQ==", + "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.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.16.0.tgz", + "integrity": "sha512-E2+9IzzXMc1iaBy9zmo+UYvluE3TW7bCGWSF41hVWUE01o8nzr1rvOQYSxelxr6StUvRcTMe633eY8mXASMaNw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/visitor-keys": "8.16.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.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.16.0.tgz", + "integrity": "sha512-C1zRy/mOL8Pj157GiX4kaw7iyRLKfJXBR3L82hk5kS/GyHcOFmy4YUq/zfZti72I9wnuQtA/+xzft4wCC8PJdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.16.0", + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/typescript-estree": "8.16.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" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.16.0.tgz", + "integrity": "sha512-pq19gbaMOmFE3CbL0ZB8J8BFCo2ckfHBfaIsaOZgBIF4EoISJIdLX5xRhd0FGB0LlHReNRuzoJoMGpTjq8F2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.16.0", + "eslint-visitor-keys": "^4.2.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/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" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -9222,9 +10037,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": [ { @@ -9240,9 +10055,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" @@ -11711,6 +12527,271 @@ "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/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) => ( + +) 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 ( +
+ + +
+ ) +} 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) => ( + + +
+
+ +
+ +
{title}
+
+
+
+) + +interface Props { + children: React.ReactNode + className?: string + title?: string + animateForward?: boolean +} + +export const Layout = ({ children, className, title, animateForward }: Props) => ( +
+ {title && } + <PageDivider /> + <div className="col-lg w-full h-full">{children}</div> + </div> +) 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 ( + <Link to={href} className={classNames("framer-blue", className)} onClick={handleClick}> + {children} + </Link> + ) +} + +export const ExternalLink = ({ href, children, className }: LinkProps) => ( + <a href={href} target="_blank" className={classNames("text-salesforce-blue", className)}> + {children} + </a> +) 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 = () => <img src="./salesforce.svg" alt="Salesforce logo" className="w-[30px] h-[30px] rounded-lg" /> 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) => ( + <div className={cx("col items-center justify-center h-full p-[15px] no-scrollbar overflow-y-auto", className)}> + <p className="text-primary">{title}</p> + <div className="text-tertiary text-center max-w-[200px] break-words no-scrollbar overflow-y-auto"> + {children} + </div> + </div> +) 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) => ( + <QueryErrorResetBoundary> + {({ reset }) => ( + <ErrorBoundary + onReset={reset} + fallbackRender={({ resetErrorBoundary, error }) => { + return ( + <div className="flex flex-col h-full"> + <div className="flex-grow overflow-y-auto px-[15px]"> + <Message title={(error instanceof PluginError && error.title) || ""}> + {error.message} + <br /> + <br /> + Please retry or{" "} + <a + href="#" + className="text-framer-blue" + onClick={async e => { + e.preventDefault() + auth.logout().then(() => framer.closePlugin()) + }} + > + logout + </a> + . + </Message> + </div> + <div className="sticky bottom-0 left-0 p-[15px]"> + <hr className="mb-[15px]" /> + <button className="w-full" onClick={resetErrorBoundary}> + Retry + </button> + </div> + </div> + ) + }} + > + {children} + </ErrorBoundary> + )} + </QueryErrorResetBoundary> +) 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<HTMLDivElement>(null) + const scrollTimeout = useRef<NodeJS.Timeout>() + + // 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 ( + <div className="relative w-full" style={{ height: `${height}px`, minHeight: `${height}px` }}> + <div ref={containerRef} className={`h-full w-full no-scrollbar overflow-auto ${className}`}> + {children} + </div> + <div + className="absolute top-0 left-0 right-0 z-10 pointer-events-none transition-opacity duration-300" + style={{ + height: fadeHeight, + background: "linear-gradient(to bottom, var(--framer-color-bg) 0%, transparent 100%)", + opacity: showTopFade ? 1 : 0, + }} + /> + <div + className="absolute bottom-0 left-0 right-0 z-10 pointer-events-none transition-opacity duration-300" + style={{ + height: fadeHeight, + background: "linear-gradient(to top, var(--framer-color-bg) 0%, transparent 100%)", + opacity: showBottomFade ? 1 : 0, + }} + /> + </div> + ) +} 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 ( + <div + className={cx( + className, + spinnerClassNames(size), + inheritColor && styles.buttonWithDepthSpinner, + !inline && styles.centeredStyle + )} + {...rest} + /> + ) +} 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<string, unknown>[]) { + 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<CustomCode | null>(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<PublishInfo>() + + 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( + <React.StrictMode> + <QueryClientProvider client={queryClient}> + <App /> + </QueryClientProvider> + </React.StrictMode> +) 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<number | ReturnType<typeof setInterval>>() + 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 ( + <main> + <div className="col-lg items-center my-auto"> + <Logo /> + <div className="col items-center"> + <h6>Connect to Salesforce</h6> + <p className="text-center max-w-[200px] text-tertiary">{message}</p> + </div> + </div> + <Button className="w-full mt-auto" onClick={login} isLoading={isLoading} variant="secondary"> + Log In + </Button> + </main> + ) +} 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 ( + <main> + <div className="h-full col items-center justify-center"> + <h6>Account Engagement</h6> + <p className="max-w-[200px] text-center text-tertiary"> + Enter your Business Unit ID. Need help? Learn more{" "} + <ExternalLink href="https://help.salesforce.com/s/articleView?id=000381973&type=1"> + here + </ExternalLink> + . + </p> + </div> + <div className="col-lg"> + <input + type="text" + placeholder="Business Unit ID" + value={unitId} + onChange={e => setUnitId(e.target.value)} + className="w-full" + /> + <div className="row"> + <button onClick={handleSkip} className="flex-1"> + Skip + </button> + <Button + onClick={handleAddBusinessUnit} + className="flex-1 framer-button-primary" + isLoading={isLoading} + > + Connect + </Button> + </div> + </div> + </main> + ) +} 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<string, string | number> + onClick?: () => void +} + +const MenuOption = ({ icon, title, to, className, state, onClick }: MenuOptionProps) => { + const [, navigate] = useLocation() + + return ( + <button + className={classNames("h-[110px] w-full tile col items-center justify-center rounded-md", className)} + onClick={() => (onClick ? onClick() : navigate(to, { state }))} + > + {icon} + <p className="font-semibold text-tertiary">{title}</p> + </button> + ) +} + +export default function Menu() { + return ( + <main> + <div className="col-lg items-center pt-[30px] pb-15"> + <Logo /> + <div className="col items-center"> + <h6>Welcome to Salesforce</h6> + <p className="text-center text-tertiary max-w-[200px]"> + View forms, monitor site traffic, embed chats, and much more. + </p> + </div> + </div> + <div className="grid grid-cols-2 gap-2.5"> + <MenuOption + title="Web Forms" + to="/object-search?redirect=/web-form&requiredFields=createable,updateable" + icon={<GlobeIcon />} + state={{ title: "Web Forms" }} + /> + <MenuOption title="MCAE Forms" to="/account-engagement-forms" icon={<FormsIcon />} /> + <MenuOption title="Messaging" to="/messaging" icon={<MessagesIcon />} className="gap-[7px]" /> + <MenuOption title="Tracking" to="/tracking" icon={<ChartsIcon />} /> + <MenuOption + title="Sync" + to="" + icon={<SyncIcon />} + onClick={() => framer.notify("Launch the plugin via the CMS to sync objects")} + /> + <MenuOption title="Account" to="/account" icon={<PersonIcon />} /> + </div> + </main> + ) +} 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) => `<script type='text/javascript'> + function initEmbeddedMessaging() { + try { + embeddedservice_bootstrap.settings.language = '${languageLocaleKey}'; + + embeddedservice_bootstrap.init( + '${orgId.slice(0, -3)}', + '${durableId}', + '${instanceUrl}/${urlPathPrefix}', + { + scrt2URL: '${instanceUrl.replace(".salesforce.com", ".salesforce-scrt.com")}' + } + ); + } catch (err) { + console.error('Error loading Embedded Messaging: ', err); + } + }; +</script> +<script type='text/javascript' src='https://framer2-dev-ed.develop.my.site.com/${urlPathPrefix}/assets/js/bootstrap.min.js' onload='initEmbeddedMessaging()'></script> +` + +export default function Messaging() { + const [copiedIndex, setCopiedIndex] = useState<number | null>(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 <CenteredSpinner /> + + 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 <body> tag on the pages where you want it to appear") + } + + if (embeds.length === 0) { + return ( + <Message title="No Embeds">Create an embedded service in Salesforce to add it to your Framer site"</Message> + ) + } + + return ( + <div className="flex flex-col gap-0 p-[15px]"> + <div className="flex pb-[15px] border-b border-divider"> + <p className="flex-grow">Chat</p> + <p className="w-[62px]">Embed</p> + </div> + <ScrollFadeContainer className="col py-[15px]" height={209}> + {embeds.map((service, i) => { + const name = service.DurableId.replace(/_/g, " ") + + return ( + <div className="row items-center min-h-[30px]" key={i}> + <p + className="flex-1 text-ellipsis overflow-hidden text-nowrap text-primary w-[148px]" + title={name} + > + {name} + </p> + <button + className="w-[62px]" + onClick={() => handleCopyEmbed(i, service.Site, service.DurableId)} + > + {copiedIndex === i ? "Copied" : "Copy"} + </button> + </div> + ) + })} + </ScrollFadeContainer> + <div className="col-lg sticky top-0 left-0"> + <hr /> + <button + className="framer-button-primary" + onClick={() => + window.open( + `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/EmbeddedServiceDeployments/home`, + "_blank" + ) + } + > + View Embeds + </button> + </div> + </div> + ) +} 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<number | null>(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<HTMLInputElement>) => { + // 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 <CenteredSpinner /> + + 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 ( + <div className="flex flex-col gap-0 p-[15px]"> + <div className="relative flex items-center pb-[15px]"> + <SearchIcon className="absolute left-[10px] text-gray-400" /> + <input + type="text" + placeholder="Search objects..." + className="w-full !pl-[30px]" + onChange={e => setSearchQuery(e.target.value)} + value={searchQuery} + onKeyDown={handleKeyDown} + /> + </div> + <ScrollFadeContainer className="col pb-[15px]" height={framer.mode === "canvas" ? 240 : 278}> + {filteredObjects.length > 0 ? ( + filteredObjects.map((object, index) => ( + <button + key={object.name} + className="tile h-[30px] flex items-center justify-between rounded-lg pl-[15px]" + onMouseEnter={() => setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={() => handleNavigateToObject(object)} + > + <p + className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[190px] text-primary font-medium" + title={`${object.label} (${object.name})`} + > + {object.label} ({object.name}) + </p> + {hoveredIndex === index && <IconChevron />} + </button> + )) + ) : ( + <div className="col items-center my-auto"> + <p className="text-primary">No Results</p> + <p className="max-w-[200px] text-tertiary text-center"> + Try using different keywords and search again + </p> + </div> + )} + </ScrollFadeContainer> + <div className="col-lg sticky top-0 left-0"> + <hr /> + <button + className="framer-button-primary" + onClick={() => + window.open( + `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/ObjectManager/home`, + "_blank" + ) + } + > + View Objects + </button> + </div> + </div> + ) +} 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<string, string> => { + 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<string | null>(null) + const [collectionFieldConfig, setCollectionFieldConfig] = useState<ManagedCollectionFieldConfig[]>([]) + const [includedFieldIds, setIncludedFieldIds] = useState(new Set<string>()) + const [fieldNameOverrides, setFieldNameOverrides] = useState<Record<string, string>>({}) + const [, setProgress] = useState<SyncProgress | null>(null) + + useEffect(() => { + if (!pluginContext || !objectConfig) return + + const newIncludedFieldNames = new Set<string>( + 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 <CenteredSpinner size="medium" /> + } + + return ( + <form onSubmit={handleSubmit} className="h-full px-[15px] pb-[15px]"> + <div className="col w-full text-tertiary pt-[15px]"> + <label htmlFor="slugField">Slug Field</label> + <select + className="w-full" + value={slugFieldId ?? ""} + onChange={e => setSlugFieldId(e.target.value)} + required + > + {slugFields.map(field => ( + <option key={field.name} value={field.name}> + {field.label} + </option> + ))} + </select> + </div> + <FieldMapper + collectionFieldConfig={collectionFieldConfig} + fieldNameOverrides={fieldNameOverrides} + isFieldSelected={fieldId => includedFieldIds.has(fieldId)} + onFieldToggle={fieldId => { + setIncludedFieldIds(current => { + const nextSet = new Set(current) + if (nextSet.has(fieldId)) { + nextSet.delete(fieldId) + } else { + nextSet.add(fieldId) + } + return nextSet + }) + }} + onFieldNameChange={(id, value) => { + setFieldNameOverrides(current => ({ + ...current, + [id]: value, + })) + }} + fromLabel="Salesforce Field" + toLabel="Collection Field" + className="pb-[15px] mt-2.5" + /> + <div className="sticky left-0 bottom-0 flex justify-between bg-primary pt-[15px] border-t border-divider items-center max-w-full"> + <Button variant="secondary" className="w-full" isLoading={isSyncing}> + Import {objectLabel} + </Button> + </div> + </form> + ) +} 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<number | null>(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 <CenteredSpinner /> + + if (!handlers) return null + + if (handlers.length === 0) { + return ( + <Message title="No Form Handlers"> + Create a form handler in Account Engagement to add it to your Framer site + </Message> + ) + } + + return ( + <div className="flex flex-col gap-0 p-[15px]"> + <p> + Create a Framer form with field names matching the form handler fields, then add the webhook URL below. + </p> + <ScrollFadeContainer className="col py-[15px]" height={172}> + {handlers.map((form, index) => ( + <div + key={index} + className="bg-tertiary min-h-[30px] row items-center rounded-lg pl-[15px] pr-[15px] font-semibold text-secondary relative" + > + <p className="flex-grow max-w-[190px] text-ellipsis text-nowrap overflow-hidden">{form.name}</p> + <button + className="absolute right-0 w-fit !bg-transparent hover:bg-transparent" + onClick={() => handleCopy(form.url, index)} + > + {copiedIndex === index ? <TickIcon /> : <CopyIcon />} + </button> + </div> + ))} + </ScrollFadeContainer> + <div className="col-lg sticky top-0 left-0"> + <hr /> + <button + className="framer-button-primary" + onClick={() => + window.open( + `${auth.tokens.getOrThrow().instanceUrl}/lightning/page/pardot/form%2Fforms`, + "_blank" + ) + } + > + View Forms + </button> + </div> + </div> + ) +} 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 <CenteredSpinner /> + + if (!forms) return null + + if (forms.length === 0) { + return <Message title="No Forms">Create a form in Account Engagement to add it to your Framer site</Message> + } + + return ( + <main> + <p> + For further customization, use{" "} + <InternalLink href="/account-engagement-forms/handlers">form handlers</InternalLink>. + </p> + <ScrollFadeContainer className="col" height={161}> + {forms.map((form, index) => ( + <Draggable + key={index} + data={{ + type: "componentInstance", + url: "https://framer.com/m/Salesforce-Form-jh2p.js", + attributes: { + controls: { + html: form.embedCode, + }, + }, + }} + > + <button + key={index} + className="min-h-[30px] row items-center rounded-lg pl-[15px] pr-[15px] font-semibold text-secondary relative" + onClick={() => + framer.addComponentInstance({ + url: "https://framer.com/m/Salesforce-Form-jh2p.js", + attributes: { + controls: { + html: form.embedCode, + }, + }, + }) + } + > + <p className="max-w-[190px] text-ellipsis text-nowrap overflow-hidden">{form.name}</p> + </button> + </Draggable> + ))} + </ScrollFadeContainer> + <div className="col-lg sticky top-0 left-0"> + <hr /> + <button + className="framer-button-primary" + onClick={() => + window.open( + `${auth.tokens.getOrThrow().instanceUrl}/lightning/page/pardot/form%2Fforms`, + "_blank" + ) + } + > + View Forms + </button> + </div> + </main> + ) +} 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 ( + <main> + <p> + Adds your domains to the <ExternalLink href="https://google.com">Trusted Sites</ExternalLink> and{" "} + <ExternalLink href="https://google.com">CORS Whitelist</ExternalLink> in your Salesforce Org to ensure + both forms and bots can communicate with Salesforce. + </p> + <button + className="framer-button-primary" + onClick={() => + window.open( + "https://help.salesforce.com/s/articleView?id=sf.security_overview.htm&type=5", + "_blank" + ) + } + > + Learn More + </button> + </main> + ) +} 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 <Button variant="secondary" className="w-[86px]" isLoading disabled /> + } + + 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 ( + <Button + variant={isFullyConnected ? "secondary" : "primary"} + className="w-[86px]" + isLoading={isLoading || isLoadingLocal} + disabled={disabled} + onClick={() => (isFullyConnected ? handleDisconnect() : handleConnect())} + > + {isFullyConnected ? "Disconnect" : "Connect"} + </Button> + ) +} + +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 <CenteredSpinner /> + + if (!user || !org) return null + + return ( + <main> + <h6>User</h6> + <div className="panel-row"> + <span>Email</span> + <p>{user.email}</p> + </div> + <h6>Org</h6> + <div className="col"> + <div className="panel-row"> + <span>Name</span> + <p>{org.Name}</p> + </div> + <div className="panel-row"> + <span>Type</span> + <p>{org.OrganizationType}</p> + </div> + <div className="panel-row"> + <span>BUID</span> + <p>{auth.getBusinessUnitId() || "N/A"}</p> + </div> + </div> + <InternalLink href="/account/domain-connection" className="flex gap-[5px] items-center"> + <h6>Domain</h6> + <InfoIcon /> + </InternalLink> + <div className="col"> + <div className="panel-row"> + <span>Staging</span> + {stagingUrl ? ( + <DomainButton + domain={stagingUrl} + trustedSites={trustedSiteUrls} + corsWhitelist={corsWhitelistUrls} + trustedSitesData={trustedSites} + corsWhitelistData={corsWhitelist} + isLoading={isLoadingTrustedSites || isLoadingCorsWhitelist} + /> + ) : ( + <Button variant="secondary" className="w-[86px]" disabled> + N/A + </Button> + )} + </div> + <div className="panel-row"> + <span>Production</span> + {stagingUrl ? ( + <DomainButton + domain={prodUrl || stagingUrl} + trustedSites={trustedSiteUrls} + corsWhitelist={corsWhitelistUrls} + trustedSitesData={trustedSites} + corsWhitelistData={corsWhitelist} + isLoading={isLoadingTrustedSites || isLoadingCorsWhitelist} + /> + ) : ( + <Button variant="secondary" className="w-[86px]" disabled> + N/A + </Button> + )} + </div> + </div> + <hr /> + <button className="framer-button-destructive w-full" onClick={handleLogout}> + Logout + </button> + </main> + ) +} 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 ( + <main> + <p>Log in to Marketing Cloud and hover over your account name in the upper-right corner.</p> + <form onSubmit={handleSave}> + <input + type="text" + value={inputMarketingId} + onChange={e => setInputMarketingId(e.target.value)} + placeholder="Marketing ID" + className="w-full" + /> + </form> + <button className="framer-button-primary" onClick={handleSave}> + Save + </button> + </main> + ) +} 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<string | null>(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: `<script type="text/javascript" src="https://${mid}.collect.igodigital.com/collect.js"></script>`, + bodyEndHTML: `<script type="text/javascript">_etmc.push(["setOrgId", "${mid}"]);_etmc.push(["trackPageView"]);</script>`, +}) + +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 <CenteredSpinner /> + + return ( + <main> + <p> + By installing the Collect Tracking Code, you can monitor visitor interactions and activities on your + site. Learn more{" "} + <ExternalLink href="https://help.salesforce.com/s/articleView?id=mktg.mc_ctc_collect_code.htm&type=5"> + here + </ExternalLink> + . + </p> + {savedMarketingId ? ( + <button + className={classNames({ "framer-button-primary": !isTrackingEnabled })} + onClick={handleToggleTracking} + > + {isTrackingEnabled ? "Disable" : "Enable"} + </button> + ) : ( + <button onClick={() => navigate("/tracking/mid")} className="framer-button-primary"> + Get Started + </button> + )} + </main> + ) +} 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 <CenteredSpinner /> + + 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 <Message title="No Form Fields">This object does not have any configurable form fields</Message> + } + + return ( + <div className="flex flex-col gap-0 p-[15px]"> + <div className="flex gap-7 pb-2.5"> + <p className="flex-1 text-tertiary">Name</p> + <p className="flex-1 text-tertiary">API Name</p> + </div> + <ScrollFadeContainer className="col pb-[15px]" height={241}> + {availableFormFields?.map((field, i) => ( + <div className="row items-center" key={i}> + <input type="text" className="flex-1 w-full" value={field.label} readOnly /> + <IconChevron /> + <input type="text" className="flex-1 w-full" value={field.name} readOnly /> + </div> + ))} + </ScrollFadeContainer> + <div className="col-lg sticky top-0 left-0"> + <hr /> + <Button + onClick={() => + window.open( + `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/ObjectManager/${objectName}/Details/view`, + "_blank" + ) + } + > + {" "} + View Object + </Button> + </div> + </div> + ) +} 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 ( + <main> + <p> + Create a Framer form with field names matching the object's fields, then add the webhook URL below. See{" "} + <InternalLink + href={`/web-form/fields?objectName=${objectName}&objectLabel=${objectLabel}`} + state={{ title: objectLabel }} + > + available fields + </InternalLink> + . + </p> + <CopyInput + value={isPending ? API_URL : form?.webhook || ""} + isLoading={isPending} + message="Paste the webhook into your Framer Form webhook settings" + /> + <Button + disabled={isPending} + onClick={() => + window.open( + `${auth.tokens.getOrThrow().instanceUrl}/lightning/setup/ObjectManager/${objectName}/Details/view`, + "_blank" + ) + } + > + View Object + </Button> + </main> + ) +} 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<PageProps> + title?: string | (() => string) + children?: Route[] + size?: PluginSize +} + +interface Match { + match: ReturnType<typeof useRoute> + 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: ( + <motion.div {...(animationProps as MotionProps)} className="w-full h-full"> + <Layout title={pageTitle} animateForward={animationDirection === 1}> + <PageErrorBoundaryFallback> + <Element params={params} pluginContext={pluginContext} /> + </PageErrorBoundaryFallback> + </Layout> + </motion.div> + ), + size, + } + } + + return { page: <div>404. This should never happen.</div> } +} + +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 ( + <div + style={{ + width: size?.width ?? DEFAULT_PLUGIN_WIDTH, + height: size?.height ?? DEFAULT_PLUGIN_HEIGHT, + }} + className="absolute top-0 left-0 right-0 overflow-hidden" + > + {children} + </div> + ) +} + +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 ( + <div className="relative w-full h-full overflow-hidden"> + <AnimatePresence> + {page && ( + <SizePreserver size={size} key={location.pathname}> + {cloneElement(page, { key: location.pathname })} + </SizePreserver> + )} + </AnimatePresence> + </div> + ) +} 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<T>(value: T): value is NonNullable<T> { + 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 @@ +/// <reference types="vite/client" /> 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"), + }, + }, +})