diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml new file mode 100644 index 000000000000..bd5b5139bc6b --- /dev/null +++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml @@ -0,0 +1,53 @@ +# This is a duplicate for setupGitForOSBotify except we are using a Github App now for Github Authentication. +# GitHub Apps have higher rate limits. The reason this is being duplicated is because the existing action is still in use +# in open PRs/branches that aren't up to date with main and it ends up breaking action workflows as a result. +name: "Setup Git for OSBotify" +description: "Setup Git for OSBotify" + +inputs: + GPG_PASSPHRASE: + description: "Passphrase used to decrypt GPG key" + required: true + OS_BOTIFY_APP_ID: + description: "Application ID for OS Botify" + required: true + OS_BOTIFY_PRIVATE_KEY: + description: "OS Botify's private key" + required: true + +outputs: + # Do not try to use this for committing code. Use `secrets.OS_BOTIFY_COMMIT_TOKEN` instead + OS_BOTIFY_API_TOKEN: + description: Token to use for GitHub API interactions. + value: ${{ steps.generateToken.outputs.token }} + +runs: + using: composite + steps: + - name: Decrypt OSBotify GPG key + run: cd .github/workflows && gpg --quiet --batch --yes --decrypt --passphrase=${{ inputs.GPG_PASSPHRASE }} --output OSBotify-private-key.asc OSBotify-private-key.asc.gpg + shell: bash + + - name: Import OSBotify GPG Key + shell: bash + run: cd .github/workflows && gpg --import OSBotify-private-key.asc + + - name: Set up git for OSBotify + shell: bash + run: | + git config user.signingkey 367811D53E34168C + git config commit.gpgsign true + git config user.name OSBotify + git config user.email infra+osbotify@expensify.com + + - name: Enable debug logs for git + shell: bash + if: runner.debug == '1' + run: echo "GIT_TRACE=true" >> "$GITHUB_ENV" + + - name: Generate a token + id: generateToken + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a + with: + app_id: ${{ inputs.OS_BOTIFY_APP_ID }} + private_key: ${{ inputs.OS_BOTIFY_PRIVATE_KEY }} diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index b6558b049647..75f46e68fe5a 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -41,15 +41,17 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Set up git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main + uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - name: Get previous app version id: getPreviousVersion uses: Expensify/App/.github/actions/javascript/getPreviousVersion@main with: - SEMVER_LEVEL: 'PATCH' + SEMVER_LEVEL: "PATCH" - name: Fetch history of relevant refs run: | @@ -119,7 +121,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - name: 'Announces a CP failure in the #announce Slack room' + - name: "Announces a CP failure in the #announce Slack room" uses: 8398a7/action-slack@v3 if: ${{ failure() }} with: diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml index ba907334c595..c9c97d5355fb 100644 --- a/.github/workflows/createNewVersion.yml +++ b/.github/workflows/createNewVersion.yml @@ -26,12 +26,18 @@ on: LARGE_SECRET_PASSPHRASE: description: Passphrase used to decrypt GPG key required: true - OS_BOTIFY_TOKEN: - description: Token for the OSBotify user - required: true SLACK_WEBHOOK: description: Webhook used to comment in slack required: true + OS_BOTIFY_COMMIT_TOKEN: + description: OSBotify personal access token, used to workaround committing to protected branch + required: true + OS_BOTIFY_APP_ID: + description: Application ID for OS Botify App + required: true + OS_BOTIFY_PRIVATE_KEY: + description: OSBotify private key + required: true jobs: validateActor: @@ -43,7 +49,7 @@ jobs: id: getUserPermissions run: echo "PERMISSION=$(gh api /repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission | jq -r '.permission')" >> "$GITHUB_OUTPUT" env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} createNewVersion: runs-on: macos-latest @@ -65,18 +71,23 @@ jobs: uses: actions/checkout@v3 with: ref: main - token: ${{ secrets.OS_BOTIFY_TOKEN }} + # The OS_BOTIFY_COMMIT_TOKEN is a personal access token tied to osbotify + # This is a workaround to allow pushes to a protected branch + token: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} - name: Setup git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main + uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - name: Generate version id: bumpVersion uses: Expensify/App/.github/actions/javascript/bumpVersion@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} SEMVER_LEVEL: ${{ inputs.SEMVER_LEVEL }} - name: Commit new version diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2ff67680940..9e0f633ae34e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,16 +9,18 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/staging' steps: + - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + id: setupGitForOSBotify + with: + GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Checkout staging branch uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 with: ref: staging - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - name: Setup git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main - with: - GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + token: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - name: Tag version run: git tag "$(npm run print-version --silent)" @@ -30,16 +32,19 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/production' steps: + - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + id: setupGitForOSBotify + with: + GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + + - uses: actions/checkout@v3 - name: Checkout uses: actions/checkout@v3 with: ref: production - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - name: Setup git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main - with: - GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + token: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - name: Get current app version run: echo "PRODUCTION_VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" @@ -49,7 +54,7 @@ jobs: uses: Expensify/App/.github/actions/javascript/getDeployPullRequestList@main with: TAG: ${{ env.PRODUCTION_VERSION }} - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} IS_PRODUCTION_DEPLOY: true - name: Generate Release Body @@ -64,4 +69,4 @@ jobs: tag_name: ${{ env.PRODUCTION_VERSION }} body: ${{ steps.getReleaseBody.outputs.RELEASE_BODY }} env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index e2323af2486e..f8b68786aaab 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -12,6 +12,19 @@ jobs: outputs: isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) }} steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: main + token: ${{ secrets.OS_BOTIFY_TOKEN }} + + - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + id: setupGitForOSBotify + with: + GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + - name: Validate actor is deployer id: isDeployer run: | @@ -21,13 +34,13 @@ jobs: echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" fi env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - name: Reopen and comment on issue (not a team member) if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }} uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENT: | Sorry, only members of @Expensify/Mobile-Deployers can close deploy checklists. @@ -38,14 +51,14 @@ jobs: id: checkDeployBlockers uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} - name: Reopen and comment on issue (has blockers) if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }} uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENT: | This issue either has unchecked items or has not yet been marked with the `:shipit:` emoji of approval. @@ -70,9 +83,12 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup Git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main + id: setupGitForOSBotify + uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - name: Update production branch run: | @@ -109,9 +125,11 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup Git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main + uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - name: Update staging branch to trigger staging deploy run: | diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 186490c7baaf..d7d372aa7948 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -92,9 +92,11 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup Git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotify@main + uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} + OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - name: Update staging branch from main run: | diff --git a/.storybook/fonts.css b/.storybook/fonts.css index bbbcf3839000..906490c3a9d9 100644 --- a/.storybook/fonts.css +++ b/.storybook/fonts.css @@ -40,6 +40,13 @@ src: url('../assets/fonts/web/ExpensifyMono-Bold.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyMono-Bold.woff') format('woff'); } +@font-face { + font-family: ExpensifyNewKansas-Medium; + font-weight: 400; + font-style: normal; + src: url('../assets/fonts/web/ExpensifyNewKansas-Medium.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyNewKansas-Medium.woff') format('woff'); +} + * { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/README.md b/README.md index 9aad797ebb51..daf9ddfae1ff 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ New Expensify Icon

- + New Expensify

diff --git a/android/app/build.gradle b/android/app/build.gradle index c14f35a72c7b..8e5044fb9527 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001037904 - versionName "1.3.79-4" + versionCode 1001038110 + versionName "1.3.81-10" } flavorDimensions "default" diff --git a/assets/animations/Magician.json b/assets/animations/Magician.json new file mode 100644 index 000000000000..393ad8ad3964 --- /dev/null +++ b/assets/animations/Magician.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":24,"ip":0,"op":69,"w":853,"h":480,"nm":"Comp 1","ddd":0,"assets":[{"id":"comp_0","nm":"Expensify-Magic-Link-120822-kjs-1","fr":24,"layers":[{"ddd":0,"ind":2,"ty":3,"nm":"sparkle 5 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1028,450.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,12.5]},"t":18,"s":[25,25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,13.5]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":23,"s":[100,100,100]},{"t":28,"s":[25,25,100]}],"ix":6,"l":2}},"ao":0,"ip":18,"op":29,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"sparkle 5 Outlines","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-12.47,143.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,23.234],[0,0],[0,-22.979],[0,0]],"o":[[0,23.49],[0,0],[0,-22.723],[0,0]],"v":[[0,-27.575],[-12,1.021],[0,27.575],[12,1.021]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.929411824544,0.560784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1027.956,450.428],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":18,"op":29,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"sparkle 4 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1056,396.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":20,"s":[25,25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":30,"s":[25,25,100]}],"ix":6,"l":2}},"ao":0,"ip":20,"op":31,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"sparkle 4 Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-40.47,197.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,38.984],[0,0],[0,-38.556],[0,0]],"o":[[0,39.413],[0,0],[0,-38.127],[0,0]],"v":[[0,-46.267],[-20.135,1.714],[0,46.267],[20.135,1.714]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.929411824544,0.560784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1056.968,395.412],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":20,"op":31,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":3,"nm":"sparkle 3 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1242,355.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":22,"s":[25,25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":27,"s":[100,100,100]},{"t":32,"s":[25,25,100]}],"ix":6,"l":2}},"ao":0,"ip":22,"op":33,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"sparkle 3 Outlines","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-226.47,238.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,23.234],[0,0],[0,-22.979],[0,0]],"o":[[0,23.49],[0,0],[0,-22.723],[0,0]],"v":[[0,-27.575],[-12,1.021],[0,27.575],[12,1.021]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.929411824544,0.560784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1242.386,355.773],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":22,"op":33,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":3,"nm":"sparkle 2 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1152,138.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":24,"s":[25,25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":29,"s":[100,100,100]},{"t":34,"s":[25,25,100]}],"ix":6,"l":2}},"ao":0,"ip":24,"op":35,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"sparkle 2 Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-136.47,455.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,15.082],[0,0],[0,-14.917],[0,0]],"o":[[0,15.248],[0,0],[0,-14.751],[0,0]],"v":[[0,-17.9],[-7.79,0.663],[0,17.9],[7.79,0.663]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.929411824544,0.560784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1152.535,138.957],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":24,"op":35,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":3,"nm":"sparkle 1 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1120,116.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":26,"s":[25,25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":36,"s":[25,25,100]}],"ix":6,"l":2}},"ao":0,"ip":26,"op":37,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"sparkle 1 Outlines","parent":10,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-104.47,477.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,23.234],[0,0],[0,-22.979],[0,0]],"o":[[0,23.49],[0,0],[0,-22.723],[0,0]],"v":[[0,-27.575],[-12,1.021],[0,27.575],[12,1.021]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.929411824544,0.560784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1120.952,117.61],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":37,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"body all comp","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[966,555.5,0],"to":[0,-2,0],"ti":[0,3,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[966,543.5,0],"to":[0,-3,0],"ti":[0,-0.667,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":39,"s":[966,537.5,0],"to":[0,0.667,0],"ti":[0,-1,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":47,"s":[966,547.5,0],"to":[0,1,0],"ti":[0,0.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":50,"s":[966,543.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[966,543.5,0],"to":[0,2,0],"ti":[0,-2,0]},{"t":68,"s":[966,555.5,0]}],"ix":2,"l":2},"a":{"a":0,"k":[966,543.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":1932,"h":1087,"ip":0,"op":69,"st":0,"ct":1,"bm":0}]},{"id":"comp_1","nm":"body all comp","fr":24,"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"bowtie peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[878,480.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":3,"nm":"head peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[23]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[2]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":51,"s":[0]},{"t":68,"s":[23]}],"ix":10},"p":{"a":0,"k":[852,470.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":3,"nm":"body peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[871,504.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"hand 1 peg","parent":5,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[38]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[-6]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":51,"s":[0]},{"t":68,"s":[38]}],"ix":10},"p":{"a":0,"k":[257,-81.649,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":3,"nm":"arm 1 b peg","parent":6,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[10]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[-7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":51,"s":[0]},{"t":68,"s":[10]}],"ix":10},"p":{"a":0,"k":[-43,-223.649,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":3,"nm":"arm 1 a peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[-1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":51,"s":[0]},{"t":68,"s":[27]}],"ix":10},"p":{"a":0,"k":[810,557.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":3,"nm":"hat hand peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[1018,678.851,0],"to":[0,-2.333,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1018,664.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":39,"s":[1018,651.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":47,"s":[1018,665.183,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":50,"s":[1018,664.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[1018,664.851,0],"to":[0,0,0],"ti":[0,-2.333,0]},{"t":68,"s":[1018,678.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":3,"nm":"arm 2 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[909,524.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,110,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":33,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":39,"s":[101,91,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":47,"s":[102.667,101.333,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":50,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":51,"s":[100,100,100]},{"t":68,"s":[100,110,100]}],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"finger OL Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[91.53,444.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-1.637,-2.817],[-2.045,4.838],[-0.469,2.45]],"o":[[-0.672,4.914],[3.649,6.282],[0.857,-2.027],[0,0]],"v":[[-6.613,-9.515],[-6.333,3.232],[5.984,0.521],[7.969,-6.263]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[996.38,135.455],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[3.909,2.631],[1.728,-0.883],[0.602,-1.676],[-1.618,0.846],[1.006,-4.022],[1.441,-1.398]],"o":[[7.289,-8.787],[-2.536,-1.707],[-2.314,1.145],[1.392,-3.877],[-2.968,1.516],[-0.595,2.382],[0,0]],"v":[[2.596,13.132],[5.688,-11.335],[-0.681,-12.248],[-5.282,-6.033],[-0.681,-12.248],[-6.509,-2.321],[-9.884,3.321]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[978.982,153.671],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"wand Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[91.53,444.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[14.674,-0.821],[-11.753,-8.458],[-14.673,0.765],[11.893,8.458]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1085.197,172.322],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-63.345,-23.263],[-65.734,-14.123],[62.813,23.263],[65.734,14.039]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1007.71,149.825],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"hand 1 Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[91.53,444.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-21.609,3.182],[5.634,3.792],[1.728,-0.882],[0.602,-1.677],[-1.618,0.847],[1.005,-4.022],[0,0],[4.242,4.772],[0,0],[-4.243,-4.773],[0,0],[-1.591,6.363],[-2.968,1.517],[-10.278,-0.664],[-4.033,-6.944],[-2.045,4.838],[2.422,8.333],[11.74,-1.97],[1.747,-3.885],[-0.997,6.54],[0.947,-0.237],[-0.055,0.183],[7.231,2.572],[2.305,-6.158],[0,0],[9.015,-15.908],[0,0],[3.29,5.556],[-0.717,0],[-0.304,0.814],[5.059,-2.024],[-0.179,-11.135],[0,0],[7.424,7.954],[5.57,-3.974],[-4.772,-9.943],[2.386,-10.34],[0,0]],"o":[[18.295,-8.352],[21.245,-3.128],[-2.536,-1.707],[-2.314,1.145],[1.392,-3.878],[-2.968,1.517],[-1.591,6.363],[0,0],[-4.243,-4.773],[0,0],[4.242,4.772],[0,0],[1.005,-4.022],[1.396,-11.442],[10.738,-0.796],[3.649,6.282],[3.428,-8.109],[-2.878,-9.899],[-0.997,6.54],[1.747,-3.885],[0.343,-0.215],[0.947,-0.237],[2.726,-17.866],[-3.944,-1.403],[3.29,5.556],[0,0],[9.015,-15.908],[0,0],[-0.005,-0.065],[0.199,0],[-2.03,-3.429],[-8.499,3.4],[0.266,16.438],[0,0],[-4.417,-4.732],[-5.966,8.749],[0,0],[-2.04,8.837],[0,0]],"v":[[-25.087,48.957],[11.645,60.722],[29.144,28.375],[22.775,27.462],[18.174,33.678],[22.775,27.462],[16.948,37.391],[8.463,46.406],[3.691,33.148],[-9.036,28.375],[3.691,33.148],[8.463,46.406],[16.948,37.391],[22.775,27.462],[29.807,-1.718],[34.522,24.729],[46.839,22.018],[48.109,-7.008],[22.305,-19.003],[18.114,-2.729],[22.305,-19.003],[22.052,-18.621],[22.305,-19.003],[17.146,-62.502],[5.497,-56.095],[8.993,-41.622],[3.16,-3.442],[8.993,-41.622],[5.497,-56.095],[5.845,-57.198],[5.497,-56.095],[-4.794,-59.651],[-5.855,-28.895],[-16.991,-5.562],[-29.452,-31.148],[-44.565,-35.125],[-35.418,-11.263],[-31.838,9.815],[-42.974,25.724]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[955.525,113.959],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"arm 1 b Outlines","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[298.53,311.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.21,-0.14],[0.81,-0.52],[0.18,-0.11],[3.64,-4.45],[-0.57,-1.66],[0,0],[-11.24,5.42],[2.11,2.91],[9.76,-6.85],[0,0],[0,0],[0,0],[0,0],[-0.18,-0.12],[-7.39,0.98],[-5.52,3.52]],"o":[[0,0],[-0.34,0.23],[-0.16,0.11],[-5.11,3.4],[-4.25,5.21],[0.07,0.2],[0,0],[13.11,-6.37],[-3.73,-5.17],[-1.24,1.16],[0,0],[0,0],[0,0],[0,0],[1.32,0.84],[6.98,-0.92],[0,0]],"v":[[8.4,-5.74],[8.08,-5.53],[6.32,-4.38],[5.81,-4.05],[-14.14,11.49],[-15.44,26.3],[-15.33,26.61],[21.45,4.8],[27.14,-16.96],[4.65,-19.76],[2.56,-17.94],[-0.8,-15.88],[-6.25,-12.53],[-34.56,4.87],[-34.28,5.06],[-19.71,8.31],[5.81,-4.05]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[909.989,157.62],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-7.56,19.5]],"o":[[-8.34,20.87],[0,0]],"v":[[-11.015,-18.35],[19.355,-1.15]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[714.004,292.46],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[7.47,11.35],[-7.47,-11.35]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[876.449,178.18],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-5.77,3.6],[-3.19,1.98]],"o":[[0,0],[6.47,-4.02],[3.59,-2.22],[0,0]],"v":[[-21.9,-11.83],[-6.76,11.83],[11.71,0.35],[21.9,-5.97]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[872.649,189.89],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[1.38,-0.97]],"o":[[-2.16,1.13],[0,0]],"v":[[2.725,-1.675],[-2.725,1.675]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[906.464,143.415],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[4.96,-3.12],[2.97,-5.29]],"o":[[0,0],[0,0],[-48.47,28.52],[-4.95,3.12],[0,0]],"v":[[86.36,-55.715],[79.63,-51.565],[61.4,-40.335],[-69.66,39.805],[-86.36,55.715]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[789.349,218.395],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-39.18,24.37]],"o":[[2.16,-7.6],[0,0],[0,0]],"v":[[-66.265,44.795],[-33.455,17.785],[66.265,-44.795]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[799.624,246.515],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[-1.45,1.03],[0,0],[-0.18,-0.12]],"v":[[-14.155,8.605],[14.155,-8.795],[11.825,-7.035],[-13.875,8.795]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[889.584,153.885],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[11.13,15.12],[6.13,-3.2],[0,0],[-1.24,1.16],[-3.73,-5.17],[13.11,-6.37],[0,0],[0.07,0.2]],"o":[[0,0],[-4.16,-7.4],[0,0],[0,0],[9.76,-6.85],[2.11,2.91],[-11.24,5.42],[0,0],[15.06,-9.34]],"v":[[0.16,10.7],[8.12,-13.96],[-10.36,-15.88],[-7,-17.94],[-4.91,-19.76],[17.58,-16.96],[11.89,4.8],[-24.89,26.61],[-25,26.3]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[919.549,157.62],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.11,3.4],[6.98,-0.92],[1.32,0.84],[0,0],[-1.45,1.03],[0,0],[-4.16,-7.4],[0,0],[15.06,-9.34],[-4.25,5.21]],"o":[[-5.52,3.52],[-7.39,0.98],[0,0],[0,0],[0,0],[6.13,-3.2],[11.13,15.12],[0,0],[-0.57,-1.66],[3.64,-4.45]],"v":[[8.545,-6.52],[-16.975,5.84],[-31.545,2.59],[-5.845,-13.24],[-3.515,-15],[1.935,-18.35],[20.415,-16.43],[12.455,8.23],[-12.705,23.83],[-11.405,9.02]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[907.254,160.09],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-48.47,28.52],[0,0],[0,0],[2.16,-7.6],[-8.34,20.87],[-4.95,3.12]],"o":[[0,0],[-39.18,24.37],[0,0],[-7.56,19.5],[2.97,-5.29],[4.96,-3.12]],"v":[[70.48,-66.375],[85.62,-42.715],[-14.1,19.865],[-46.91,46.875],[-77.28,29.675],[-60.58,13.765]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[780.269,244.435],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 11","np":2,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[6.47,-4.02],[0,0],[0,0]],"o":[[0,0],[-5.77,3.6],[0,0],[0,0],[0,0]],"v":[[16.365,5.255],[16.805,5.965],[-1.665,17.445],[-16.805,-6.215],[1.425,-17.445]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.235294132607,0.450980422076,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[867.554,184.275],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 12","np":2,"cix":2,"bm":0,"ix":12,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.52,3.52],[3.64,-4.45],[-0.57,-1.66],[3.59,-2.22],[0,0],[0,0],[0,0],[-7.39,0.98]],"o":[[-5.11,3.4],[-4.25,5.21],[-3.19,1.98],[0,0],[0,0],[0,0],[1.32,0.84],[6.98,-0.92]],"v":[[23.41,-18.335],[3.46,-2.795],[2.16,12.015],[-8.03,18.335],[-8.47,17.625],[-23.41,-5.075],[-16.68,-9.225],[-2.11,-5.975]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.235294132607,0.450980422076,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[892.389,171.905],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 13","np":2,"cix":2,"bm":0,"ix":13,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"bowtie main Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.53,113.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.242,-9.015],[-4.773,-7.955],[-4.242,6.363],[3.712,8.484]],"o":[[-4.242,9.015],[4.773,7.954],[4.242,-6.363],[-3.712,-8.485]],"v":[[-11.268,-11.401],[-11.268,12.462],[11.533,12.992],[12.329,-11.666]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.470588265213,0.019607843137,0.019607843137,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[877.95,481.052],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[25.848,11.048],[-0.8,-0.381],[-2.496,8.679],[-0.771,-0.387],[-9.893,6.974],[-19.633,-37.192]],"o":[[0,0],[-28.422,-12.148],[0,0],[2.532,-8.807],[0,0],[9.894,-6.973],[7.972,15.101]],"v":[[40.384,23.093],[-25.214,32.975],[-31.406,5.761],[-35.599,-11.037],[-19.301,-17.834],[-18.775,-37.05],[45.664,5.018]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.470588265213,0.019607843137,0.019607843137,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[827.939,470.062],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"bowtie OL Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.53,113.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-27.35,2.729],[0.859,-0.12]],"o":[[-1.783,6.781],[0,0],[30.074,-3],[0,0]],"v":[[-45.604,-20.71],[-40.93,-10.215],[17.313,17.98],[31.099,-5.426]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.470588265213,0.019607843137,0.019607843137,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[908.755,488.141],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"body Outlines","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[144.53,89.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.236],[7.029,0],[0,7.236],[-7.029,0]],"o":[[0,7.236],[-7.029,0],[0,-7.236],[7.029,0]],"v":[[12.727,0],[0,13.101],[-12.727,0],[0,-13.101]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[846.796,649.659],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[27.575,-1.06],[39.241,-58.862],[0,0],[18.559,0.53],[0,0]],"o":[[-9.545,48.786],[61.513,-37.12],[0,0],[12.726,-11.666],[0,0]],"v":[[5.966,-70.661],[-51.57,71.72],[43.086,-28.768],[12.86,-37.253],[51.57,-47.859]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.392156892664,0.749019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[900.752,561.391],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[25.454,6.363],[0,0],[-13.257,-4.242],[0,0],[-27.575,-68.937]],"o":[[-23.332,9.545],[0,0],[-11.666,3.712],[0,0],[5.303,-30.757]],"v":[[8.219,-73.975],[-33.673,-43.749],[-3.447,-39.507],[-29.431,-25.719],[25.188,73.974]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.392156892664,0.749019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[823.729,559.402],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-6.989,2.263]],"o":[[0,0],[0,0],[6.11,-0.264],[0,0]],"v":[[-1.027,-21.953],[-8.549,-21.608],[-9.838,21.953],[9.838,18.328]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[885.769,488.549],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.637,3.928],[0,0],[0,0],[0,0]],"o":[[-19.62,-10.075],[0,0],[0,0],[6.06,-1.963]],"v":[[14.302,8.898],[-9.958,-16.158],[-14.302,-20.14],[-3.436,20.14]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[899.044,486.737],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":3,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-18.316,-4.7],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.552,-19.538],[-18.521,4.855],[10.544,20.459],[18.521,-20.459]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[845.696,487.996],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":3,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-7.262,0.313],[0,0]],"o":[[0,0],[5.831,1.496],[0,0],[0,0]],"v":[[-2.512,-21.341],[-10.49,19.577],[9.202,21.624],[10.491,-21.936]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[866.73,488.878],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":3,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.085,4.265],[7.909,0.176],[0,0],[-7.098,3.029]],"o":[[-1.962,0.097],[0,0],[6.609,-0.956],[0.293,-13.519]],"v":[[6.836,-19.572],[-13.953,-20.192],[-6.994,20.192],[13.659,14.389]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[893.493,508.271],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":3,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.252,0.143],[0,0],[-8.447,1.223],[0,0]],"o":[[0,0],[6.939,0.88],[0,0],[-5.282,-0.117]],"v":[[-4.076,-20.348],[-11.633,19.787],[11.633,19.639],[4.674,-20.745]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[874.868,508.824],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":3,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-16.803,-2.13],[0,0]],"o":[[0,0],[0,0],[0,0],[-9.229,-0.251]],"v":[[-2.369,-20.494],[-17.795,12.19],[10.239,20.494],[17.795,-19.641]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[852.996,508.117],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":3,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-6.989,2.263]],"o":[[0,0],[0,0],[6.11,-0.264],[0,0]],"v":[[-1.027,-21.953],[-8.549,-21.608],[-9.838,21.953],[9.838,18.328]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[885.504,526.423],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 11","np":3,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.637,3.928],[0,0],[0,0],[0,0]],"o":[[-13.257,-4.772],[0,0],[0,0],[6.061,-1.962]],"v":[[14.965,11.812],[2.238,-20.535],[-14.965,-19.746],[-4.099,20.535]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[899.442,524.216],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 12","np":3,"cix":2,"bm":0,"ix":12,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-18.316,-4.7],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.552,-19.538],[-18.521,4.855],[10.544,20.459],[18.521,-20.459]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[845.431,525.87],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 13","np":3,"cix":2,"bm":0,"ix":13,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-7.262,0.314],[0,0]],"o":[[0,0],[5.831,1.496],[0,0],[0,0]],"v":[[-2.513,-21.341],[-10.491,19.577],[9.201,21.623],[10.49,-21.937]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[866.465,526.752],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 14","np":3,"cix":2,"bm":0,"ix":14,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[19.185,3.895],[17.567,-37.267],[0,0]],"o":[[0,0],[-2.303,18.353],[0,0],[0,0]],"v":[[32.215,-46.021],[-6.354,-53.937],[-32.214,53.407],[-31.685,53.937]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.235294132607,0.450980422076,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[881.132,577.848],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 15","np":3,"cix":2,"bm":0,"ix":15,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.586,-0.195],[0,0],[-2.303,18.354]],"o":[[0,0],[17.567,-37.266],[-18.496,-3.755]],"v":[[-18.697,-57.703],[-6.898,57.898],[18.698,-50.506]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.235294132607,0.450980422076,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[856.081,574.417],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 16","np":3,"cix":2,"bm":0,"ix":16,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[8.325,-56.011],[61.242,-63.131],[0,0],[-21.136,-95.205],[0,0]],"o":[[1.233,60.226],[66.401,17.452],[0,0],[68.406,-66.816],[0,0]],"v":[[-22.688,-99.111],[-115.379,98.318],[18.147,49.079],[46.972,155.121],[102.158,-89.141]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[823.346,627.574],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 17","np":3,"cix":2,"bm":0,"ix":17,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-84.845,21.741],[0,0]],"o":[[0,0],[-9.545,-33.938],[0,0]],"v":[[-61.513,-3.712],[61.512,33.408],[29.165,-55.149]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[833.083,660.319],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 18","np":3,"cix":2,"bm":0,"ix":18,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-84.845,21.741],[0,0]],"o":[[0,0],[-9.545,-33.938],[0,0]],"v":[[-61.512,-3.712],[61.513,33.408],[29.166,-55.149]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[828.311,683.651],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 19","np":3,"cix":2,"bm":0,"ix":19,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-84.845,21.741],[0,0]],"o":[[0,0],[-9.545,-33.938],[0,0]],"v":[[-61.513,-3.712],[61.512,33.408],[29.165,-55.149]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[820.737,707.104],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 20","np":3,"cix":2,"bm":0,"ix":20,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"arm 1 a Outlines","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[205.53,36.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-0.01,0.02],[10.78,-13.79]],"o":[[0.01,-0.02],[7.37,-17.08],[0,0]],"v":[[11.315,15.68],[11.335,15.63],[-18.705,-1.89]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[722.174,275.22],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.22,-0.36],[-3.9,-12.72],[-9.39,-15.08],[-5.62,-11.22],[10,36.66],[0,0],[8.55,23.77],[-3.54,7.32]],"o":[[-0.23,0.34],[-4.24,6.86],[0,0],[8.35,22.27],[5.63,11.22],[-6.17,-14.33],[0,0],[-5.72,-15.9],[0,0]],"v":[[-58.51,-156.02],[-59.18,-154.97],[-62.08,-125.78],[22.9,104.29],[37.96,144.8],[55.98,112.97],[41.7,77.59],[-25.08,-105.5],[-29.15,-137.4]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[762.639,428.3],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-0.33,0.53]],"o":[[0.26,-0.54],[0,0]],"v":[[-0.44,0.805],[0.44,-0.805]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[733.949,290.045],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.17,-14.33],[5.63,11.22],[8.35,22.27],[0,0],[-4.24,6.86],[0,0],[7.37,-17.08],[0.01,-0.02],[-5.72,-15.9],[0,0]],"o":[[10,36.66],[-5.62,-11.22],[-9.39,-15.08],[-3.9,-12.72],[0,0],[10.78,-13.79],[-0.01,0.02],[-3.54,7.32],[8.55,23.77],[0,0]],"v":[[55.98,119.34],[37.96,151.17],[22.9,110.66],[-62.08,-119.41],[-59.18,-148.6],[-59.17,-148.6],[-29.13,-131.08],[-29.15,-131.03],[-25.08,-99.13],[41.7,83.96]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[762.639,421.93],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":17,"ty":0,"nm":"head comp","parent":2,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[164,124,0],"ix":2,"l":2},"a":{"a":0,"k":[966,543.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":1932,"h":1087,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"bowtie UL Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.53,113.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-27.35,2.728],[0.859,-0.121],[-0.23,8.763],[0.833,-0.134],[7.17,9.331],[29.256,-28.747]],"o":[[0,0],[30.074,-3],[0,0],[0.232,-8.892],[0,0],[-7.169,-9.33],[-11.879,11.673]],"v":[[-38.419,8.467],[19.824,36.664],[33.61,13.256],[42.467,-1.084],[29.274,-12.112],[34.442,-30.062],[-38.019,-9.807]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.470588265213,0.019607843137,0.019607843137,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[906.244,469.458],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":19,"ty":0,"nm":"hat all comp","parent":7,"refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-2,-70,0],"ix":2,"l":2},"a":{"a":0,"k":[966,543.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":1932,"h":1087,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"arm 2 Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[106.53,69.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[5.332,-5.359],[9.05,3.298],[9.71,10.883],[0,0],[0,0],[-17.303,-21.917],[-3.285,-1.932]],"o":[[0,0],[-2.516,1.923],[-6.912,-3.616],[-17.102,-20.681],[0,0],[0,0],[2.955,3.743],[9.98,5.868]],"v":[[63.769,66.125],[60.916,77.854],[47.27,79.31],[22.84,58.58],[-66.248,-66.301],[-47.555,-82.607],[44.316,49.035],[53.851,57.401]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[963.686,595.742],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"head comp","fr":24,"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"eye1 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[882,317.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"eye1 line Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[133.53,276.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.931],[12.639,0],[0,7.931],[-12.639,0]],"o":[[0,7.931],[-12.639,0],[0,-7.931],[12.639,0]],"v":[[22.885,0],[0,14.361],[-22.885,0],[0,-14.361]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[881.778,318.379],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":3,"nm":"lid1 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[884,305.351,0],"to":[0,-1.417,0],"ti":[0,1.417,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":33,"s":[884,296.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[884,296.851,0],"to":[0,1.417,0],"ti":[0,-1.417,0]},{"t":68,"s":[884,305.351,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"lid2 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[883,330.351,0],"to":[0,1.583,0],"ti":[0,-1.583,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":33,"s":[883,339.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[883,339.851,0],"to":[0,-1.583,0],"ti":[0,1.583,0]},{"t":68,"s":[883,330.351,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"eye1 mask Outlines 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[965.53,543.11,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.931],[12.639,0],[0,7.931],[-12.639,0]],"o":[[0,7.931],[-12.639,0],[0,-7.931],[12.639,0]],"v":[[22.885,0],[0,14.361],[-22.885,0],[0,-14.361]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[881.778,318.379],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"lid1 Outlines","parent":3,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[131.53,297.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[912.249,303.77],[912.249,288.04]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.07,8.97],[0.07,-8.97]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[856.169,297.01],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[912.249,288.04],[856.239,288.04]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14.9,-0.97]],"o":[[16.14,-2.68],[0,0]],"v":[[-28.075,1.59],[28.075,-0.62]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[884.174,304.39],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[16.14,-2.68],[0,0]],"o":[[0,0],[-14.9,-0.97],[0,0],[0,0]],"v":[[28.075,-8.97],[28.075,6.76],[-28.075,8.97],[-27.935,-8.97]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,0.831372608858,0.486274539723,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[884.174,297.01],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"eye1 mask Outlines","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[133.53,276.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.931],[12.639,0],[0,7.931],[-12.639,0]],"o":[[0,7.931],[-12.639,0],[0,-7.931],[12.639,0]],"v":[[22.885,0],[0,14.361],[-22.885,0],[0,-14.361]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[881.778,318.379],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"lid2 Outlines","parent":4,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[132.53,254.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[857.199,331.23],[857.199,345.16]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.275,-7.655],[0.275,7.655]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[910.734,337.505],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[911.009,345.16],[857.199,345.16]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-18.19,4.45]],"o":[[17.34,3.32],[0,0]],"v":[[-26.63,-0.97],[26.63,-2.35]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[883.829,332.2],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-18.19,4.45],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[17.34,3.32]],"v":[[26.355,-7.655],[26.905,7.655],[-26.905,7.655],[-26.905,-6.275]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,0.831372608858,0.486274539723,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[884.104,337.505],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":9,"ty":3,"nm":"eyes2 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[887,353.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"eyes2 line Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[128.53,240.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-10.564],[-15.186,0],[0.66,11.554],[11.401,0.265]],"o":[[-7.422,0],[0,7.559],[15.186,0],[0,0],[-11.401,-0.265]],"v":[[-12.699,-16.194],[-26.081,-1.303],[-0.661,16.194],[25.421,-1.963],[11.775,-14.716]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[864.717,353.521],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[7.662,5.572]],"o":[[-12.475,-5.197],[0,0]],"v":[[17.574,7.895],[-17.574,-7.895]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[907.711,359.453],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-4.62,-0.33],[-17.13,-0.5],[-6.19,1.62],[0,-15.19],[2.821,-0.517]],"o":[[0,0],[2.56,0.18],[13.82,0.41],[0,0],[0,11.821],[0,0]],"v":[[-46.715,-13.847],[-39.455,-14.837],[-2.655,-12.857],[31.195,-14.177],[46.715,0.353],[37.521,15.167]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[887.334,351.867],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0.461,-1.103],[0,0]],"v":[[-0.456,0.865],[0.456,-0.865]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[884.222,339.964],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":11,"ty":3,"nm":"lid3 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[885,338.851,0],"to":[0,-1.333,0],"ti":[0,1.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":33,"s":[885,330.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[885,330.851,0],"to":[0,1.333,0],"ti":[0,-1.333,0]},{"t":68,"s":[885,338.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":3,"nm":"lid4 peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[884,372.351,0],"to":[0,1.917,0],"ti":[0,-1.917,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":33,"s":[884,383.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[884,383.851,0],"to":[0,-1.917,0],"ti":[0,1.917,0]},{"t":68,"s":[884,372.351,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"eyes2 mask Outlines 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[965.53,543.11,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[15.18,0],[0,7.56],[-7.42,0],[-11.4,-0.26]],"o":[[0.66,11.55],[-15.19,0],[0,-10.57],[0,0],[11.4,0.27]],"v":[[25.42,-1.965],[-0.66,16.195],[-26.08,-1.305],[-12.7,-16.195],[11.77,-14.725]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[864.719,353.525],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-15.19],[2.82,-0.51],[0,0],[7.56,5.5],[11.4,0.27],[0,0],[-13.03,-0.38],[-6.19,1.62]],"o":[[0,11.82],[0,0],[-12.38,-5.13],[0,0],[-11.4,-0.26],[6.61,0.46],[13.82,0.41],[0,0]],"v":[[41.015,0.13],[31.825,14.94],[31.775,15.06],[-2.895,-0.53],[-16.545,-13.29],[-41.015,-14.76],[-8.355,-13.08],[25.495,-14.4]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[893.034,352.09],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"lid3 Outlines","parent":11,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[130.53,263.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.21,-7.52],[0.21,7.52]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[936.739,328.26],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-0.35,-6.965],[0.35,6.965]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[834.509,327.705],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[936.529,320.74],[834.159,320.74]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-12.57,3.72]],"o":[[4.61,2.63],[0,0]],"v":[[-51.045,-2.415],[51.045,-1.305]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[885.904,337.085],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[4.61,2.63],[0,0]],"o":[[0,0],[-12.57,3.72],[0,0],[0,0]],"v":[[50.975,-9.38],[51.395,5.66],[-50.695,4.55],[-51.395,-9.38]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,0.831372608858,0.486274539723,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[885.554,330.12],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"eyes2 mask Outlines","parent":9,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[128.53,240.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[15.18,0],[0,7.56],[-7.42,0],[-11.4,-0.26]],"o":[[0.66,11.55],[-15.19,0],[0,-10.57],[0,0],[11.4,0.27]],"v":[[25.42,-1.965],[-0.66,16.195],[-26.08,-1.305],[-12.7,-16.195],[11.77,-14.725]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[864.719,353.525],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-15.19],[2.82,-0.51],[0,0],[7.56,5.5],[11.4,0.27],[0,0],[-13.03,-0.38],[-6.19,1.62]],"o":[[0,11.82],[0,0],[-12.38,-5.13],[0,0],[-11.4,-0.26],[6.61,0.46],[13.82,0.41],[0,0]],"v":[[41.015,0.13],[31.825,14.94],[31.775,15.06],[-2.895,-0.53],[-16.545,-13.29],[-41.015,-14.76],[-8.355,-13.08],[25.495,-14.4]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[893.034,352.09],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"lid4 Outlines","parent":12,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[131.53,210.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[935.299,371.24],[935.299,393.04]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[833.059,370],[833.059,393.04]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[25.52,2.41]],"o":[[-25.25,1.79],[0,0]],"v":[[51.12,-0.275],[-51.12,-1.515]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[884.179,371.515],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[833.059,393.04],[935.299,393.04]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-25.25,1.79],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[25.52,2.41]],"v":[[51.12,-10.28],[51.12,11.52],[-51.12,11.52],[-51.12,-11.52]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,0.831372608858,0.486274539723,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[884.179,381.52],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":17,"ty":3,"nm":"head peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[852,470.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"head Outlines","parent":17,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[163.53,123.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[7.544,0],[1.453,-5.983],[0,0],[0,0],[-4.263,0],[0,7.543]],"o":[[-6.43,0],[0,0],[0,0],[2.505,3.06],[7.544,0],[0,-7.544]],"v":[[-0.2,-13.66],[-13.46,-3.228],[-2.757,1.665],[-10.773,8.646],[-0.2,13.66],[13.46,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[876.347,352.424],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[6.014,0],[1.158,-4.769],[0,0],[0,0],[-3.398,0],[0,6.013]],"o":[[-5.125,0],[0,0],[0,0],[1.997,2.439],[6.014,0],[0,-6.014]],"v":[[-0.159,-10.889],[-10.729,-2.574],[-2.198,1.327],[-8.588,6.892],[-0.159,10.889],[10.729,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[893.196,318.379],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[7.268,0],[1.4,-5.764],[0,0],[0,0],[-4.107,0],[0,7.268]],"o":[[-6.194,0],[0,0],[0,0],[2.413,2.948],[7.268,0],[0,-7.268]],"v":[[-0.193,-13.16],[-12.968,-3.11],[-2.657,1.603],[-10.379,8.329],[-0.193,13.16],[12.967,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[921.078,351.558],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-10.564],[-15.186,0],[0.66,11.554],[11.401,0.265]],"o":[[-7.422,0],[0,7.559],[15.186,0],[0,0],[-11.401,-0.265]],"v":[[-12.699,-16.194],[-26.081,-1.303],[-0.661,16.194],[25.421,-1.963],[11.775,-14.716]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[864.717,353.521],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-24.76,10.235]],"o":[[0,0],[0,0]],"v":[[-28.232,-5.364],[28.232,-4.872]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[888.56,409.563],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[18.157,-0.661]],"o":[[0,0],[0,0]],"v":[[14.031,-8.089],[-14.031,8.089]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[804.467,355.684],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[25.09,-11.885]],"o":[[0,0],[0,0]],"v":[[11.555,-9.739],[-12.545,9.739]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[803.642,335.216],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[1.981,-3.631]],"o":[[0.33,9.904],[0,0]],"v":[[13.371,-11.39],[-13.701,11.39]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[802.486,314.417],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[17.497,-13.536]],"o":[[0,0],[0,0]],"v":[[22.449,4.457],[-22.449,6.768]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[801.661,277.772],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[16.506,-4.292]],"o":[[0,0],[0,0]],"v":[[12.875,6.107],[-12.875,-1.815]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[839.627,262.917],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[31.362,0.33]],"o":[[0,0],[0,0]],"v":[[22.449,11.059],[-22.449,-11.059]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[864.717,259.285],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 11","np":2,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[36.645,1.98]],"o":[[0,0],[0,0]],"v":[[22.119,15.351],[-22.119,-15.351]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[884.194,253.013],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 12","np":2,"cix":2,"bm":0,"ix":12,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2.31,2.311]],"o":[[0,0],[0,0]],"v":[[-1.735,3.386],[1.735,-3.386]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[898.305,303.443],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 13","np":2,"cix":2,"bm":0,"ix":13,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.66,1.981]],"o":[[0,0],[0,0]],"v":[[0,4.545],[-0.33,-4.545]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[885.515,299.32],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 14","np":2,"cix":2,"bm":0,"ix":14,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[3.301,1.981]],"o":[[0,0],[0,0]],"v":[[1.815,4.559],[-1.816,-4.559]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[871.485,300.324],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 15","np":2,"cix":2,"bm":0,"ix":15,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[2.971,1.32]],"o":[[0,0],[0,0]],"v":[[2.971,3.961],[-2.971,-3.961]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[861.415,304.678],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 16","np":2,"cix":2,"bm":0,"ix":16,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.931],[12.639,0],[0,7.931],[-12.639,0]],"o":[[0,7.931],[-12.639,0],[0,-7.931],[12.639,0]],"v":[[22.885,0],[0,14.361],[-22.885,0],[0,-14.361]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[881.778,318.379],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 17","np":3,"cix":2,"bm":0,"ix":17,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[-2.121,-6.363],[0,0]],"v":[[3.763,3.519],[-3.763,-3.519]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[889.433,371.079],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 18","np":2,"cix":2,"bm":0,"ix":18,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.778,0],[0,-2.971],[-22.449,0],[0.762,0.011],[0,7.593],[14.525,10.565]],"o":[[-3.611,-0.12],[-7.593,0],[0,2.971],[36.404,0],[0,0],[0,-11.555],[0,0]],"v":[[-23.944,-3.145],[-26.081,-3.301],[-35.984,5.943],[-12.875,17.827],[22.78,18.158],[35.984,11.555],[-19.477,-19.148]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,0.831372608858,0.486274539723,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[909.615,370.705],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 19","np":3,"cix":2,"bm":0,"ix":19,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-4.62,-0.33],[-17.13,-0.5],[-6.19,1.62],[0,-15.19],[0,0],[15.11,7.16],[0,0]],"o":[[0,0],[2.56,0.18],[13.82,0.41],[0,0],[0,15.18],[0,0],[-23.98,-11.84],[0,0]],"v":[[-46.715,-16.04],[-39.455,-17.03],[-2.655,-15.05],[31.195,-16.37],[46.715,-1.84],[36.205,13.11],[11.745,10.2],[-2.655,-14.96]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[887.334,354.06],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 20","np":2,"cix":2,"bm":0,"ix":20,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-15.19],[0,0],[15.11,7.16],[0,0],[0,0],[-6.19,1.62]],"o":[[0,15.18],[0,0],[-23.98,-11.84],[0,0],[13.82,0.41],[0,0]],"v":[[29.475,-2.005],[18.965,12.945],[-5.495,10.035],[-19.895,-15.125],[-19.895,-15.215],[13.955,-16.535]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[904.574,354.225],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 21","np":2,"cix":2,"bm":0,"ix":21,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[4.952,0],[2.972,-3.961],[9.904,0.33],[2.311,-0.99],[-25.751,-0.66],[-4.952,5.282]],"o":[[-2.917,-1.856],[-4.952,0],[0,0],[-9.904,-0.33],[0,0],[13.866,0],[0,0]],"v":[[28.676,-4.312],[17.277,-12.875],[5.062,-5.282],[-8.473,-13.865],[-28.941,-4.953],[3.742,14.195],[28.941,-2.456]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.964705942191,0.552941176471,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[887.852,408.341],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 22","np":3,"cix":2,"bm":0,"ix":22,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[6.086,-16.704],[0,0],[0,0],[0,0],[0,0],[-1.321,23.109],[-5.282,23.77],[-16.506,7.263],[-42.917,-7.263],[0,0],[0.66,-19.808],[5.282,-14.525],[15.846,12.876],[19.808,-25.75],[0,-17.827],[0,0]],"o":[[2.641,13.866],[-7.34,20.143],[10.738,-4.375],[-6.929,8.73],[9.904,0],[-38.296,3.961],[1.32,-23.109],[5.282,-23.769],[16.507,-7.263],[56.123,9.244],[0,0],[-0.66,19.808],[-3.962,-5.282],[-20.799,15.847],[-17.827,19.808],[0,17.827],[0,0]],"v":[[-40.936,47.041],[-29.381,95.241],[-56.607,107.033],[-42.917,91.939],[-60.745,95.241],[-49.519,78.735],[-71.308,10.066],[-85.174,-41.434],[-37.635,-70.486],[34.333,-108.121],[75.271,-55.3],[87.815,-33.512],[64.706,8.746],[59.755,-50.678],[-21.458,-36.812],[-24.43,24.592],[-49.19,48.361]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.819607902976,0.501960784314,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[853.822,328.946],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 23","np":3,"cix":2,"bm":0,"ix":23,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-9.574,0],[-2.963,1.261],[0,2.942],[0,0],[0,0],[2.121,14.848],[28.635,0.531],[4.773,-27.574],[-9.545,-22.272],[-3.182,-18.56],[-29.165,3.712],[0,0]],"o":[[3.631,6.603],[4.515,0],[12.633,-5.373],[0,0],[0,0],[0,0],[-2.121,-14.848],[-28.636,-0.53],[-4.772,27.575],[9.546,22.272],[3.182,18.559],[22.272,-7.955],[0,0]],"v":[[5.827,65.623],[29.266,74.206],[40.403,72.183],[53.036,49.446],[53.036,23.366],[54.499,-29.747],[62.839,-77.156],[18.296,-116.397],[-53.823,-75.035],[-55.414,27.84],[-31.552,98.368],[10.341,110.034],[45.87,68.937]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,0.831372608858,0.486274539723,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[864.03,367.439],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 24","np":3,"cix":2,"bm":0,"ix":24,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"hat all comp","fr":24,"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"hat peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1146,639.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"hat Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-130.47,-45.39,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-51.397,-2.254],[-35.167,1.804],[-4.208,-12.624],[11.722,6.312],[51.998,-27.952],[17.785,7.808]],"o":[[0,0],[32.912,-1.804],[35.166,-1.803],[4.207,-12.924],[-13.358,-7.193],[-37.42,19.387],[-24.646,-10.82]],"v":[[-105.498,-7.89],[-64.02,31.334],[55.906,-11.948],[111.21,12.849],[99.187,-19.612],[-2.705,-3.382],[-84.158,16.756]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1138.248,524.199],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[22.807,1.723],[4.208,-22.242],[-22.843,4.809],[0.013,19.208]],"o":[[1.589,15.834],[32.461,3.005],[-3.388,-28.24],[-48.151,3.419]],"v":[[-54.959,-36.339],[-55.905,33.864],[55.905,32.06],[52.299,-36.868]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1146.664,605.978],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-48.151,3.419],[0,1.308],[-1.202,2.404],[0,0],[-2.062,-20.539]],"o":[[-0.001,-1.235],[0,-43.882],[-17.733,-14.427],[0,0],[22.807,1.723]],"v":[[52.901,35.361],[52.899,31.552],[58.91,-24.353],[-58.911,12.917],[-54.358,35.891]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.470588265213,0.019607843137,0.019607843137,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1146.063,533.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.601,11.421],[0,0],[0,0],[0,0],[-15.177,1.124]],"o":[[-0.601,-11.421],[0,0],[0,0],[15.154,3.966],[24.346,-1.803]],"v":[[38.068,-4.47],[-13.629,-25.81],[-38.669,-17.148],[-38.669,18.529],[10.867,24.685]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1212.592,530.397],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"chains mask Outlines","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[965.53,543.11,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[100.29,-4.91],[-2.12,292.66]],"o":[[0,0],[-88.31,13.51],[-166.77,-119.19],[0,0]],"v":[[318.54,-308.751],[318.54,251.479],[30.15,275.149],[-316.42,-308.751]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1019.409,275.15],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"chains comp","tt":1,"refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[966,543.5,0],"ix":2,"l":2},"a":{"a":0,"k":[966,543.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":1932,"h":1087,"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"hat UL Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-130.47,-45.39,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-29.455,7.514],[-24.346,1.803],[0.601,11.422],[0,0],[0,0],[15.63,5.11],[9.294,-10.223],[-13.225,-16.831]],"o":[[11.722,5.41],[24.346,-1.803],[-0.601,-11.421],[0,0],[0,0],[-15.629,-5.109],[-9.017,9.919],[12.464,15.863]],"v":[[12.624,16.232],[83.407,29.458],[110.608,0.302],[58.911,-21.037],[11.121,-4.506],[-55.304,-26.147],[-100.99,-21.037],[-97.984,14.729]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1140.052,525.625],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":3,"nm":"hat hand peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1018,664.851,0],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":69,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"hat hand Outlines","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-2.47,-70.39,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.064,0.011],[-0.026,-0.03]],"o":[[0.026,0.03],[0.044,-0.054]],"v":[[-0.062,-0.044],[0.018,0.044]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[1169.64,657.822],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[12.461,-1.725],[2.924,-1.272],[7.556,0.795],[-12.329,2.386],[-2.371,0.328],[3.033,2.744],[0,0],[12.727,-12.726],[12.436,0.054],[1.703,0.037],[-16.675,-0.425],[-3.074,-0.072],[2.527,0.064],[1.48,-4.242],[0,0],[-7.954,4.243],[-8.484,0],[-12.328,2.254],[5.802,6.638],[0.026,0.03],[0,0],[-0.052,-0.008],[2.755,0.918],[7.556,-5.568],[-4.59,-1.53],[-2.023,-2.289],[0.044,-0.054],[-14.164,8.291]],"o":[[-2.371,0.328],[-12.329,2.386],[7.556,0.795],[2.924,-1.272],[1.443,-3.61],[-8.352,-7.557],[-42.157,0],[-8.648,8.649],[-1.542,-0.006],[1.707,4.333],[2.518,-0.187],[-3.071,0.204],[-16.871,1.248],[31.352,0.579],[0,0],[7.955,-4.242],[11.666,-0.663],[7.065,-3.042],[-0.027,-0.03],[-0.052,-0.008],[0,0],[-2.023,-2.289],[-4.59,-1.53],[7.556,-5.568],[2.755,0.918],[0.064,0.011],[13.697,0.367],[10.692,-4.678]],"v":[[68.484,-13.673],[60.522,-11.335],[24.728,-9.744],[60.522,-11.335],[68.484,-13.673],[67.681,-24.459],[2.456,-24.459],[-51.633,-1.393],[-80.266,8.505],[-85.135,8.433],[-61.322,18.948],[-52.958,18.759],[-61.322,18.948],[-84.721,30.905],[-28.566,31.485],[-12.127,27.773],[2.721,18.759],[23.932,27.773],[23.323,7.72],[23.242,7.632],[23.137,7.622],[23.242,7.632],[15.978,2.585],[-8.282,3.778],[15.978,2.585],[23.242,7.632],[23.323,7.72],[74.442,-0.995]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.980392216701,0.941176530427,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1146.401,650.213],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-16.87,1.248],[1.707,4.333],[4.238,0.057],[0,0],[0,0],[0,0],[-3.935,-0.073]],"o":[[-16.674,-0.425],[-3.439,-0.074],[0,0],[0,0],[0,0],[4.106,0.092],[1.48,-4.242]],"v":[[17.736,-0.6],[-6.077,-11.115],[-17.538,-11.357],[-17.737,11.112],[-17.648,10.612],[-17.737,11.112],[-5.663,11.357]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.235294132607,0.450980422076,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1067.299,669.856],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[1.789,0],[2.25,0.279],[0,0],[0,0],[0,0],[-0.954,-0.025],[-3.207,-0.072],[0,0]],"o":[[0,0],[-2.54,0],[0,0],[0,0],[0,0],[0.926,0.056],[3.271,0.085],[0,0],[0,0]],"v":[[6.242,-10.771],[1.204,-11.036],[-5.979,-11.47],[-5.955,-11.036],[-5.979,-11.47],[-6.375,11.099],[-3.568,11.236],[6.153,11.47],[6.375,-7.656]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.235294132607,0.450980422076,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1043.387,669.335],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-3.071,0.205],[2.519,-0.186]],"o":[[-3.074,-0.072],[2.527,0.065]],"v":[[4.182,-0.091],[-4.182,0.098]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[1089.195,668.996],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.929,8.298],[-3.661,-1.963],[0,0],[11.954,-9.011],[7.143,2.562]],"o":[[2.819,-6.529],[9.575,5.134],[0,0],[-6.987,-0.305],[-8.284,-2.972]],"v":[[-20.019,-4.573],[-8.329,-12.341],[10.39,-8.266],[9.994,14.303],[-10.531,11.357]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1027.018,666.131],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":3,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":69,"st":0,"ct":1,"bm":0}]},{"id":"comp_4","nm":"chains comp","fr":24,"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"chain peg 12","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-27]},{"i":{"x":[0.659],"y":[1]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-27]},{"i":{"x":[0.815],"y":[1]},"o":{"x":[0.425],"y":[0]},"t":43,"s":[-27]},{"t":45,"s":[-27]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":33,"s":[1150,620.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.659},"o":{"x":0.297,"y":0.297},"t":39,"s":[1150,620.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.425},"t":43,"s":[1150,620.851,0],"to":[0,0,0],"ti":[0,0,0]},{"t":45,"s":[1150,620.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":3,"nm":"chain peg 11","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":30,"s":[-27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-27]},{"i":{"x":[0.659],"y":[1]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-58]},{"i":{"x":[0.815],"y":[1]},"o":{"x":[0.425],"y":[0]},"t":43,"s":[-58]},{"t":45,"s":[-58]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1129,577.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1145,529.851,0],"to":[0,0,0],"ti":[0.198,-39.381,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1144.637,602.079,0],"to":[-0.247,49.104,0],"ti":[0.092,-18.406,0]},{"t":45,"s":[1144,728.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":3,"nm":"chain peg 10","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":27,"s":[-27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-26]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-23]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[-26.992]},{"t":45,"s":[-34]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1105,535.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1106,501.851,0],"to":[0,0,0],"ti":[0.594,-39.974,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1104.911,575.168,0],"to":[-0.74,49.844,0],"ti":[0.277,-18.683,0]},{"t":45,"s":[1103,703.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"chain peg 9","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[-27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[-27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-26]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-5]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-3]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[-12.436]},{"t":45,"s":[-29]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1088,495.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1093,460.851,0],"to":[0,0,0],"ti":[2.771,-40.568,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1087.919,535.257,0],"to":[-3.455,50.585,0],"ti":[1.295,-18.961,0]},{"t":45,"s":[1079,665.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":3,"nm":"chain peg 8","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[-27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[-26]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[22]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[22]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[19.097]},{"t":45,"s":[14]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1089,451.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1095,416.851,0],"to":[0,0,0],"ti":[6.333,-41.36,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1083.385,492.709,0],"to":[-7.896,51.572,0],"ti":[2.96,-19.331,0]},{"t":45,"s":[1063,625.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":3,"nm":"chain peg 7","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[-26]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[22]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[49]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[43]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[48.444]},{"t":45,"s":[58]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1108,412.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1114,376.851,0],"to":[0,0,0],"ti":[6.926,-41.558,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1101.297,453.072,0],"to":[-8.636,51.818,0],"ti":[3.237,-19.423,0]},{"t":45,"s":[1079,586.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":3,"nm":"chain peg 6","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[-26]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[-5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[22]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[49]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[57]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[47]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[60.792]},{"t":45,"s":[85]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[-9.333,11.5,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1108,412.851,0],"to":[9.333,-11.5,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1145,382.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1148,344.851,0],"to":[0,0,0],"ti":[5.343,-43.734,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1138.2,425.064,0],"to":[-6.662,54.533,0],"ti":[2.497,-20.44,0]},{"t":45,"s":[1121,565.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":3,"nm":"chain peg 5","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[-26]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[-5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[22]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[49]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[57]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[37]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[28]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[40.703]},{"t":45,"s":[63]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[-9.333,11.5,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1108,412.851,0],"to":[9.333,-11.5,0],"ti":[-12.5,9.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1145,382.851,0],"to":[12.5,-9.167,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1183,357.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1183,312.851,0],"to":[0,0,0],"ti":[2.968,-48.88,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1177.556,402.501,0],"to":[-3.701,60.948,0],"ti":[1.387,-22.845,0]},{"t":45,"s":[1168,559.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":3,"nm":"chain peg 4","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-26]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[-5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[49]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[57]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[37]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[7]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[0]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[5.807]},{"t":45,"s":[16]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[-9.333,11.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1108,412.851,0],"to":[9.333,-11.5,0],"ti":[-12.5,9.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1145,382.851,0],"to":[12.5,-9.167,0],"ti":[-10.667,10,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1183,357.851,0],"to":[10.667,-10,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1209,322.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1204,274.851,0],"to":[0,0,0],"ti":[0,-52.046,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1204,370.308,0],"to":[0,64.896,0],"ti":[0,-24.325,0]},{"t":45,"s":[1204,537.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":3,"nm":"chain peg 3","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[-26]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[49]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[57]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[37]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-15]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-12]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[-22.525]},{"t":45,"s":[-41]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[-9.333,11.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1108,412.851,0],"to":[9.333,-11.5,0],"ti":[-12.5,9.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1145,382.851,0],"to":[12.5,-9.167,0],"ti":[-10.667,10,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1183,357.851,0],"to":[10.667,-10,0],"ti":[-5.167,13,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1209,322.851,0],"to":[5.167,-13,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1214,279.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1205,231.851,0],"to":[0,0,0],"ti":[-0.989,-52.442,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1206.815,328.034,0],"to":[1.234,65.39,0],"ti":[-0.462,-24.51,0]},{"t":45,"s":[1210,496.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":3,"nm":"chain peg 2","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[-26]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[-5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[49]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[57]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[37]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-15]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-43]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-24]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[-44.687]},{"t":45,"s":[-81]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":3,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[-9.333,11.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1108,412.851,0],"to":[9.333,-11.5,0],"ti":[-12.5,9.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1145,382.851,0],"to":[12.5,-9.167,0],"ti":[-10.667,10,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1183,357.851,0],"to":[10.667,-10,0],"ti":[-5.167,13,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1209,322.851,0],"to":[5.167,-13,0],"ti":[1.167,14,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1214,279.851,0],"to":[-1.167,-14,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1202,238.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1196,189.851,0],"to":[0,0,0],"ti":[2.771,-54.816,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1190.919,290.39,0],"to":[-3.455,68.351,0],"ti":[1.295,-25.62,0]},{"t":45,"s":[1182,466.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":3,"nm":"chain peg","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[-26]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[-5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[49]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[57]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[37]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[-15]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-43]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[-64]},{"i":{"x":[0.659],"y":[0.273]},"o":{"x":[0.297],"y":[0]},"t":39,"s":[-38]},{"i":{"x":[0.815],"y":[0.903]},"o":{"x":[0.425],"y":[0.258]},"t":43,"s":[-64.857]},{"t":45,"s":[-112]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[1150,620.851,0],"to":[-3.5,-7.167,0],"ti":[7.5,14.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":3,"s":[1129,577.851,0],"to":[-7.5,-14.167,0],"ti":[6.833,13.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[1105,535.851,0],"to":[-6.833,-13.667,0],"ti":[2.667,14,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[1088,495.851,0],"to":[-2.667,-14,0],"ti":[-3.333,13.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[1089,451.851,0],"to":[3.333,-13.833,0],"ti":[-9.333,11.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[1108,412.851,0],"to":[9.333,-11.5,0],"ti":[-12.5,9.167,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[1145,382.851,0],"to":[12.5,-9.167,0],"ti":[-10.667,10,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[1183,357.851,0],"to":[10.667,-10,0],"ti":[-5.167,13,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":24,"s":[1209,322.851,0],"to":[5.167,-13,0],"ti":[1.167,14,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":27,"s":[1214,279.851,0],"to":[-1.167,-14,0],"ti":[7.5,11.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":30,"s":[1202,238.851,0],"to":[-7.5,-11.833,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33,"s":[1169,208.851,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.659,"y":0.273},"o":{"x":0.297,"y":0},"t":39,"s":[1178,147.851,0],"to":[0,0,0],"ti":[8.312,-62.534,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.425,"y":0.258},"t":43,"s":[1162.756,262.545,0],"to":[-10.364,77.974,0],"ti":[3.885,-29.227,0]},{"t":45,"s":[1136,463.851,0]}],"ix":2,"l":2},"a":{"a":0,"k":[50,51.351,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":46,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"chain Outlines 12","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"chain Outlines 11","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"chain Outlines 10","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"chain Outlines 9","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"chain Outlines 8","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"chain Outlines 7","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"chain Outlines 6","parent":7,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"chain Outlines 5","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"chain Outlines 4","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":22,"ty":4,"nm":"chain Outlines 3","parent":10,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":23,"ty":4,"nm":"chain Outlines 2","parent":11,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":24,"ty":4,"nm":"chain Outlines","parent":12,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-99.47,283.61,0],"ix":2,"l":2},"a":{"a":0,"k":[965.53,543.11,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[12.068,0.316],[0,0],[0.234,-8.939],[0,0],[-5.422,-1.123],[-0.01,3.203],[0,0],[0,0],[-4.425,-0.116],[0.116,-4.425],[0,0],[0,0],[-6.638,-1.008],[-0.005,3.041]],"o":[[-0.014,-16.549],[0,0],[-16.091,-0.422],[0,0],[0.232,3.969],[6.389,1.4],[0.011,-3.203],[0,0],[0.116,-4.425],[4.425,0.116],[0,0],[-0.047,1.815],[0,0],[6.638,1.008],[0,0]],"v":[[22.284,-10.445],[2.561,-34.443],[2.226,-34.451],[-21.278,-12.255],[-22.284,23.59],[-15.666,33.473],[-7.575,24.328],[-7.552,17.702],[-7.162,-4.729],[1.094,-12.565],[8.929,-4.308],[8.514,18.82],[8.529,24.481],[14.805,32.677],[21.846,22.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.129411764706,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.839215746113,0.027450982262,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1115.086,285.506],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":46,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Expensify-Magic-Link-120822-kjs-1","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[425.5,319,0],"ix":2,"l":2},"a":{"a":0,"k":[966,543.5,0],"ix":1,"l":2},"s":{"a":0,"k":[56,56,100],"ix":6,"l":2}},"ao":0,"w":1932,"h":1087,"ip":0,"op":69,"st":0,"ct":1,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/images/MCCGroupIcons/MCC-Airlines.svg b/assets/images/MCCGroupIcons/MCC-Airlines.svg index 9d7924cff407..b707faf9857e 100644 --- a/assets/images/MCCGroupIcons/MCC-Airlines.svg +++ b/assets/images/MCCGroupIcons/MCC-Airlines.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Commuter.svg b/assets/images/MCCGroupIcons/MCC-Commuter.svg index 2996c9f5f793..d8f808cf463b 100644 --- a/assets/images/MCCGroupIcons/MCC-Commuter.svg +++ b/assets/images/MCCGroupIcons/MCC-Commuter.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Gas.svg b/assets/images/MCCGroupIcons/MCC-Gas.svg index 519882921fb6..b13e657a1af4 100644 --- a/assets/images/MCCGroupIcons/MCC-Gas.svg +++ b/assets/images/MCCGroupIcons/MCC-Gas.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Goods.svg b/assets/images/MCCGroupIcons/MCC-Goods.svg index 2aa86250e9d8..e3ea39f77344 100644 --- a/assets/images/MCCGroupIcons/MCC-Goods.svg +++ b/assets/images/MCCGroupIcons/MCC-Goods.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Groceries.svg b/assets/images/MCCGroupIcons/MCC-Groceries.svg index e957d6ee0238..349154ca5496 100644 --- a/assets/images/MCCGroupIcons/MCC-Groceries.svg +++ b/assets/images/MCCGroupIcons/MCC-Groceries.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Hotel.svg b/assets/images/MCCGroupIcons/MCC-Hotel.svg index 8de897bfafff..04be004b24bb 100644 --- a/assets/images/MCCGroupIcons/MCC-Hotel.svg +++ b/assets/images/MCCGroupIcons/MCC-Hotel.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Mail.svg b/assets/images/MCCGroupIcons/MCC-Mail.svg index 56b4d7bd1005..e554fa44f37f 100644 --- a/assets/images/MCCGroupIcons/MCC-Mail.svg +++ b/assets/images/MCCGroupIcons/MCC-Mail.svg @@ -1,4 +1,7 @@ - - - + + + + diff --git a/assets/images/MCCGroupIcons/MCC-Meals.svg b/assets/images/MCCGroupIcons/MCC-Meals.svg index e8b9eab9d803..df3672cf52a6 100644 --- a/assets/images/MCCGroupIcons/MCC-Meals.svg +++ b/assets/images/MCCGroupIcons/MCC-Meals.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Misc.svg b/assets/images/MCCGroupIcons/MCC-Misc.svg index 8bd292d0568f..a4ef1615d146 100644 --- a/assets/images/MCCGroupIcons/MCC-Misc.svg +++ b/assets/images/MCCGroupIcons/MCC-Misc.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-RentalCar.svg b/assets/images/MCCGroupIcons/MCC-RentalCar.svg index f88d28723569..789cb5bc3fe3 100644 --- a/assets/images/MCCGroupIcons/MCC-RentalCar.svg +++ b/assets/images/MCCGroupIcons/MCC-RentalCar.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Services.svg b/assets/images/MCCGroupIcons/MCC-Services.svg index f4d632e86581..25c67065c105 100644 --- a/assets/images/MCCGroupIcons/MCC-Services.svg +++ b/assets/images/MCCGroupIcons/MCC-Services.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Taxi.svg b/assets/images/MCCGroupIcons/MCC-Taxi.svg index 89d3eb239371..2cc31e4db079 100644 --- a/assets/images/MCCGroupIcons/MCC-Taxi.svg +++ b/assets/images/MCCGroupIcons/MCC-Taxi.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/assets/images/MCCGroupIcons/MCC-Utilities.svg b/assets/images/MCCGroupIcons/MCC-Utilities.svg index 464344b41b4e..27e7290bf4e5 100644 --- a/assets/images/MCCGroupIcons/MCC-Utilities.svg +++ b/assets/images/MCCGroupIcons/MCC-Utilities.svg @@ -1,4 +1,7 @@ - - - + + + + diff --git a/assets/images/eReceiptBGs/eReceiptBG_blue.png b/assets/images/eReceiptBGs/eReceiptBG_blue.png new file mode 100644 index 000000000000..f317b72dc4fc Binary files /dev/null and b/assets/images/eReceiptBGs/eReceiptBG_blue.png differ diff --git a/assets/images/eReceiptBGs/eReceiptBG_green.png b/assets/images/eReceiptBGs/eReceiptBG_green.png new file mode 100644 index 000000000000..55fe8886bca9 Binary files /dev/null and b/assets/images/eReceiptBGs/eReceiptBG_green.png differ diff --git a/assets/images/eReceiptBGs/eReceiptBG_navy.png b/assets/images/eReceiptBGs/eReceiptBG_navy.png new file mode 100644 index 000000000000..2b9616d42c11 Binary files /dev/null and b/assets/images/eReceiptBGs/eReceiptBG_navy.png differ diff --git a/assets/images/eReceiptBGs/eReceiptBG_pink.png b/assets/images/eReceiptBGs/eReceiptBG_pink.png new file mode 100644 index 000000000000..41b6492c3a35 Binary files /dev/null and b/assets/images/eReceiptBGs/eReceiptBG_pink.png differ diff --git a/assets/images/eReceiptBGs/eReceiptBG_tangerine.png b/assets/images/eReceiptBGs/eReceiptBG_tangerine.png new file mode 100644 index 000000000000..00a8cd6dd612 Binary files /dev/null and b/assets/images/eReceiptBGs/eReceiptBG_tangerine.png differ diff --git a/assets/images/eReceiptBGs/eReceiptBG_yellow.png b/assets/images/eReceiptBGs/eReceiptBG_yellow.png new file mode 100644 index 000000000000..7eb9d1f87fa6 Binary files /dev/null and b/assets/images/eReceiptBGs/eReceiptBG_yellow.png differ diff --git a/assets/images/eReceiptIcon.svg b/assets/images/eReceiptIcon.svg index f4fc8c9fcc34..e54c3a106a48 100644 --- a/assets/images/eReceiptIcon.svg +++ b/assets/images/eReceiptIcon.svg @@ -1,3 +1,6 @@ - - + + + diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 900b80ba9030..5c51f16ffc4d 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -203,7 +203,6 @@ During communication with Expensify, you will come across a variety of acronyms - **RBR:** Red Brick Road (UX Design Principle that utilizes red indicators on action items to encourage the user down the optimal path for handling and discovering errors) - **VBA:** Verified Bank Account (Bank account that has been verified as real and belonging to the correct business/individual) - **NAB:** Not a Blocker (An issue that doesn't block progress, but would be nice to not have) -- **LHN:** Left Hand Navigation - **IOU:** I owe you (used to describe payment requests between users) - **OTP:** One-time password, or magic sign-in - **RHP:** Right Hand Panel (on larger screens, pages are often displayed docked to the right side of the screen) diff --git a/contributingGuides/REGRESSION_TEST_BEST_PRACTICES.md b/contributingGuides/REGRESSION_TEST_BEST_PRACTICES.md index 842fa711ba15..f4f591d10e8e 100644 --- a/contributingGuides/REGRESSION_TEST_BEST_PRACTICES.md +++ b/contributingGuides/REGRESSION_TEST_BEST_PRACTICES.md @@ -43,7 +43,7 @@ Example: For the test case steps we're asking to be created by the contributor whose PR solved the bug, it'll fall into a category known as bug fix verification. As such, the steps that should be proposed should contain the action element `Verify` and should be tied to the expected behavior in question. The steps should be broken out by individual actions taking place with the written style of communicating exact steps someone will replicate. As such, simplicity and succinctness is key. -Here are some below examples to illustrate the writing style that covers this: +Below are some examples to illustrate the writing style that covers this: - Bug: White space appears under compose box when scrolling up in any conversation - Proposed Test Steps: - Go to URL https://staging.new.expensify.com/ diff --git a/desktop/ELECTRON_EVENTS.js b/desktop/ELECTRON_EVENTS.js index 6a808bdb99aa..ee8c0521892e 100644 --- a/desktop/ELECTRON_EVENTS.js +++ b/desktop/ELECTRON_EVENTS.js @@ -6,7 +6,7 @@ const ELECTRON_EVENTS = { REQUEST_FOCUS_APP: 'requestFocusApp', REQUEST_UPDATE_BADGE_COUNT: 'requestUpdateBadgeCount', REQUEST_VISIBILITY: 'requestVisibility', - SHOW_KEYBOARD_SHORTCUTS_MODAL: 'show-keyboard-shortcuts-modal', + KEYBOARD_SHORTCUTS_PAGE: 'keyboard-shortcuts-page', START_UPDATE: 'start-update', UPDATE_DOWNLOADED: 'update-downloaded', }; diff --git a/desktop/contextBridge.js b/desktop/contextBridge.js index 3f2748ef05b5..a8b89cdc0b64 100644 --- a/desktop/contextBridge.js +++ b/desktop/contextBridge.js @@ -11,7 +11,7 @@ const WHITELIST_CHANNELS_RENDERER_TO_MAIN = [ ELECTRON_EVENTS.LOCALE_UPDATED, ]; -const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ELECTRON_EVENTS.SHOW_KEYBOARD_SHORTCUTS_MODAL, ELECTRON_EVENTS.UPDATE_DOWNLOADED, ELECTRON_EVENTS.FOCUS, ELECTRON_EVENTS.BLUR]; +const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE, ELECTRON_EVENTS.UPDATE_DOWNLOADED, ELECTRON_EVENTS.FOCUS, ELECTRON_EVENTS.BLUR]; const getErrorMessage = (channel) => `Electron context bridge cannot be used with channel '${channel}'`; diff --git a/desktop/main.js b/desktop/main.js index 36b70b37afc5..f2c11e73e513 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -174,11 +174,11 @@ const manuallyCheckForUpdates = (menuItem, browserWindow) => { * Trigger event to show keyboard shortcuts * @param {BrowserWindow} browserWindow */ -const showKeyboardShortcutsModal = (browserWindow) => { +const showKeyboardShortcutsPage = (browserWindow) => { if (!browserWindow.isVisible()) { return; } - browserWindow.webContents.send(ELECTRON_EVENTS.SHOW_KEYBOARD_SHORTCUTS_MODAL); + browserWindow.webContents.send(ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE); }; // Actual auto-update listeners @@ -330,9 +330,9 @@ const mainWindow = () => { { id: 'viewShortcuts', label: Localize.translate(preferredLocale, `desktopApplicationMenu.viewShortcuts`), - accelerator: 'CmdOrCtrl+I', + accelerator: 'CmdOrCtrl+J', click: () => { - showKeyboardShortcutsModal(browserWindow); + showKeyboardShortcutsPage(browserWindow); }, }, {type: 'separator'}, diff --git a/docs/_config.yml b/docs/_config.yml index dc134d0d2c19..888f0b24a91e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -4,6 +4,7 @@ description: Got a question about receipts, expenses, corporate cards, or anythi url: https://help.expensify.com author: Expensify logo: /assets/images/expensify-help.svg +repository: Expensify/App open_url: true defaults: diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss index a5cc8ae2ff20..e9c56835af50 100644 --- a/docs/_sass/_search-bar.scss +++ b/docs/_sass/_search-bar.scss @@ -88,11 +88,14 @@ $color-gray-label: $color-gray-label; /* All gsc id & class are Google Search relate gcse_0 is the search bar parent & gcse_1 is the search result list parent */ #___gcse_0 { margin-left: 20px; + margin-top: -8px; } /* This input is in #___gcse_0 search bar */ input#gsc-i-id1.gsc-input { background-color: $color-appBG; + padding: 15px 0px 0px !important; + pointer-events: auto; color: #E7ECE9; font-family: "ExpensifyNeue", "Segoe UI Emoji", "Noto Color Emoji" !important; } @@ -102,23 +105,29 @@ input#gsc-i-id1.gsc-input { background-color: $color-appBG; border-bottom: $color-borders 2px solid; border-bottom-left-radius: 0px; - + pointer-events: none; + &:focus-within { border-bottom: $color-accent 2px solid; } } .gsc-input-box .gsib_a { - padding: 5px 9px 4px 0px; + padding: 0px 0px 4px 0px; } .search-icon { margin-left: auto; } +.gsst_b, .gsst_a { + padding: 0px !important; +} /* This is the close icon on search bar */ .gsib_b .gsst_a .gscb_a { color: $color-icons; + padding: 8px 6px 0px 6px !important; + pointer-events: auto; &:hover { color: $color-text; @@ -148,6 +157,7 @@ label.search-label { font-family: "ExpensifyNeue", "Segoe UI Emoji", "Noto Color Emoji"; transform: translateY(-50%); left: 20px; + pointer-events: none; color: $color-gray-label; transform-origin: left top; user-select: none; @@ -181,7 +191,6 @@ label.search-label { /* Change the Google Search Button icon into Expensify icon button */ .gsc-search-button.gsc-search-button-v2 { padding: 10px; - margin-top: -7px; margin-left: 15px; margin-right: 20px; border-radius: 25px; diff --git a/docs/articles/expensify-classic/account-settings/Account-Details.md b/docs/articles/expensify-classic/account-settings/Account-Details.md index 46a6c6ba0c25..8f80a35f3525 100644 --- a/docs/articles/expensify-classic/account-settings/Account-Details.md +++ b/docs/articles/expensify-classic/account-settings/Account-Details.md @@ -1,5 +1,61 @@ --- title: Account Details -description: Account Details +description: The Account Details section of your account is where you can update your profile photo, enable 2FA, and change the email address associated with your Expensify account. --- -## Resource Coming Soon! + +# Overview +The Account Details section of your account is where you can update your profile photo, enable 2FA, and change the email address associated with your account. + +You can have multiple email addresses tied to your account to make it easier to submit expenses or manage your account. Let’s go over how to configure the various account settings located under the Account Details section of your Expensify account. + +# How to add a profile photo +To update your name or add a profile photo, navigate to **Settings** > **Account** > **Account Details.** Under “your profile” you’ll notice a profile picture thumbnail, click “edit photo” underneath to update the profile image. + +# How to enable Two-Factor Authentication +Setting up Two-factor Authentication is one of the best ways to secure your account. This can be enabled individually in your account settings by following **Settings** > **Accounts** > **Account Details** > **Two Factor Authentication** and toggle the switch to **Enabled.** + +Save or download your **Recovery Codes.** It’s important to keep these safe! You WILL lose access to your account if you cannot use your authenticator app and do not have your recovery codes. + +Use your favorite authenticator app to connect to Expensify using the QR code or click the link to enter the secret key manually. + +Once connected, quickly enter the code generated by your app into Expensify before the timeframe runs out! + +# How to manage your devices +You can access your Expensify account on multiple devices, which allows for easy access to your account data. By heading to **Settings** > **Account** > **Account Details** > **Device Management**, you can review the devices that have access to your account. + +From that same place in your account, you can remove any devices that should no longer have access. To do this, select the **Revoke** button next to each device you wish to remove access to your account. + +# How to add a Secondary Login +A Secondary Login is helpful if you have multiple email addresses and don’t necessarily need multiple Expensify accounts. By adding additional emails to your Expensify account, you can use them to forward receipts to receipts@expensify.com and they will be uploaded to your main Expensify account. To get this added to your account, follow these steps: + +1. Log in to your Expensify account through a web browser at www.expensify.com. Please note that this process cannot be completed using the mobile app; it must be done from the website at expensify.com. +2. Navigate to **Settings** > **Account** > **Account Details**. Scroll down to find the 'Secondary Logins' section, then click the 'Add Secondary Login' button. +3. Input the email address or mobile phone number you wish to add, ensuring you include the international code if applicable. +4. You will receive a prompt to enter the Magic Code, which will be sent to the email address you're adding as a secondary login. + +# How to update your email address +Once a Secondary Login is added to your account, you can make it your primary email address. The primary address on an Expensify account is the address that will receive email notifications and updates regarding the account. Any new email addresses must be added as a secondary login before they can be made a primary address. + +1. Log in to your Expensify account through a web browser at www.expensify.com. Please note that this process cannot be completed using the mobile app; it must be done from the website at expensify.com. +2. Navigate to **Settings** > **Account** > **Account Details**. Scroll down to find the 'Secondary Logins' section, then select the **"Make Primary"** button next to the email address. +3. You can keep the old address as a secondary login or delete email addresses by selecting the **"Remove"** button. + + +# Deep Dive +## Managing emails connected to other Expensify accounts +A secondary login can only be added if it is not linked to an existing account. If you have two email addresses with Expensify accounts linked to them, you'll need to merge them instead. + +Alternatively, you can remove a personal email address from a previous work/organization account to use it elsewhere. + +Is your Secondary Login (personal email) validated in your company account? If so, do the following: +1. Navigate to expensify.com +2. Log in using your validated Secondary Login +3. Navigate to **Account** > **Settings** > **Account Details** > **Secondary Logins** +4. Remove your personal email address from the account by clicking the **"Remove"** button next to your email + +Is your Secondary Login (personal email) invalidated in your company account? If so, do the following: +1. Navigate to expensify.com +2. Enter your invalidated secondary login email address +3. You will be presented with a confirmation message saying Expensify sent you an email with a validation link +4. Head to your personal email account and follow the prompts +5. You'll receive a link in the email to click that will unlink the two accounts diff --git a/docs/articles/expensify-classic/account-settings/Merge-Accounts.md b/docs/articles/expensify-classic/account-settings/Merge-Accounts.md index 073c74346d75..7fc355b30bd9 100644 --- a/docs/articles/expensify-classic/account-settings/Merge-Accounts.md +++ b/docs/articles/expensify-classic/account-settings/Merge-Accounts.md @@ -1,5 +1,33 @@ --- title: Merge Accounts -description: Merge Accounts +description: How to merge two Expensify accounts and why this is useful. --- -## Resource Coming Soon! + +# Overview + +Merging accounts allows you to combine two accounts. When you combine two accounts, all receipts, expenses, expense reports, invoices, bills, imported cards, secondary logins, co-pilots, and group policy settings will be combined into one account. +This can be useful if you start off with an account of your own but your organization creates a separate account for you. You can then track both personal and business expenses via one account. + +# How to merge accounts +Merging two accounts together is fairly straightforward. Let’s go over how to do that below: +1. Navigate to [expensify.com](https://www.expensify.com) +2. Log into the account you want to set as the Primary account +3. Navigate to Settings > Account > Account Details +4. Scroll down to the Merge Accounts section and fill in the fields. Once you click Merge, a magic code link will be sent to you via email and you'll be prompted to enter the magic code +5. Copy the magic code, switch back to the expensify.com page, and paste the code into the required field +6. Click Merge Accounts +If you have any questions about this process, feel free to reach out to Concierge for some assistance! + +# FAQ +## Can you merge accounts from the mobile app? +No, accounts can only be merged from the full website at expensify.com. +## Can I administratively merge two accounts together? +No, only the account holder (user) can perform account merging. +## Is merging accounts reversible? +No, merging accounts is not reversible. It is a permanent action that cannot be undone. +## Are there any restrictions on account merging? +Yes! Please see below: +* If your email address belongs to a verified domain (verified in Expensify), you must start the process from the email account under the verified domain. You cannot merge a verified company email account into a personal account. +* If you have two accounts with two different verified domains, you cannot merge them together. +## What happens to my “personal” Individual policy when merging accounts? +The old “personal” Individual policy will be deleted. If you plan to submit reports under a different policy in the future, ensure that any reports on the Individual policy in the old account are marked as Open before merging the accounts. You can typically do this by selecting “Undo Submit” on any submitted reports. diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md index 25d11561755d..741def35581e 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds.md @@ -2,4 +2,117 @@ title: Commercial Card Feeds description: Commercial Card Feeds --- -## Resource Coming Soon! +# Overview +A Commercial Card Feed is a connection that’s established directly between Expensify and your bank. This type of connection is considered the most reliable way to import company card expenses. Commercial Card Feeds cannot be interrupted by common changes on the bank’s side such as updating login credentials or a change in the bank’s UI. + +The easiest way to confirm if your company card program is eligible for a commercial bank feed is to ask your bank directly. If your company uses a commercial card program that isn’t with one of our Approved! Banking Partners (which supports connecting the feed via login credentials), the best way to import your company cards is by setting up a direct Commercial Card Feed between Expensify and your bank. + +# How to set up a Commercial Card Feed +Before setting up a Commercial Card Feed, you’ll want to set up your domain in Expensify. You can do this by going to Settings > Domains > New Domain. + +After the domain is set up in Expensify, you can follow the instructions that correspond with your company’s card program. + +# How to set up a MasterCard Commercial Feed +Your bank will need to access MasterCard's SmartData portal to complete the process. Expensify is a registered vendor in the portal, so neither you, your bank, nor Expensify need to complete any MasterCard forms. (Your bank may have its own form between you and the bank, though.) + +These are the steps you'll need to follow for a MasterCard feed: +- Contact your banking relationship manager and request that your CDF (Common Data File) feed be sent directly to Expensify in the MasterCard SmartData Portal (file type: CDF version 3 Release 11.01). Please also specify the date of the earliest transactions you require in the feed. +- The bank will initiate feed delivery by finding Expensify in MasterCard's online portal. Once this is done, the bank will email you the distribution ID. +- While you're waiting for your bank, make sure to set up a domain in Expensify -- it's required for us to be able to add the card feed to your account! +- Once you have the distribution ID, send it to us using the submission form here. +- We will connect the feed once we receive the file details and notify you when the feed is enabled. + +# How to set up a Visa Commercial Feed +These are the steps you'll need to follow for a Visa feed: +- Contact your banking relationship manager and request that your VCF (Variant Call Format) feed be sent directly to Expensify. Feel free to share this information with them: "There is a check box in your bank's Visa Subscription Management portal that they, or their BPS team, can select to enable the feed. This means there is no need for a test file because Visa already has agreements with 3rd parties who receive the files." +- Ask your bank to send you the "feed filename" OR the raw file information. You'll need the Processor, Financial Institution (Bank), and Company IDs; these are available in Visa Subscription Management if your relationship manager is still looking for them. +- Once you have the file information, send it to us using the submission form here. +- While you're waiting for your bank, make sure to set up a domain -- it's required for us to be able to add the feed to your account! +- We will connect the feed once we receive these details and notify you when the feed is enabled. + +# How to set up an American Express corporate feed +To begin the process, you'll need to fill out Amex's required forms and send them to Amex so they can start processing your feed. You can download the forms here. + +Below are instructions for filling out each page of the Amex form: +- PAGE 1 + - Corporation Name = the legal name of your company on file with American Express + - Corporation Address = the legal address of your company + - Requested Feed Start Date = the date you want transactional data to start feeding into Expensify. If you'd like historical data, be sure to date back as far as you'd prefer. You must put this date in an international date format (i.e., DD/MM/YY or spelled out January 1, 1900) to ensure the correct date. + - Requestor Contact = the name of the individual party completing the request + - Email address = the email address of the individual party completing the request + - Control Account Number = the master or basic control account number corresponding to the cards you'd like to be on the feed. Please note this will not be a credit card number. If you need help with the correct control account number, please contact Amex. +- PAGE 2 + - No information required +- PAGE 3 + - Client Registered Name = the legal name of your company on file with American Express + - Master Control Account or Basic Control Account = same as from page 1; the master or basic control account number corresponding to the cards you'd like to be on the feed. Please note this will not be a credit card number. If you need help with the correct control account number, please contact Amex. +- PAGE 4 + - Country List = the name of the country in which the account for which you're requesting a feed originates + - Client Authorization = please complete your full first and last name, job title, and date (note, put this date in an international date format--i.e., DD/MM/YY). Sign in the area provided. + +Once you've completed the forms, send them to electronictransmissionsteam@aexp.com and indicate you want to set up a Commercial Card feed for your company. You should receive a confirmation message from them within a day or two with contact and tracking information. + +While you're waiting for Amex, make sure to set up a domain -- it's required for us to be able to add the feed to your account. + +Once the feed is complete, Amex will send you a Production Letter. This will have the feed information in it, which will look something like this: +R123456_B123456789_GL1025_001_$DATE$$TIME$_$SEQ$ + +Once you have the filename, send it to us using the submission form here. + +# How to assign company cards +After connecting your company cards with Expensify, you can assign each card to its respective cardholder. +To assign cards go to Settings > Domains > [Your domain] > Company Cards. +If you have more than one card feed, select the correct feed in the drop-down list in the Company Card section. +Once you’ve selected the appropriate feed, click the `Assign New Cards` button to populate the emails and last four digits of the cardholder. +Select the cardholder +You can search the populated list for all employees' email addresses. Please note that the employee will need to have an email address under this Domain in order to assign a card to them. +Select the card +You can search the list using the last 4 digits of the card number. If no transactions have posted on the card then the card number will not appear in the list. You can instead assign the card by typing in the full card number in the field. +Note: if you're assigning a card by typing in the full PAN (the full card number), press the ENTER key on your keyboard after. The field may clear itself after pressing ENTER, but click Assign anyway and then verify that the assignment shows up in the cardholder table. + +## Set the transaction start date (optional) +Any transactions that were posted prior to this date will not be imported into Expensify. If you do not make a selection, it will default to the earliest available transactions from the card. Note: We can only import data for the time period the bank is releasing to us. It's not possible to override the start date the bank has provided via this tool. + +Click the Assign button +Once assigned, you will see each cardholder associated with their card as well as the start date listed. + +If you're using a connected accounting system such as NetSuite, Xero, Intacct, Quickbooks Desktop, or QuickBooks Online, you can also connect the card to export to a specific credit card GL account. + +Go to Settings > Domains > [Domain name] > Company Cards +Click Edit Exports on the right-hand side of the card table and select the GL account you want to export expenses to. +You're all done. After the account is set, exported expenses will be mapped to the specific account selected when exported by a Domain Admin. + +# How to unassign company cards +Before you begin the unassigning process, please note that unassigning a company card will delete any unsubmitted (Open or Unreported) expenses in the cardholder's account. + +If you need to unassign a certain card, click the Actions button associated with the card in question and then click "Unassign." + +To completely remove the card connection, unassign every card from the list and then refresh the page. + +Note: If expenses are Processing and then rejected, they will also be deleted when they're returned to an Open state as the card they're linked to no longer exists. + +# FAQ + +## My Commercial Card feed is set up. Why is a specific card not coming up when I try to assign it to an employee? +Cards will appear in the drop-down when activated and have at least one posted transaction. If the card is activated and has been used for a while and you're still not seeing it, please reach out to your Account Manager or message concierge@expensify.com for further assistance. + +## Is there a fee for utilizing Commercial Card Feeds? +Nope! Commercial Card Feed setup comes at no extra cost and is a part of the Corporate Workspace pricing. + +## What is the difference between Commercial Card feeds and your direct bank connections? +The direct bank connection is a connection set up with your login credentials for that account, while the Commercial Card feed is set up by your bank requesting that Visa/MasterCard/Amex send a daily transaction feed to Expensify. The former can be done without the assistance of your bank or Expensify, but the latter requires effort from your bank and Expensify to get set up. + +## I have a Small Business Amex account. Am I eligible to set up a Commercial Card feed? +If you have a Small Business or Triumph account, you may not be eligible for a Commercial Card feed and will need to use the direct bank connection for American Express Business. + +## What if my bank uses a Commercial Card program that isn't with one of Expensify's Approved! Banking partners? +If your company uses a Commercial Card program that isn’t with one of our Approved! Banking Partners (which supports connecting the feed via login credentials), the best way to import your company cards is by setting up a direct Commercial Card feed between Expensify and your bank. Note the Approved! Banking Partners include: +- Bank of America +- Citibank +- Capital One +- Chase +- Wells Fargo +- Amex +- Stripe +- Brex + diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md index acb29d91e1d8..4fd7ef71c2e7 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Owner.md @@ -1,5 +1,49 @@ --- title: Billing-Owner -description: Billing-Owner +description: The Billing Owner is the person responsible for payment for all usage on a given Workspace --- -## Resource Coming Soon! \ No newline at end of file +# Overview +In Expensify, each Workspace has a Billing Owner. The Billing Owner is the person responsible for payment for all usage on a given Workspace. The Billing Owner is also a Workspace Admin, but it’s important to note that not all Workspace Admins are Billing Owners. +# How to set a billing owner +If you've just created a new Group Workspace, you first need to add a payment card to your account. You can do this by going to the web app's Home page and completing the payment card task. Alternatively, you can add a payment card directly from the Payments page (**Settings > Account > Payment**). +- If you already own a Group Workspace subscription, you can edit your payment card details or manage subscription options within the web app under **Settings > Workspaces > Group > Subscription**. +- If you're an individual Workspace owner, you can activate a new monthly subscription in the web app by going to **Settings > Workspaces > Individual > Subscription** section. +# How to change the Billing Owner +A Group Workspace's Billing Owner is typically the user who initially created the Workspace. However, any Workspace Admin can take over the role of Billing Owner by choosing to "Take Over Billing." +Any Workspace Admin can take over the billing responsibility of a Group Workspace as long as they are already a member of that Workspace. If you wish to become the Billing Owner of a Workspace you're not currently a member of, you need to contact an existing Workspace Admin and ask them to add you to the Group Workspace. +To take over billing: +1. Go to **Settings > Workspaces > Group**. +1. Click on the relevant Workspace name. +1. Click on "Take Over Billing." If you haven't added a payment card to your settings yet, you'll be prompted to do so to complete the transfer. + +That's it! As the new Billing Owner, you will receive a monthly email receipt for the Group Workspaces you now own. +# How to update payment details in Expensify +If you're a policy billing owner, you can change your payment information like your payment card and billing currency. If you are a billing owner using the Expensify Card, your monthly company policy charges will be billed to your Expensify Card. + +To change your payment details: +1. Log in to your account using a web browser or Android app (not available on iOS). +1. Go to **Settings > Account > Payments**. +1. To change your payment card, click "Change Payment Card" in the Payment Details section. +1. To change your billing currency, click "Change Billing Currency" and choose a new currency. You'll need to enter the CVC code of your payment card. You can pay in USD, GBP, NZD, or AUD. +# Deep Dive +## Taking over an existing subscription +If the previous Billing Owner had a 12-month subscription, it will be transferred to your Expensify account. If you already have an annual subscription, the sizes of both subscriptions will be combined. For example, if you have a subscription for 10 users and take over from someone with 50 users, your subscription will now cover 60 users. To take over the Annual Subscription, you need to transfer billing ownership of all Workspaces under the previous Billing Owner's name. + +## Taking over Consolidated Domain Billing +If a Domain Admin has enabled Consolidated Domain Billing (**Settings > Domains > Domain Name > Domain Admins**), all Group Workspaces owned by users with email addresses matching the domain will be billed to the Consolidated Domain Billing owner. You can take over billing for the entire domain by following these steps: + +To take over billing for the entire domain, you must: +1. Ensure you have a linked card on your **Settings > Account > Billing** page. +1. Be designated as the Primary Domain Admin. +1. Go to **Settings > Domains > _Domain Name_ > Domain Admins** and enable Consolidated Domain Billing. + +Currently, Consolidated Domain Billing simply consolidates the amounts due for each Group Workspace Billing Owner (listed on the **Settings > Workspaces > Group** page). If you want to use the Annual Subscription across all Workspaces on the domain, you must also be the Billing Owner of all Group Workspaces. +# FAQ +## Why can't I see the option to take over billing? +There could be two reasons: +1. You may not have the role of Workspace Admin. If you can't click on the Workspace name (if it's not a blue hyperlink), you're not a Workspace Admin. Another Workspace Admin for that Workspace must change your role before you can proceed. +1. Your domain might have Consolidated Domain Billing enabled. Refer to the Deep Dive section to understand how to take over Consolidated Domain Billing. +## What if the current Billing Owner is no longer an employee? +There are two ways to resolve this: +1. Have your IT dept. gain access to the account so that you can make yourself an admin. Your IT department may need to recreate the ex-employee's email address. Once your IT department has access to the employee's Home page, you can request a magic link to be sent to that email address to gain access to the account. +1. Have another admin make you a Workspace admin. diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md index 8ce4283dd17d..f01bb963bacf 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Change-Plan-Or-Subscription.md @@ -1,5 +1,84 @@ --- -title: Change Plan or Subscription -description: Change Plan or Subscription +title: Changing your workspace plan +description: How to change your plan or subscription --- -## Resource Coming Soon! +# Overview +Expensify offers various plans depending on your needs: Track, Submit, Collect, Control, and Free. Your choice of plan depends on whether you want to manage your expenses individually or for a group or company. You may need to upgrade from an individual plan to a group plan if you recently hired additional employees that need should be added to a Group Workspace, or you need access to Expensify's features that are only available on a paid plan. + +# How to change a subscription on an Individual Plan +## Change Individual Plan +### Web +1. Go to **Settings > Workspaces > Individual > [Your Individual Workspace]** +1. Click on **Plan** and select **Switch** under the plan you want to switch to +### Mobile +Open the Expensify app and: +1. Tap the hamburger icon (three lines) on the top left +1. Tap **Settings** +1. Tap **View All** under your Workspace +1. Select the Workspace you want to change under the "Individual" tab +1. Tap **Current Plan** under **Plan** +1. Find the **Switch** option under the plan you're not currently using +## Upgrade to a Group Plan +To upgrade to a group plan, you will need to create a Group Workspace by heading to **Settings > Workspaces > Group** and choosing a Collect or Control plan. + +# How to change a subscription on a Group Plan +## Change Group Plan +## Web +1. Go to **Settings > Workspaces > Group > [Your Group Workspace]** +1. Click on **Plan** and select **Switch** under the plan you want to switch to. + +## Mobile +1. In the Expensify mobile app, navigate to **Settings > Workspaces > [Your Workspace] > Current Plan > Switch**. + +## Adjust subscription size +When you first create a subscription, you can manually set your size by entering a number in the Subscription Size field of your subscription settings by heading to **Settings > Workspaces > Group > Subscription**. + +If you choose not to set a size yourself, it will be calculated automatically for your first bill based on your depending on which scenario below fits your use case: +- If you’ve never had activity in Expensify, your subscription size is set automatically to match the number of active users you had your first month of using Expensify on your Annual Subscription. This means you’ll see the number update automatically after your first billing. +- For existing Workspaces switching to an annual subscription, the subscription size is set to the number of active users on your last month’s billing history. + +## Auto increase subscription size +This feature manages your subscription by automatically increasing the count whenever there is activity that exceeds your subscription size. Whenever your subscription size is increased, you will start a new 12-month commitment for the new subscription size in full. + +To enable automatically increasing your subscription size, head to **Settings > Workspaces > Group > Subscription** and toggle this feature on. + +## Auto renew +By default, your subscription is set to automatically renew after a year. To disable this, head to **Settings > Workspaces > Subscription** and use the toggle to turn this feature off before your current subscription ends. + +If Auto Renew is disabled then the last bill at the annual rate will be issued on the date listed under the Auto Renew settings. + +# How to downgrade to a free account from an Individual Plan +## Web +1. Log in to your account through a web browser. +1. Go to **Settings > Policies > Individual > Subscription**. +1. Click "Cancel Subscription" to end your Monthly Subscription. + +Note: Your subscription is a pre-purchase for 30 days of unlimited SmartScanning. This means that when you cancel, you do not get a refund and instead get to use the remainder of the month of unlimited SmartScanning you purchased. + +## App Store +If you subscribed via iOS, you must cancel your monthly subscription through the App Store by heading to App Store > click on your ID > Subscriptions. You can't cancel it directly in Expensify. + +# How to downgrade to a free account from a Group Plan +## Pay-per-use +If you have a Group Workspace and use Pay-Per-Use billing, you can downgrade by going to **Settings > Workspaces > Group** and clicking the cog button next to your Workspace name, then choosing **Delete**. + +Note: Deleting a Workspace removes its configurations and Workspace members but not their Expensify accounts. + +When deleting your final paid Workspace, if any Workspace members have been active that month (this means anybody who created, edited, submitted, approved, exported, or deleted a report) you will be billed for their activity as part of the downgrade flow. + +## Annual subscription +If you recently started an annual subscription, you can downgrade for a full refund before the second bill. If you meet the criteria below, you can request a refund by going to **Settings > Your Account > Billing** in the web app: +- Own Collect or Control Group Workspaces +- Have only been billed for a single month +- Have not cleared a balance in the past + +Note: Refunds apply to Collect or Control Group Workspaces with one month of billing and no previous balance. + +Once you’ve successfully downgraded to a free Expensify account, your Workspace will be deleted and you will see a refund line item added to your Billing History. + +# FAQ +## Will I be charged for a monthly subscription even if I don't use SmartScans? +Yes, the Monthly Subscription is prepaid and not based on activity, so you'll be charged regardless of usage. + +## I'm on a group policy; do I need the monthly subscription too? +Probably not. Group policy members already have unlimited SmartScans, so there's usually no need to buy the subscription. However, you can use it for personal use if you leave your company's Workspace. diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md index 1ace758978aa..aa08340dd7a6 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription.md @@ -1,5 +1,67 @@ --- title: Individual Subscription -description: Individual Subscription +description: Learn more about managing an Individual Subscription. --- -## Resource Coming Soon! +# Overview +An Individual Subscription is a great option for solo entrepreneurs or anyone who needs to track their own expenses or get paid by someone outside their own organization. +A free Individual Subscription includes: +- Up to 25 SmartScans/month +- Expense tracking +- Mileage tracking +- Invoicing +- Bill splitting +- Receive & send money + +To get unlimited SmartScans, you can upgrade your Individual Subscription for $4.99 (USD per month. + + +# How to sign up for an Individual Subscription +## Website +To activate an Individual Subscription from the web: +1. Log into your Expensify account +2. Navigate to **Settings > Workspaces > Individual** +3. Click **Activate Subscription** under **Monthly** +4. If you don't already have a billing card associated with your account, you will be prompted to add one + +Once payment is complete, you’re all set! + +## Mobile App: +1. Tap **Settings** +2. Under the Workspaces section, select **Free Trial** +3. Select **Upgrade** +4.Tap **Subscription** to upgrade your account + + +# How to manage the subscription +## Web and Android: +When you buy a subscription on the web or through an Android device, you'll be asked to enter your billing information immediately. After the purchase, you can easily view or cancel your subscription anytime by going to **Settings > Workspaces > Individual > Subscription > Show Details**. + +## iOS: +If you purchase a monthly subscription on an iOS device, it will be managed, including cancellations, through the App Store rather than within the Expensify app. You can learn how to manage App Store Subscriptions here. + +After purchasing the subscription from the App Store, remember to sync your app by: +1. Log into the Expensify mobile app +2. Click the three bars in the upper left corner +3. Scroll to **Settings** +4. Select **Sync Account** + +The subscription renewal date is the same as the purchase date. For instance, if you sign up for the subscription on September 7th, it will renew automatically on October 7th. You can cancel your subscription anytime during the month if you no longer need unlimited SmartScans. If you do cancel, keep in mind that your subscription (and your ability to SmartScan) will continue until the last day of the billing cycle. + + +# FAQ +## Can I use an Individual Subscription while on a Collect or Control Plan? +You can! If you want to track expenses separately from your organization’s Workspace, you can sign up for an Individual Subscription. However, only Submit and Track Workspace plans are available when on an Individual Subscription. Collect and Control Workspace plans require an annual or pay-per-use subscription. For more information, visit expensify.com/pricing. + +## Can I cancel an Individual Subscription anytime? +Yep! You can cancel an Individual Subscription anytime. + +## How do I cancel my subscription? +Follow the steps below to cancel a Monthly Subscription started via the website or Android app: +1. Log into your account using your preferred web browser (ex: Firefox, Chrome, Safari) +2. Navigate to **Settings > Workspace > Individual > Subscriptions** +3. Click the **Cancel Subscription** button to cancel your Monthly Subscription + +Your subscription is a pre-purchase for 30 days of unlimited SmartScanning. This means when you cancel you do not get a refund and instead get to use the remainder of the month of unlimited SmartScanning you purchased. + +## How can I cancel my subscription from the iOS app? +If you signed up for the Monthly Subscription via iOS and your iTunes account, you will need to log into iTunes and locate the subscription there in order to cancel it. The ability to cancel an Expensify subscription started via iOS is strictly limited to your iTunes account. diff --git a/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md b/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md index 3f2e49952c4a..795a895e81f0 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Expense-Types.md @@ -1,5 +1,42 @@ --- title: Expense Types -description: Expense Types +description: Details of the different Expense filters and Expense Types --- -## Resource Coming Soon! + +# Overview +Expense types help categorize different expenses for better financial management. While reimbursable and non-reimbursable expenses are common, Expensify offers various other options to suit your needs. Let's explore the available expense types. + +# How To +## Filtering a Report by Expense Type +Organizing a report by expense type can make it easier to review expenses on a report. +- Open the report you're interested in. +- Click the **Details** icon in the upper right corner of the report, +- Change the “View” to **Detailed** and “Split by” **Reimbursable** or **Billable**. +- You’ll also see the option to **Group by Category** or **Tags**. + + +# Deep Dive +Each report will show the total amount for all expenses in the upper right. Under that total, there will be a breakdown of amounts that are reimbursable, billable, and non-reimbursable (depending on which of those expense types exist on the report). + +## Expense Types +- **Reimbursable Expenses:** Employees pay for these expenses out of their pockets on behalf of the business and are usually reimbursed. They often come from cash, debit cards, or personal credit card purchases. +- **Non-reimbursable Expenses:** The business directly covers these expenses, so there's no need to reimburse the employee. Typically, these expenses are company card expenses. +- **Billable Expenses:** Business or employee expenses must be billed to a specific client or vendor. Choose this option if you need to track expenses for invoicing to customers, clients, or other departments. +- **Per Diem Expenses:** These expenses involve a daily or partial daily rate you can configure in your expense Workspace. +- **Time Expenses:** Employees or jobs are billed based on an hourly rate that you can set within Expensify. +- **Distance Expenses:** These expenses are related to travel for work. + +# FAQ + +## What’s the difference between a receipt, an expense, and a report attachment? + +- **Expense:** Created when you SmartScan or manually upload a receipt from a purchase. +- **Receipt:** Automatically attached to the expense during the SmartScan process. +- **Report Attachments:** Additional documents that need to be submitted to your approver (e.g., supplemental documents to the purchase) can be added to a report anytime by clicking the paperclip icon in the Reports Comments. + +## How are credits or refunds displayed in Expensify? +In Expensify, a credit is displayed as an expense with a minus (ex. -$1.00) in front of it. That’s because Expensify defaults all expenses as something that needs to be paid by the company. So a credit that is returned to the company is displayed as a negative expense. + +If a report includes a credit or a refund expense, it will offset the total amount on the report. +For example, the report has two reimbursable expenses, $400 and $500. The total Reimbursable is $900. +Conversely, a -$400 and $500 will be a total Reimbursable amount of $500 diff --git a/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md b/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md index e72abfcad51a..ff9e2105ffac 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md +++ b/docs/articles/expensify-classic/expense-and-report-features/The-Reports-Page.md @@ -1,5 +1,43 @@ --- title: The Reports Page -description: The Reports Page +description: Details about the Reports Page filters and CSV export options --- -## Resource Coming Soon! + +## How to use the Reports Page +The Reports page is your central hub for a high-level view of a Reports' status. You can see the Reports page on a web browser when you sign into your Expensify account. +Here, you can quickly see which reports need submission (referred to as **Open**), which are currently awaiting approval (referred to as **Processing**), and which reports have successfully been **Approved** or **Reimbursed**. +To streamline your experience, we've incorporated user-friendly filters on the Reports page. These filters allow you to refine your report search by specific criteria, such as dates, submitters, or their association with a workspace. + +## Report filters +- **Reset Filters/Show Filters:** You can reset or display your filters at the top of the Reports page. +- **From & To:** Use these fields to refine your search to a specific date range. +- **Report ID, Name, or Email:** Narrow your search by entering a Report ID, Report Name, or the submitter's email. +- **Report Types:** If you're specifically looking for Bills or Invoices, you can select this option. +- **Submitters:** Choose between "All Submitters" or enter a specific employee's email to view their reports. +- **Policies:** Select "All Policies" or specify a particular policy associated with the reports you're interested in. + +## Report status +- **Open icon:** These reports are still "In Progress" and must be submitted by the creator. If they contain company card expenses, a domain admin can submit them. If labeled as “Rejected," an Approver has rejected it, typically requiring some adjustments. Click into the report and review the History for any comments from your Approver. +- **Processing icon:** These reports have been submitted for Approval but have not received the final approval. +- **Approved icon:** Reports in this category have been Approved but have yet to be Reimbursed. For non-reimbursable reports, this is the final status. +- **Reimbursed icon:** These reports have been successfully Reimbursed. If you see "Withdrawing," it means the ACH (Automated Clearing House) process is initiated. "Confirmed" indicates the ACH process is in progress or complete. No additional status means your Admin is handling reimbursement outside of Expensify. +- **Closed icon:** This status represents an officially closed report. + + +## How to Export a report to a CSV +To export a report to a CSV file, follow these steps on the Reports page: + +1. Click the checkbox on the far left of the report row you want to export. +2. Navigate to the upper right corner of the page and click the "Export to" button. +3. From the drop-down options that appear, select your preferred export format. + +# FAQ +## What does it mean if the integration icon for a report is grayed out? +If the integration icon for a report appears grayed out, the report has yet to be fully exported. +To address this, consider these options: +- Go to **Settings > Policies > Group > Connections** within the workspace associated with the report to check for any errors with the accounting integration (i.e., The connection to NetSuite, QuickBooks Online, Xero, Sage Intacct shows an error). +- Alternatively, click the “Sync Now" button on the Connections page to see if any error prevents the export. + +## How can I see a specific expense on a report? +To locate a specific expense within a report, click on the Report from the Reports page and then click on an expense to view the expense details. + diff --git a/docs/articles/expensify-classic/getting-started/Invite-Employees.md b/docs/articles/expensify-classic/getting-started/Invite-Employees.md deleted file mode 100644 index 73dc7b8274f0..000000000000 --- a/docs/articles/expensify-classic/getting-started/Invite-Employees.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Invite Employees -description: Invite Employees ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Invite-Members.md b/docs/articles/expensify-classic/getting-started/Invite-Members.md new file mode 100644 index 000000000000..5b3c17c2e8fb --- /dev/null +++ b/docs/articles/expensify-classic/getting-started/Invite-Members.md @@ -0,0 +1,62 @@ +--- +title: Invite Members +description: Learn how add your employees to submit expenses in Expensify +--- +# Overview + +To invite your employees to Expensify, simply add them as members to your Workspace. + +# How to Invite members to Expensify + +## Inviting Members Manually + +Navigate to **Settings > Workspace > Group > *Workspace Name* > People** - then click **Invite** and enter the invitee's email address. + +Indicate whether you want them to be an Employee, Admin, or Auditor on the Workspace. + +If you are utilizing the Advanced Approval feature and the invitee is an approver, you can use the "Approves to" field to specify to whom they approve and forward reports for additional approval. + +## Inviting Members to a Workspace in Bulk + +Navigate to **Settings > Workspaces > Group > *Workspace Name* > People** - then click Invite and enter all of the email addresses separated by comma. Indicate whether you want them to be an Employee, Admin, or Auditor on the Workspace. + +If you are utilizing the Advanced Approval feature, you can specify who each member should submit their expense reports to and who an approver should send approved reports to for the next step in the approval process. If someone is the final approver, you can leave this field blank. + +Another convenient method is to employ the spreadsheet bulk upload option for inviting members to a Workspace. This proves particularly helpful when initially configuring your system or when dealing with numerous member updates. Simply click the "Import from Spreadsheet" button and upload a file in formats such as .csv, .txt, .xls, or .xlsx to streamline the process. + +After uploading the spreadsheet, we'll display a window where you can choose which columns to import and what they correspond to. These are the fields: +- Email +- Role +- Custom Field 1 +- Custom Field 2 +- Submits To +- Approves To +- Approval Limit +- Over Limit Forward To + +Click the **Import** button and you're done. We will import the new members with the optional settings and update any already existing ones. + +## Inviting Members with a Shareable Workspace Joining Link + +You have the ability to invite your colleagues to join your Expensify Workspace by sharing a unique Workspace Joining Link. You can use this link as many times as necessary to invite multiple members through various communication methods such as internal emails, chats, text messages, and more. + +To find your unique link, simply go to **Settings > Workspace > Group > *Workspace Name* > People**. + +## Allowing Members to Automatically Join Your Workspace + +You can streamline the process of inviting colleagues to your Workspace by enabling the Pre-approve switch located below your Workspace Joining Link. This allows teammates to automatically become part of your Workspace as soon as they create an Expensify account using their work email address. + +Here's how it works: If a colleague signs up with a work email address that matches the email domain of a company Workspace owner (e.g., if the Workspace owner's email is admin@expensify.com and the colleague signs up with employee@expensify.com), they will be able to join your Workspace seamlessly without requiring a manual invitation. When new members join the Workspace, they will be set up to submit their expense reports to the Workspace owner by default. + +To enable this feature, go to **Settings > Workspace > Group > *Workspace Name* > People**. + + +# FAQ +## Who can invite members to Expensify +Any Workspace Admin can add members to a Group Workspace using any of the above methods. + +## How can I customize an invite message? +Under **Settings > Workspace > Group > *Workspace Name* > People > Invite** you can enter a custom message you'd like members to receive in their invitation email. + +## How can I invite members via the API? +If you would like to integrate an open API HR software, you can use our [Advanced Employee Updater API](https://integrations.expensify.com/Integration-Server/doc/employeeUpdater/) to invite members to your Workspace. diff --git a/docs/articles/expensify-classic/getting-started/Plan-Types.md b/docs/articles/expensify-classic/getting-started/Plan-Types.md index f0323947ee12..90c632ffa5cc 100644 --- a/docs/articles/expensify-classic/getting-started/Plan-Types.md +++ b/docs/articles/expensify-classic/getting-started/Plan-Types.md @@ -1,5 +1,32 @@ --- -title: Plan-Types -description: Plan-Types +title: Plan Types +description: Learn which Expensify plan is the best fit for you --- -## Resource Coming Soon! +# Overview +You can access comprehensive information about Expensify's plans and pricing by visiting www.expensify.com/pricing. Below, we provide an overview of each plan type to assist you in selecting the one that best suits your business or personal requirements. + +## Free Plan +The Free plan is suited for small businesses, offering a dedicated workspace for efficiently handling Expensify card management, expense reimbursement, invoicing, and bill payment. This plan includes unlimited receipt scanning for all users within the company and the potential to earn up to 1% cashback on card spending exceeding $25,000 per month (across all cards). + +## Collect Workspace Plan +The Collect Workspace Plan is designed with small companies in mind, providing essential features like a single layer of expense approvals, reimbursement capabilities, corporate card management, and basic integration options such as QuickBooks Online, QuickBooks Desktop, and Xero. This plan is ideal for those who require simple expense management functions. + +## Control Workspace Plan +Our most popular option, the Control Workspace plan, offers a heightened level of control and Workspace customization. With a Control Workspace, you gain access to multi-level approval workflows, comprehensive corporate card management, advanced accounting integration, tax tracking capabilities, and advanced expense rules that facilitate the enforcement of your internal expense policy. This plan provides a robust set of features for effective expense management. + +## Individual Track Plan +The Track plan is tailored for solo Expensify users who don't require expense submission to others. Individuals or sole proprietors can choose the Track plan to customize their Individual Workspace to align with their personal expense tracking requirements. + +## Individual Submit Plan +The Submit plan is designed for individuals who need to keep track of their expenses and share them with someone else, such as their boss, accountant, or even a housemate. It's specifically tailored for single users who want to both track and submit their expenses efficiently. + +# FAQ + +## How can I change Individual plans? +You have the flexibility to switch between a Track and Submit plan, or vice versa, at any time by navigating to **Settings > Workspaces > Individual > *Workspace Name* > Plan**. This allows you to adapt your expense management approach as needed. + +## How can I upgrade Group plans? +You can easily upgrade from a Collect to a Control plan at any time by going to **Settings > Workspaces > Group > *Workspace Name* > Plan**. However, it's important to note that if you have an active Annual Subscription, downgrading from Control to Collect is not possible until your current commitment period expires. + +## How does pricing work if I have two types of Group Workspace plans? +If you have a Control and Collect Workspace, you will be charged at the Control Workspace rate. diff --git a/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md b/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md deleted file mode 100644 index 507d24503af8..000000000000 --- a/docs/articles/expensify-classic/getting-started/tips-and-tricks/Enable-Location-Access-On-Web.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Enable Location Access on Web -description: How to enable location access for Expensify websites on your browser -redirect_from: articles/other/Enable-Location-Access-on-Web/ ---- - - -# About - -If you'd like to use features that rely on your current location you will need to enable location permissions for Expensify. You can find instructions for how to enable location settings on the three most common web browsers below. If your browser is not in the list then please do a web search for your browser and "enable location settings". - -# How-to - - -### Chrome -1. Open Chrome -2. At the top right, click the three-dot Menu > Settings -3. Click "Privacy and Security" and then "Site Settings" -4. Click Location -5. Check the "Not allowed to see your location" list to make sure expensify.com and new.expensify.com are not listed. If they are, click the delete icon next to them to allow location access - -[Chrome help page](https://support.google.com/chrome/answer/142065) - -### Firefox - -1. Open Firefox -2. In the URL bar enter "about:preferences" -3. On the left hand side select "Privacy & Security" -4. Scroll down to Permissions -5. Click on Settings next to Location -6. If location access is blocked for expensify.com or new.expensify.com, you can update it here to allow access - -[Firefox help page](https://support.mozilla.org/en-US/kb/permissions-manager-give-ability-store-passwords-set-cookies-more) - -### Safari -1. In the top menu bar click Safari -2. Then select Settings > Websites -3. Click Location on the left hand side -4. If expensify.com or new.expensify.com have "Deny" set as their access, update it to "Ask" or "Allow" - -Ask: The site must ask if it can use your location. -Deny: The site can’t use your location. -Allow: The site can always use your location. - -[Safari help page](https://support.apple.com/guide/safari/websites-ibrwe2159f50/mac) \ No newline at end of file diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/ADP.md b/docs/articles/expensify-classic/integrations/HR-integrations/ADP.md index 3ee1c8656b4b..65b276796c2a 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/ADP.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/ADP.md @@ -1,5 +1,81 @@ --- -title: Coming Soon -description: Coming Soon +title: How to use the ADP integration +description: Expensify’s ADP integration lets you pay out expense reports outside of the Expensify platform. Expensify creates a Custom Export Format that can be uploaded to ADP directly. --- -## Resource Coming Soon! +# Overview +Expensify’s ADP integration lets you pay out expense reports outside of the Expensify platform. Expensify creates a Custom Export Format that can be uploaded to ADP directly. + +You’ll need to be on the Control Plan to create a Custom Export Format. + +Your employee list in ADP can also be imported into Expensify via Expensify’s People table in CSV format, which will speed up the process of importing the correct values to sync up your employee’s reports with ADP. This feature is available on all plans. + +# How to use the ADP integration + +## Step 1: Set up the ADP import file + +A basic setup for an ADP import file includes five columns. In order (from left to right), these columns are: + +- **Company Code** - See “Edit Company” page in ADP +- **Batch ID** - Found in “Edit Company” +- **File #** - Employee number in ADP +- **Earnings 3 Code** - See “Edit Profit Center Group” page +- **Earnings 3 Amount** - Found in “Edit Profit Center Group” + +There is a **File #** for each employee that you’re tracking in **Expensify** located under “**RUN Powered by ADP**” - navigate to **Reports tab > Tax Reports > Wage > Tax Register**. + +In **Expensify**, the **File #** is entered in the **Custom Field 1 or 2** column in the **Members table**. +The **Earnings 3 Code** is the ADP code that corresponds to a payroll account you’re tracking in **Expensify**. The **Earnings 3 Amount** is the total of a given expense you’re sending to payroll. + +In **Expensify**, you can enter the **Earnings 3 Code** at **Settings > Workspaces > [Group Workspace Name] > Categories > Categories [Category Name] > Edit Rules > Add under Payroll Code**. + +## Step 2:Create your ADP Export Format + +For a basic setup, visit **Settings > Workspaces > [Group Workspace Name] > Export Formats** and add these column headings and corresponding formulas: + +- **Name:** Company Code + - **Formula:** [From Step 1.] + +- **Name:** BatchID + - **Formula:** [From Step 1.] + +- **Name:** File # + - **Formula:** {report:submit.from:customfield1} + +- **Name:** Earnings 3 Code + - **Formula:** {expense:category:payrollcode} + +- **Name:** Earnings 3 Amount + - **Formula:** {expense:amount} + +The Company Code column is hardcoded with your company’s code in ADP. Similarly, the Batch ID is hard coded with whatever Batch ID your company is using in ADP. + +## Step 3.:Export to CSV or XLS + +To export the file, do the following: + +1. Go to your "Reports" page in Expensify +2. Select the reports you want to export +3. Click "Export to..." and choose your custom ADP format +4. Your download will begin automatically and be delivered in CSV or XLS format + +## Step 4: Upload to ADP + +You should be able to upload your ADP file directly to ADP without any changes. + +# Deep Dive + +## Using the ADP integration + +You can set Custom Fields and Payroll Codes in bulk using a CSV upload in Expensify’s settings pages. + +If you have additional requirements for your ADP upload, for example, additional headings or datasets, reach out to your Expensify Account Manager who will assist you in customizing your ADP export. Expensify Account Managers are trained to accommodate your data requests and help you retrieve them from the system. + +# FAQ + +- Do I need to convert my employee list into new column headings so I can upload it to Expensify? + +Yes, you’ll need to convert your ADP employee data to the same headings as the spreadsheet that can be downloaded from the Members table in Expensify. + +- Can I add special fields/items to my ADP Payroll Custom Export Format? + +Yes! You can ask your Expensify Account Manager to help you prepare your ADP Payroll export so that it meets your specific requirements. Just reach out to them via the Chat option in Expensify and they’ll help you get set up. diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Accelo.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Accelo.md new file mode 100644 index 000000000000..fffe0abb43aa --- /dev/null +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Accelo.md @@ -0,0 +1,74 @@ +--- +title: Accelo +description: Help doc for Accelo integration +--- + + +# Overview +Accelo is a cloud-based business management software platform tailored for professional service companies, offering streamlined operations. It enables seamless integration with Expensify, allowing users to effortlessly import expense details from Expensify into Accelo, associating them with the corresponding project, ticket, or retainer within the system. + +# How to Connect Expensify to Accelo +To connect Expensify to Accelo, follow these clear steps: + +## Prerequisites +Ensure you have administrator access to Accelo. +Have a Workspace Admin role in Expensify. + +## Connecting Expensify to Accelo +1. Access the Expensify Integration Server: +- Open the Expensify Integration Server. +2. Retrieve Your Partner User ID and Partner User Secret: +- Important: These credentials are distinct from your regular Expensify username and password. +- If you haven't previously set up the integration server, click where it indicates "click here." +3. Regenerating Partner User Secret (If Necessary): +- Note: If you've previously configured the integration server, you must regenerate your Partner User Secret. Do this by clicking "click here" to regenerate your partnerUserSecret. +- If you currently use the Integration Server/API for another integration, remember to update that integration to use the new Secret. +4. Configure Accelo: +- Return to your Accelo account. +- Navigate to your Integrations page and select the Expensify tab. +5. Enter Expensify Integration Server Credentials: +- Provide your Expensify Integration Server's Partner User ID and Partner User Secret. +- Click "Save" to complete the setup. +6. Connection Established: +- Congratulations! Your Expensify account is now successfully connected to Accelo. + +With this connection in place, all Expensify users can effortlessly synchronize their expenses with Accelo, streamlining their workflow and improving efficiency. + +## How to upload your Accelo Project Codes as Tags in Expensify +Once you have connected Accelo to Expensify, the next step is to upload your Accelo Project Codes as Tags in Expensify. Simply go to Go to **Settings** > **Workspaces** > **Group** > _[Workspace Name]_ > **Tags** and upload your CSV. +If you directly integrate with Xero or QuickBooks Online, you must upload your Project Codes by appending your tags. Go to **Settings** > **Workspaces** > **Group** > _[Workspace Name]_ > **Tags** and click on “Append a custom tag list from a CSV” to upload your Project Codes via a CSV. + +# Deep Dive +## Information sync between Expensify and Accelo +The Accelo integration does a one-way sync, which means it brings expenses from Expensify into Accelo. When this happens, it transfers specific information from Expensify expenses to Accelo: + +| Expensify | Accelo | +|---------------------|-----------------------| +| Comment | Title | +| Date | Date Incurred | +| Category | Type | +| Tags | Against (relevant Project, Ticket or Retainer) | +| Distance (mileage) | Quantity | +| Hours (time expenses) | Quantity | +| Amount | Purchase Price and Sale Price | +| Reimbursable? | Reimbursable? | +| Billable? | Billable? | +| Receipt | Attachment | +| Tax Rate | Tax Code | +| Attendees | Submitted By | + +## Expense Status +The status of your expense report in Expensify is also synced in Accelo. + +| Expensify Report Status | Accelo Expense Status | +|-------------------------|-----------------------| +| Open | Submitted | +| Submitted | Submitted | +| Approved | Approved | +| Reimbursed | Approved | +| Rejected | Declined | +| Archived | Approved | +| Closed | Approved | + +## Importing expenses from Expensify to Accelo +Accelo regularly checks Expensify for new expenses once every hour. It automatically brings in expenses that have been created or changed since the last sync. diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md b/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md index 3ee1c8656b4b..8092ed9c6dd6 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/NetSuite.md @@ -1,5 +1,575 @@ --- -title: Coming Soon -description: Coming Soon +title: NetSuite +description: Connect and configure NetSuite directly to Expensify. --- -## Resource Coming Soon! +# Overview +Expensify's seamless integration with NetSuite enables you to streamline your expense reporting process. This integration allows you to automate the export of reports, tailor your coding preferences, and tap into NetSuite's array of advanced features. By correctly configuring your NetSuite settings in Expensify, you can leverage the connection's settings to automate most of the tasks, making your workflow more efficient. + +Before getting started with connecting NetSuite to Expensify, there are a few things to note: +- Token-based authentication works by ensuring that each request to NetSuite is accompanied by a signed token which is verified for authenticity +- You must be able to login to NetSuite as an administrator to initiate the connection +- You must have a Control Plan in Expensify to integrate with NetSuite +- Employees don’t need NetSuite access or a NetSuite license to submit expense reports since the connection is managed by the Workspace Admin +- Each NetSuite subsidiary will need its own Expensify Group Workspace +- Ensure that your workspace's report output currency setting matches the NetSuite Subsidiary default currency +- Make sure your page size is set to 1000 for importing your customers and vendors. Go to Setup > Integration > Web Services Preferences > 'Search Page Size' + +# How to Connect to NetSuite + +## Step 1: Install the Expensify Bundle in NetSuite + +1. While logged into NetSuite as an administrator, go to Customization > SuiteBundler > Search & Install Bundles, then search for "Expensify" +2. Click on the Expensify Connect bundle (Bundle ID 283395) +3. Click Install +4. If you already have the Expensify Connect bundle installed, head to _Customization > SuiteBundler > Search & Install Bundles > List_ and update it to the latest version +5. Select **Show on Existing Custom Forms** for all available fields + +## Step 2: Enable Token-Based Authentication + +1. Head to _Setup > Company > Enable Features > SuiteCloud > Manage Authentication_ +2. Make sure “Token Based Authentication” is enabled +3. Click **Save** + +## Step 3: Add Expensify Integration Role to a User + +The user you select must have access to at least the permissions included in the Expensify Integration Role, but they're not required to be an Admin. +1. In NetSuite, head to Lists > Employees, and find the user you want to add the Expensify Integration role to +2. Click _Edit > Access_, then find the Expensify Integration role in the dropdown and add it to the user +3. Click **Save** + +Remember that Tokens are linked to a User and a Role, not solely to a User. It's important to note that you cannot establish a connection with tokens using one role and then switch to another role afterward. Once you've initiated a connection with tokens, you must continue using the same token/user/role combination for all subsequent sync or export actions. + +## Step 4: Create Access Tokens + +1. Using Global Search in NetSuite, enter “page: tokens” +2. Click **New Access Token** +3. Select Expensify as the application (this must be the original Expensify integration from the bundle) +4. Select the role Expensify Integration +5. Press **Save** +6. Copy and Paste the token and token ID to a saved location on your computer (this is the only time you will see these details) + +## Step 5: Confirm Expense Reports are Enabled in NetSuite. + +Enabling Expense Reports is required as part of Expensify's integration with NetSuite: +1. Logged into NetSuite as an administrator, go to Setup > Company > Enable Features > Employees +2. Confirm the checkbox next to Expense Reports is checked +3. If not, click the checkbox and then Save to enable Expense Reports + +## Step 6: Confirm Expense Categories are set up in NetSuite. + +Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are an alias for General Ledger accounts for coding expenses. + +1. Logged into NetSuite as an administrator, go to Setup > Accounting > Expense Categories (a list of Expense Categories should show) +2. If no Expense Categories are visible, click **New** to create new ones + +## Step 7: Confirm Journal Entry Transaction Forms are Configured Properly + +1. Logged into NetSuite as an administrator, go to _Customization > Forms > Transaction Forms_ +2. Click **Customize** or **Edit** next to the Standard Journal Entry form +3. Then, click Screen Fields > Main. Please verify the "Created From" label has "Show" checked and the Display Type is set to Normal +4. Click the sub-header Lines and verify that the "Show" column for "Receipt URL" is checked +5. Go to _Customization > Forms > Transaction Forms_ and ensure all other transaction forms with the journal type have this same configuration + +## Step 8: Confirm Expense Report Transaction Forms are Configured Properly + +1. Logged into NetSuite as an administrator, go to _Customization > Forms > Transaction Forms_ +2. Click **Customize** or **Edit** next to the Standard Expense Report form, then click **Screen Fields > Main** +3. Verify the "Created From" label has "Show" checked and the Display Type is set to Normal +4. Click the second sub-header, Expenses, and verify that the 'Show' column for 'Receipt URL' is checked +5. Go to _Customization > Forms > Transaction Forms_ and ensure all other transaction forms with the expense report type have this same configuration + +## Step 9: Confirm Vendor Bill Transactions Forms are Configured Properly + +1. Logged into NetSuite as an administrator, go to _Customization > Forms > Transaction Forms_ +2. Click **Customize** or **Edit** next to your preferred Vendor Bill form +3. Then, click _Screen Fields > Main_ and verify that the "Created From" label has "Show" checked and that Departments, Classes, and Locations have the "Show" label unchecked +4. Under the Expenses sub-header (make sure to click the "Expenses" sub-header at the very bottom and not "Expenses & Items"), ensure "Show" is checked for Receipt URL, Department, Location, and Class +5. Go to _Customization > Forms > Transaction Forms_ and provide all other transaction forms with the vendor bill type have this same configuration + +## Step 10: Confirm Vendor Credit Transactions Forms are Configured Properly + +1. While logged in as an administrator, go to _Customization > Forms > Transaction Forms_ +2. Click **Customize** or **Edit** next to your preferred Vendor Credit form, then click _Screen Fields > Main_ and verify that the "Created From" label has "Show" checked and that Departments, Classes, and Locations have the "Show" label unchecked +3. Under the Expenses sub-header (make sure to click the "Expenses" sub-header at the very bottom and not "Expenses & Items"), ensure "Show" is checked for Receipt URL, Department, Location, and Class +4. Go to _Customization > Forms > Transaction Forms_ and ensure all other transaction forms with the vendor credit type have this same configuration + +## Step 11: Set up Tax Groups (only applicable if tracking taxes) + +Expensify imports NetSuite Tax Groups (not Tax Codes), which you can find in NetSuite under _Setup > Accounting > Tax Groups_. + +Tax Groups are an alias for Tax Codes in NetSuite and can contain one or more Tax Codes (Please note: for UK and Ireland subsidiaries, please ensure your Tax Groups do not have more than one Tax Code). We recommend naming Tax Groups so your employees can easily understand them, as the name and rate will be displayed in Expensify. + +Before importing NetSuite Tax Groups into Expensify: +1. Create your Tax Groups in NetSuite by going to _Setup > Accounting > Tax Groups_ +2. Click **New** +3. Select the country for your Tax Group +4. Enter the Tax Name (this is what employees will see in Expensify) +5. Select the subsidiary for this Tax Group +6. Select the Tax Code from the table you wish to include in this Tax Group +7. Click **Add** +8. Click **Save** +9. Create one NetSuite Tax Group for each tax rate you want to show in Expensify + +Ensure Tax Groups can be applied to expenses by going to _Setup > Accounting > Set Up Taxes_ and setting the Tax Code Lists Include preference to "Tax Groups And Tax Codes" or "Tax Groups Only." + +If this field does not display, it’s not needed for that specific country. + +## Step 12: Connect Expensify and NetSuite + +1. Log into Expensify as a Policy Admin and go to **Settings > Workspaces > _[Workspace Name]_ > Connections > NetSuite** +2. Click **Connect to NetSuite** +3. Enter your Account ID (Account ID can be found in NetSuite by going to _Setup > Integration > Web Services Preferences_) +4. Then, enter the token and token secret +5. Click **Connect to NetSuite** + +From there, the NetSuite connection will sync, and the configuration dialogue box will appear. + +Please note that you must create the connection using a NetSuite account with the Expensify Integration role + +Once connected, all reports exported from Expensify will be generated in NetSuite using SOAP Web Services (the term NetSuite employs when records are created through the integration). + +# How to Configure Export Settings + +There are numerous options for exporting Expensify reports to NetSuite. Let's explore how to configure these settings to align with your business needs. +To access these settings, head to **Settings > Workspace > Group > Connections** and select the **Configure** button. + +## Export Options + +### Subsidiary + +The subsidiary selection will only appear if you use NetSuite OneWorld and have multiple subsidiaries active. If you add a new subsidiary to NetSuite, sync the workspace connection, and the new subsidiary should appear in the dropdown list under **Settings > Workspaces > _[Workspace Name]_ > Connections**. + +### Preferred Exporter + +This option allows any admin to export, but the preferred exporter will receive notifications in Expensify regarding the status of exports. + +### Date + +The three options for the date your report will export with are: +- Date of last expense: This will use the date of the previous expense on the report +- Submitted date: The date the employee submitted the report +- Exported date: The date you export the report to NetSuite + +## Reimbursable Expenses + +### Expense Reports + +Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite. + +### Vendor Bills + +Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding policy. Each report will be posted as payable to the vendor associated with the employee who submitted the report. +You can also set an approval level in NetSuite for vendor bills. + +### Journal Entries + +Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this policy. All the transactions will be posted to the payable account specified in the policy. + +You can also set an approval level in NetSuite for the journal entries. + +**Important Notes:** +- Journal entry forms by default do not contain a customer column, so it is not possible to export customers or projects with this export option +- The credit line and header level classifications are pulled from the employee record + +## Non-Reimbursable Expenses + +### Vendor Bills + +Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills. + +### Journal Entries + +Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite. + +**Important Notes:** +- Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab +- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option +- The credit line and header level classifications are pulled from the employee record + +### Expense Reports + +To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite. + +To use a default corporate card for non-reimbursable expenses, you must select the correct card on the employee records (for individual accounts) or the subsidiary record (If you use a non-one world account, the default is found in your accounting preferences). + +Add the corporate card option and corporate card main field to your expense report transaction form in NetSuite by: +1. Heading to _Customization > Forms > Transaction Forms > Preferred expense report form > Screen Fields_ +2. Under the Main tab, check “Show” for Account for Corporate Card Expenses +3. On the Expenses tab, check “Show” for Corporate Card + +You can select the default account on your employee record to use individual corporate cards for each employee. Make sure you add this field to your employee entity form in NetSuite. +If you have multiple cards assigned to a single employee, you cannot export to each account. You can only have a single default per employee record. + +### Export Invoices + +Select the Accounts Receivable account you want your Invoice Reports to export. In NetSuite, the Invoices are linked to the customer, corresponding to the email address where the Invoice was sent. + +### Default Vendor Bills + +The list of vendors will be available in the dropdown when selecting the option to export non-reimbursable expenses as vendor bills. + +# How to Configure Coding Settings + +The Coding tab is where NetSuite information is configured in Expensify, which allows employees to code expenses and reports accurately. There are several coding options in NetSuite. Let’s go over each of those below. + +## Expense Categories + +Expensify's integration with NetSuite automatically imports NetSuite Expense Categories as Categories in Expensify. + +Please note that each expense must have a Category selected to export to NetSuite. The category chosen must be imported from NetSuite and cannot be manually created in Expensify. + +If you want to delete Categories, you must do this in NetSuite. Categories are added and modified on the integration’s side and then synced with Expensify. +Once imported, you can turn specific Categories on or off under **Settings > Workspaces > _[Workspace Name]_ > Categories**. + +## Tags + +The NetSuite integration allows you to configure Customers, Projects, Departments, Classes, and Locations as line-item expense classifications. These are called Tags in Expensify. + +Suppose a default Customer, Project, Department, Class, or Location ties to the employee record in NetSuite. In that case, Expensify will create a rule that automatically applies that tag to all expenses made by that employee (the Tag is still editable if necessary). + +If you want to delete Tags, you must do this in NetSuite. Tags are added and modified on the integration’s side and then synced with Expensify. + +Once imported, you can turn specific Tags on or off under **Settings > Workspaces > _[Workspace Name]_ > Tags**. + +## Report Fields + +The NetSuite integration allows you to configure Customers, Projects, Departments, Classes, and Locations as report-level classifications. These are called Report Fields in Expensify. + +## NetSuite Employee Default + +The NetSuite integration allows you to set Departments, Classes, and Locations according to the NetSuite Employee Default for expenses exported as both Expense Reports and Journal Entries. + +These fields must be set in NetSuite's employee(s) record(s) to be successfully applied to expenses upon export. + +You cannot use the employee default setting with a vendor bill export if you have both a vendor and an employee set up for the user under the same email address and subsidiary. + +## Tax + +The NetSuite integration allows users to apply a tax rate and amount to each expense. To do this, import Tax Groups from NetSuite: +1. In NetSuite, head to _Setup > Accounting > Tax Groups_ +2. Once imported, go to the NetSuite connection configuration page in Expensify (under **Settings > Workspaces > Group > _[Workspace Name]_ > Connection > NetSuite > Coding**), refresh the subsidiary list, and the Tax option will appear +3. From there, enable Tax +4. Click **Save** +5. Sync the connection +6. All Tax Groups for the connected NetSuite subsidiary will be imported to Expensify as taxes. +7. After syncing, go to **Settings > Workspace > Group > _[Workspace Name]_ > Tax** to see the tax groups imported from NetSuite +8. Use the turn on/off button to choose which taxes to make available to your employees +9. Select a default tax to apply to the workspace (that tax rate will automatically apply to all new expenses) + +## Custom Segments + +To add a Custom Segment to your workspace, you’ll need to locate three fields in NetSuite: +- Segment Name +- Internal ID +- Script/Field ID + +**To find the Segment Name:** +1. Log in as an administrator in NetSuite +2. Head to _Customization > Lists, Records, & Fields > Custom Segments_ +3. You’ll see the Segment Name on the Custom Segments page + +**To find the Internal ID:** +1. Ensure you have internal IDs enabled in NetSuite under _Home > Set Preferences_ +2. Navigate back to the Custom Segment page +3. Click the **Custom Record Type** hyperlink +4. You’ll see the Internal ID on the Custom Record Type page + +**To find the Script/Field ID:** + +If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_). + +If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_). + +Lastly, head over to Expensify and do the following: +1. Navigate to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** +2. Choose how to import Custom Segments (Report Fields or Tags) +3. Fill out the three fields (Segment Name, Internal ID, Script ID) +4. Click **Submit** + +From there, you should see the values for the Custom Segment under the Tag or Report Field settings in Expensify. + +Don’t use the "Filtered by" feature available for Custom Segments. Expensify can’t make these dependent on other fields. If you do have a filter selected, we suggest switching that filter in NetSuite to "Subsidiary" and enabling all subsidiaries to ensure you don't receive any errors upon exporting reports. + +### Custom Records + +Custom Records are added through the Custom Segments feature. + +To add a Custom Record to your workspace, you’ll need to locate three fields in NetSuite: +- The name of the record +- Internal ID +- Transaction Column ID + +**To find the Internal ID:** +1. Make sure you have Internal IDs enabled in NetSuite under Home > Set Preferences +2. Navigate back to the Custom Segment page +3. Click the Custom Record Type hyperlink +4. You’ll see the Internal ID on the Custom Record Type page + +**To find the Transaction Column ID:** +If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_). + +If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_). + +Lastly, head over to Expensify and do the following: +1. Navigate to **Settings > Workspace > Group > [Workspace Name]_ > Connections > Configure > Coding tab** +2. Choose how to import Custom Records (Report Fields or Tags) +3. Fill out the three fields (the name or label of the record, Internal ID, Transaction Column ID) +4. Click **Submit** + +From there, you should see the values for the Custom Records under the Tag or Report Field settings in Expensify. + +### Custom Lists + +To add Custom Lists to your workspace, you’ll need to locate two fields in NetSuite: +- The name of the record +- The ID of the Transaction Line Field that holds the record + +**To find the record:** +1. Log into Expensify +2. Head to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** +3. The name of the record will be populated in a dropdown list + +The name of the record will populate in a dropdown list. If you don't see the one you are looking for, click **Refresh Custom List Options**. + +**To find the Transaction Line Field ID:** +1. Log into NetSuite +2. Search "Transaction Line Fields" in the global search +3. Open the option that is holding the record to get the ID + +Lastly, head over to Expensify, and do the following: +1. Navigate to **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** +2. Choose how to import Custom Lists (Report Fields or Tags) +3. Enter the ID in Expensify in the configuration screen +4. Click **Submit** + +From there, you should see the values for the Custom Lists under the Tag or Report Field settings in Expensify. + +# How to Configure Advanced Settings + +The NetSuite integration’s advanced configuration settings are accessed under **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > NetSuite > Configure > Advanced tab**. + +Let’s review the different advanced settings and how they interact with the integration. + +## Auto Sync + +Enabling Auto Sync ensures that the information in NetSuite and Expensify is always in sync through automating exports, tracking direct deposits, and communicating export errors. + +**Automatic Export:** +- When you turn on the Auto Sync feature in Expensify, any final report you approve will automatically be sent to NetSuite. +- This happens every day at approximately the same time. + +**Direct Deposit Alert:** +- If you use Expensify's Direct Deposit ACH and have Auto Sync, getting reimbursed for an Expensify report will automatically create a Bill Payment in NetSuite. + +**Tracking Exports and Errors:** +- In the comments section of an Expensify report, you can find extra details about the report. +- The comments section will tell you when the report was sent to NetSuite, and if there were any problems during the export, it will show the error. + +## Newly Imported Categories + +With this enabled, all submitters can add any newly imported Categories to an Expense. + +## Invite Employees & Set Approval Workflow + +### Invite Employees + +Use this option in Expensify to bring your employees from a specific NetSuite subsidiary into Expensify. +Once imported, Expensify will send them an email letting them know they've been added to a workspace. + +### Set Approval Workflow + +Besides inviting employees, you can also establish an approval process in NetSuite. + +By doing this, the Approval Workflow in Expensify will automatically follow the same rules as NetSuite, typically starting with Manager Approval. + +- **Basic Approval:** A single level of approval, where all users submit directly to a Final Approver. The Final Approver defaults to the workspace owner but can be edited on the people page. +- **Manager Approval (default):** Two levels of approval route reports first to an employee's NetSuite expense approver or supervisor, and second to a workspace-wide Final Approver. By NetSuite convention, Expensify will map to the supervisor if no expense approver exists. The Final Approver defaults to the workspace owner but can be edited on the people page. +- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured on the workspace's People settings page. If you enable this setting, it’s recommended you review the newly imported employees and managers on the **Settings > Workspaces > Group > _[Workspace Name]_ > People page**. You can set a user role for each new employee and enforce an approval workflow. + +## Automatically Create Employees/Vendors + +With this feature enabled, Expensify will automatically create a new employee or vendor (if one doesn’t already exist) from the email of the report submitter in NetSuite. + +## Export Foreign Currency Amount + +Using this feature allows you to send the original amount of the expense rather than the converted total when exporting to NetSuite. This option is available if you are exporting reimbursable expenses as Expense Reports. + +## Cross-Subsidiary Customers/Projects + +This allows you to import Customers and Projects across all subsidiaries to a single group workspace. For this functionality, you must enable "Intercompany Time and Expense" in NetSuite. + +That feature is found in NetSuite under _Setup > Company > Setup Tasks: Enable Features > Advanced Features_. + +## Sync Reimbursed Reports + +If you're using Expensify's Direct Deposit ACH feature and you want to export reimbursable expenses as either Expense Reports or Vendor Bills in NetSuite, here's what to do: +1. In Expensify, go to the Advanced Settings tab +2. Look for a toggle or switch related to this feature +3. Turn it on by clicking the toggle +4. Select the correct account for the Bill Payment in NetSuite +5. Ensure the account you choose matches the default account for Bill Payments in NetSuite + +That's it! When Expensify reimburses an expense report, it will automatically create a corresponding Bill Payment in NetSuite. + +Alternatively, if reimbursing outside of Expensify, this feature will automatically update the expense report status in Expensify from Approved to Reimbursed when the respective report is paid in NetSuite and the corresponding workspace syncs via Auto-Sync or when the integration connection is manually synced. + +## Setting Approval Levels + +With this setting enabled, you can set approval levels based on your export type. + +- **Expense Reports:** These options correspond to the default preferences in NetSuite – “Supervisor approval only,” “Accounting approval only,” or “Supervisor and Accounting approved.” +- **Vendor Bills or Journal Entries:** These options correspond to the default preferences in NetSuite – “Pending Approval” or “Approved for Posting.” + +If you have Approval Routing selected in your accounting preference, this will override the selections in Expensify. + +If you do not wish to use Approval Routing in NetSuite, go to _Setup > Accounting > Accounting Preferences > Approval Routing_ and ensure Vendor Bills and Journal Entries are not selected. + +### Collection Account + +When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting. + +# Deep Dive + +## Categories + +You can use the Auto-Categorization feature so that expenses are automatically categorized. + +To set Category Rules (e.g., receipt requirements or comments), go to the categories page in the workspace under **Settings > Workspaces > _[Workspace Name]_ > Categories**. + +With this setting enabled, when an Expense Category updates in NetSuite, it will update in Expensify automatically. + +## Company Cards + +NetSuite's company card feature simplifies exporting reimbursable and non-reimbursable transactions to your General Ledger (GL). This approach is recommended for several reasons: + +1. **Separate Employees from Vendors:** NetSuite allows you to maintain separate employee and vendor records. This feature proves especially valuable when integrating with Expensify. By utilizing employee defaults for classifications, your employees won't need to apply tags to all their expenses manually. +2. **Default Accounts Payable (A/P) Account:** Expense reports enable you to set a default A/P account for export on your subsidiary record. Unlike vendor bills, where the A/P account defaults to the last selected account, the expense report export option allows you to establish a default A/P account. +3. **Mix Reimbursable and Non-Reimbursable Expenses:** You can freely mix reimbursable and non-reimbursable expenses without categorizing them in NetSuite after export. NetSuite's corporate card feature automatically categorizes expenses into the correct GL accounts, ensuring a neat and organized GL impact. + +#### Let’s go over an example! + +Consider an expense report with one reimbursable and one non-reimbursable expense. Each needs to be exported to different accounts and expense categories. + +In NetSuite, you can quickly identify the non-reimbursable expense marked as a corporate card expense. Reviewing the GL impact, you'll notice that the reimbursable expense is posted to the default A/P account set on the subsidiary record. On the other hand, the company card expense is assigned to the Credit Card account, which can either be set as a default on the subsidiary record (for a single account) or the employee record (for individual credit card accounts in NetSuite). + +Furthermore, each expense is categorized according to your selected expense category. + +You'll need to set up default corporate cards in NetSuite to use the expense report option for your corporate card expenses. + +For non-reimbursable expenses, choose the appropriate card on the subsidiary record. You can find the default in your accounting preferences if you're not using a OneWorld account. + +Add the corporate card option and the corporate card main field to configure your expense report transaction form in NetSuite: +1. Go to _Customization > Forms > Transaction Forms > Preferred expense report form > Screen Fields_ +2. Under the Main tab, check "Show for Account for Corporate Card Expenses" +3. On the Expenses tab, check "Show for Corporate Card" + +If you prefer individual corporate cards for each employee, you can select the default account on the employee record. Add this field to your employee entity form in NetSuite (under _Customize > Customize Form_ from any employee record). Note that each employee can have only one corporate card account default. + +### Exporting Company Cards to GL Accounts in NetSuite + +If you need to export company card transactions to individual GL accounts, you can set that up at the domain level. + +Let’s go over how to do that: +1. Go to **Settings > Domain > _[Domain name]_ > Company Cards** +2. Click the Export Settings cog on the right-hand side of the card and select the GL account where you want the expenses to export + +After setting the account, exported expenses will be mapped to that designated account. + +## Tax + +You’ll want to set up Tax Groups in Expensify if you're keeping track of taxes. + +Expensify can import "NetSuite Tax Groups" (not Tax Codes) from NetSuite. Tax Groups can contain one or more Tax Codes. If you have subsidiaries in the UK or Ireland, ensure your Tax Groups have only one Tax Code. + +You can locate these in NetSuite by setting up> Accounting > Tax Groups. + +You’ll want to name Tax Groups something that makes sense to your employees since both the name and the tax rate will appear in Expensify. + +To bring NetSuite Tax Groups into Expensify, here's what you need to do: +1. Create your Tax Groups in NetSuite by going to _Setup > Accounting > Tax Groups_ +2. Click **New** +3. Pick the country for your Tax Group +4. Enter the Tax Name (this will be visible to your employees in Expensify) +5. Next, select the subsidiary for this Tax Group +6. Finally, from the table, choose the Tax Code you want to include in this Tax Group +7. Click **Add**, then click **Save** + +Repeat those steps for each tax rate you want to use in Expensify. + +Next, ensure that Tax Groups can be applied to expenses: +1. In NetSuite, head to _Setup > Accounting > Set Up Taxes_ +2. Set the preference for "Tax Code Lists Include" to either "Tax Groups And Tax Codes" or "Tax Groups Only." If you don't see this field, don't worry; it means you don't need to set it for that specific country + +NetSuite has a pre-made list of tax groups for specific locations, but you can also create your own. We'll import both your custom tax groups and the default ones. It's important not to deactivate the default NetSuite tax groups because we rely on them for exporting specific types of expenses. + +For example, there's a default Canadian tax group called CA-Zero, which we use when exporting mileage and per diem expenses that don't have any taxes applied in + +Expensify. If you deactivate this group in NetSuite, it will lead to export errors. + +Additionally, some tax nexuses in NetSuite have specific settings that need to be configured in a certain way to work seamlessly with the Expensify integration: +- ​​In the Tax Code Lists Include field, choose "Tax Groups" or "Tax Groups and Tax Codes." This setting determines how tax information is handled. +- In the Tax Rounding Method field, select "Round Off." Although it won't cause connection errors, not using this setting can result in exported amounts differing from what NetSuite expects. + +If your tax groups are importing into Expensify but not exporting to NetSuite, check that each tax group has the right subsidiaries enabled. That is crucial for proper data exchange. + +## Multi-Currency + +When using multi-currency features with NetSuite, remember these points: + +**Matching Currencies:** The currency set for a vendor or employee record must match the currency chosen for the subsidiary in your Expensify configuration. This alignment is crucial for proper handling. + +**Foreign Currency Conversion:** If you create expenses in one currency and then convert them to another currency within Expensify before exporting, you can include both the original and converted amounts in the exported expense reports. This option, called "Export foreign currency amount," can be found in the Advanced tab of your configuration. Note that Expensify sends only the amounts; the actual currency conversion is performed in NetSuite. + +**Bank Account Currency:** When synchronizing bill payments, make sure your bank account's currency matches the subsidiary's currency. Failure to do so will result in an "Invalid Account" error. This alignment is necessary to prevent issues during payment processing. + +## Exporting Invoices + +When you mark an invoice as paid in Expensify, the paid status syncs with NetSuite and vice versa! + +Let's dive right in: +1. Access Configuration Settings: Go to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configuration** +2. Choose Your Accounts Receivable Account: Scroll down to "Export Expenses to" and select the appropriate Accounts Receivable account from the dropdown list. If you don't see any options, try syncing your NetSuite connection by returning to the Connections page and clicking **Sync Now** + +### Exporting an Invoice to NetSuite + +Invoices will be automatically sent to NetSuite when they are in the "Processing" or "Paid" status. This ensures you always have an up-to-date record of unpaid and paid invoices. + +If you have Auto Sync disabled, you'll need to export your invoices, along with your expense reports, manually. Follow these three simple steps: +1. Filter Invoices: From your Reports page, use filters to find the invoices you want to export. +2. Select Invoices: Pick the invoices ready for export. +3. Export to NetSuite: Click **Export to NetSuite** in the top right-hand corner. + +When exporting to NetSuite, we match the recipient's email address on the invoice to a customer record in NetSuite, meaning each customer in NetSuite must have an email address in their profile. If we can't find a match, we'll create a new customer in NetSuite. + +Once exported, the invoice will appear in the Accounts Receivable account you selected during your NetSuite Export configuration. + +### Updating an Invoice to paid + +When you mark an invoice as "Paid" in Expensify, this status will automatically update in NetSuite. Similarly, if the invoice is marked as "Paid" in NetSuite, it will sync with Expensify. The payment will be reflected in the Collection account specified in your Advanced Settings Configuration. + +## Download NetSuite Logs + +Sometimes, we might need more details from you to troubleshoot issues with your NetSuite connection. Providing the NetSuite web services usage logs is incredibly useful. + +Here's how you can send them to us: +1. **Generate the Logs:** Start by trying to export a report from your system. This action will create the most recent logs that we require. +2. **Access Web Services Usage Logs:** You can locate these logs in your NetSuite account. Just use the global search bar at the top of the page and type in "Web Services Usage Log." +3. **Identify the Logs:** Look for the most recent log entry. It should have "FAILED" under the STATUS column. Click on the two blue "view" links under the REQUEST and RESPONSE columns. These are the two .xml files we need to examine. + +Send these two files to your Account Manager or Concierge so we can continue troubleshooting! + +# FAQ + +## What type of Expensify plan is required for connecting to NetSuite? + +You need a group workspace on a Control Plan to integrate with NetSuite. + +## How does Auto Sync work with reimbursed reports? + +If a report is reimbursed via ACH or marked as reimbursed in Expensify and then exported to NetSuite, the report is automatically marked as paid in NetSuite during the next sync. + +If a report is exported to NetSuite and then marked as paid in NetSuite, the report is automatically marked as reimbursed in Expensify during the next sync. + +## If I enable Auto Sync, what happens to existing approved and reimbursed reports? + +If you previously had Auto Sync disabled but want to allow that feature to be used going forward, you can safely turn on Auto Sync without affecting existing reports. Auto Sync will only take effect for reports created after enabling that feature. diff --git a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md index 3ee1c8656b4b..9fd745838caf 100644 --- a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md +++ b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md @@ -1,5 +1,34 @@ --- -title: Coming Soon -description: Coming Soon +title: Google Apps SSO +description: Expensify integrates with Google Apps SSO to easily invite users to your workspace. --- -## Resource Coming Soon! +Google Apps SSO Integration +# Overview +Expensify offers a Single Sign-on (SSO) integration with [Google Apps](https://cloud.google.com/architecture/identity/single-sign-on) for one-click Workspace invites. + +To set this up for users, you must: + +- Be an admin for a **Group Workspace** using a Collect or Control Workspace. +- Have Administrator access to the Google Apps Admin console. + +Google Apps SSO differs from using Google as your Identity Provider for SAML SSO, which limits domain access. To complete the Google SAML setup, follow the Google guide to [Set up SSO via SAML for Expensify](https://support.google.com/a/answer/7371682). You can enable both at the same time. +# How to Enable the Expensify App on Google Apps +To enable Expensify for your Google Apps domain and add an “Expenses” link to your universal navigation bar, please run through the following: +1. Sign in to your Google Apps Admin console as an administrator. +2. Navigate to the [Expensify App Listing on Google Apps](https://workspace.google.com/marketplace/app/expensify/452047858523). +3. Click **Admin Install** to start installing the app. +4. Click **Continue**. +5. Ensure the correct domain is selected if you have access to multiple. +6. Click **Finish**. You can configure access for specific Organizational Units later if needed. +7. All account holders on your domain can now access Expensify from the Google Apps directory by clicking **More** and choosing **Expensify**. +8. Now, follow the steps below to sync your users with Expensify automatically. +# How to Sync Users from Google Apps to Expensify +To sync your Google Apps users to your Expensify Workspace, follow these steps: +1. Follow the above steps to install Expensify in your Google Apps directory. +2. Log in to [Expensify](https://www.expensify.com/). +3. Click [Settings>Workspaces>Group](https://www.expensify.com/admin_policies?param={"section":"group"}). +4. Select the Workspace you wish to invite users to. +5. Select **People** from the admin menu. +6. Click **Sync G Suite Now** to identify anyone on your domain not yet on the Workspace and add them to it. + +The connection does not automatically refresh, you will need to click **Sync G Suite Now** whenever you’re ready to add new users. diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md b/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md index 3ee1c8656b4b..178621a62d90 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md +++ b/docs/articles/expensify-classic/integrations/travel-integrations/Egencia.md @@ -1,5 +1,30 @@ --- -title: Coming Soon -description: Coming Soon +title: Egencia Integration +description: Expensify-Egencia integration automatically adds Egencia booking receipts to Expensify. --- -## Resource Coming Soon! +# Overview +[Egencia](https://www.egencia.com/en/) is a platform used to book and manage business travel. Integrating Expensify and Egencia ensures any bookings made using Egencia will automatically import as expenses to Expensify. +## Requirements: +- You'll need to have a Control Workspace +- A verified Domain + +# How to use Egencia with Expensify +When an employee makes a booking in Egencia: +- The receipt itinerary will automatically be imported to the traveler's Expensify account along with the expense details without needing to submit the information manually. +- When the traveler uses their company credit card to make a purchase via Egencia, the Egencia receipt will automatically merge with the credit card transaction. + +The travel information will also be available in the Trips section of the mobile app of the recipient's Expensify account. +# How to Enable the Egencia Feed +A file feed is an automated transfer of data files from Egencia to Expensify. + +Egencia controls the feed, so to connect Expensify you will need to: +1. Contact your Egencia account manager. +2. Request that they enable your Expensify feed. + +# How to Connect to a Central Purchasing Account +Once your Egencia account manager has established the feed, you can automatically forward all Egencia booking receipts to a single Expensify account. To do this: +1. Open a chat with Concierge. +2. Tell Concierge “Please enable Central Purchasing Account for our Egencia feed. The account email is: xxx@yourdomain.com”. + +The receipt the traveler receives is a "reservation expense." Reservation expenses are non-reimbursable and won’t be included in any integrated accounting system exports. The reservation sent to the traveler's account is added to their mobile app Trips feature so that the traveler can easily keep tabs on upcoming travel and receive trip notifications. + diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md b/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md index 6b40a0f4adce..fcb1c8018613 100644 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md +++ b/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md @@ -6,7 +6,7 @@ description: Expensify Per Diems support simple, pre-determined, tax-free allowa Per Diems are a flat rate given based on a timed range traveled for business purposes regardless of actual expenses incurred. A Per Diem is only based on the time you travel for work: it starts when you leave your home and ends when you arrive home. -Per Diems themselves are created to alleviate much of the heavy lifting that Expense Reporting is well known for. Per Diem claims generally remove the hassle of saving multiple receipts and allow you to claim back a simple, pre-determined, tax-free allowance set by your jurisdiction or company. +Per Diems themselves are created to alleviate much of the heavy lifting that expense reporting is well known for. Per Diem claims generally remove the hassle of saving multiple receipts and allow you to claim back a simple, pre-determined, tax-free allowance set by your jurisdiction or company. # How to set up Per Diems in Expensify @@ -75,11 +75,10 @@ Most companies will also set a Description Hint, which allows admins to take the **Cons:** -- Sometimes, jurisdiction-set amounts can be deemed incorrect or too low. In these cases, it can be challenging to establish a fair and realistic per diem for different costs in different locations. Additionally, allowing more than a tax-free amount adds undue labor for admins teams to split out taxable and tax-free expense reimbursements. +- Sometimes, jurisdiction-set amounts can be deemed incorrect or too low. In these cases, it can be challenging to establish a fair and realistic per diem for different costs in different locations. Additionally, allowing more than a tax-free amount adds undue labor for admin teams to split out taxable and tax-free expense reimbursements. - Set Per Diems might restrict employee choices that could have benefitted the company, i.e., a sales team member not picking up a full dinner tab or pushing split bill reclamation back onto employees after an individual picks up the tab and each user submits their own Per Diem claims. - It does not eliminate employee expense fraud, and reduced receipt requirements may make it easier. - -As a business, you can never be sure that your expenses bill matches what employees have had to spend. Because you're reimbursing pre-determined amounts, there may be substantial hidden savings you're not taking advantage of. +- As a business, you can never be sure that your expenses bill matches what employees have had to spend. Because you're reimbursing pre-determined amounts, there may be substantial hidden savings you're not taking advantage of. ## How to manage existing rates and avoid duplicates diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/SAML-SSO.md b/docs/articles/expensify-classic/policy-and-domain-settings/SAML-SSO.md new file mode 100644 index 000000000000..758cb70067e1 --- /dev/null +++ b/docs/articles/expensify-classic/policy-and-domain-settings/SAML-SSO.md @@ -0,0 +1,89 @@ +--- +title: Managing Single Sign-On (SSO) and User Authentication in Expensify +description: Learn how to effectively manage Single Sign-On (SSO) and user authentication in Expensify alongside your preferred SSO provider. Our comprehensive guide covers SSO setup, domain verification, and specific instructions for popular providers like AWS, Okta, and Microsoft Azure. Streamline user access and enhance security with Expensify's SAML-based SSO integration. +--- +# Overview +This article provides a comprehensive guide on managing Single Sign-On (SSO) and user authentication in Expensify alongside your preferred SSO provider. Expensify uses SAML to enable and manage SSO between Expensify and your SSO provider. + +# How to Use SSO in Expensify +Before setting up Single Sign-On with Expensify you will need to make sure your domain has been verified. Once the domain is verified, you can access the SSO settings by navigating to Settings > Domains > [Domain Name] > SAML. +On this page, you can: +- Get Expensify's Service Provider MetaData. You will need to give this to your identity provider. +- Enter your Identity Provider MetaData. Please contact your SAML SSO provider if you are unsure how to get this. +- Choose whether you want to make SAML SSO required for login. If you choose this option, members will only be able to log in to Expensify via SAML SSO. +Instructions for setting up Expensify for specific SSO providers can be found below. If you do not see your provider listed below, please contact them and request instructions. +- [Amazon Web Services (AWS SSO)](https://static.global.sso.amazonaws.com/app-202a715cb67cddd9/instructions/index.htm) +- [Bitium](https://support.bitium.com/administration/saml-expensify/) +- [Google SAML](https://support.google.com/a/answer/7371682) (for GSuite, not Google SSO) +- [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/documentation/articles/active-directory-saas-expensify-tutorial/) +- [Okta](https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Expensify.html) +- [OneLogin](https://onelogin.service-now.com/support?id=kb_article&sys_id=e44c9e52db187410fe39dde7489619ba) +- [Oracle Identity Cloud Service](https://docs.oracle.com/en/cloud/paas/identity-cloud/idcsc/expensify.html#Expensify) +- [SAASPASS](https://saaspass.com/saaspass/expensify-two-factor-authentication-2fa-single-sign-on-sso-saml.html) +- Microsoft Active Directory Federation Services (see instructions in the FAQ section below) + +When SSO is enabled, employees will be prompted to sign in through Single Sign-On when using their company email (private domain email) and also a public email (e.g. gmail.com) linked as a secondary login. + +## How can I update the Microsoft Azure SSO Certificate? +Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IdP will include two unique x509 certificates instead of one and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid. + +To transfer from one Microsoft Azure certificate to another, please follow the below steps: +1. In Azure Directory , create your new certificate. +2. In Azure Director, remove the old, expiring certificate. +3. In Azure Directory, activate the remaining certificate, and get a new IdP for Expensify from it. +4. In Expensify, replace the previous IdP with the new IdP. +5. Log in via SSO. If login continues to fails, write into Concierge for assistance. + +## How can I enable deactivating users with the Okta SSO integration? +Companies using Okta can deactivate users in Expensify using the Okta SCIM API. This means that when a user is deactivated in Okta their access to Expensify will expire and they will be logged out of both the web and mobile apps. Deactivating a user through Okta will not close their account in Expensify, if you are offboarding this employee, you will still want to close the account. You will need have a verified domain and SAML fully setup before completing setting up the deactivation feature. + +To enable deactivating users in Okta, follow these steps: +1. In Expensify, head to *Settings > Domains > _[Domain Name]_ > SAML* +2. Ensure that the toggle is set to Enabled for *SAML Login* and *Required for login* +3. In Okta, go to *Admin > Applications > Add Application* +4. Search for Expensify and click on Add. +5. On the next screen, enter your company domain (e.g. yourcompany.com). +6. In the tab Sign-On Options, click *Next* (leaving default settings). +7. In the tab Assign to People, click *Next* and then click Done. +8. Next, in Okta, go to *Admin > Applications > Expensify > Sign On > View Setup Instructions* and follow the steps listed. +9. Then, go to *Directory > Profile Editor > Okta user > Profile* +10. Click the information bubble to the right of the *First name* and *Last name* attributes +11. Uncheck *Yes* under *Attribute required* field and press *Save Attribute*. +12. Email concierge@expensify.com providing your domain and request that Okta SCIM be enabled. You will receive a response when this step has been completed. +13. In Expensify, go to *Domains > _[Domain Name]_ > SAML > Show Token* and copy the Okta SCIM Token you received. +14. In Okta, go to *Admin > Applications > Expensify > Provisioning > API Integration > Configure API Integration* +15. Select Enable API Integration and paste the Okta SCIM Token in API Token field and then click Save. +15. Go to To App, click Edit Provisioning Users, select Enable Deactivate Users and then Save. (You may also need to set up the Expensify Attribute Mappings if you have not previously in steps 9-11). + +Successful activation of this function will be indicated by the green Push User Deactivation icon being enabled at the top of the app page. + +## How can I set up SAML authentication with Microsoft ADFS? +Before getting started, you will need to have a verified domain and Control plan in order to set up SSO with Microsoft ADFS. + +To enable SSO with Microsoft ADFS follow these steps: +1. Open the ADFS management console, and click the *Add Relying Party Trust* link on the right. +2. Check the option to *Import data about the relying party from a file*, then click the *Browse* button. You will input the XML file of Expensify’s metadata which can be found on the Expensify SAML setup page. +3. The metadata file will provide the critical information that ADFS needs to set up the trust. In ADFS, give it a name, and click Next. +4. Select the option to permit all users, then click Next. +5. The next step will give you an overview of what is being configured. Click Next. +6. The new trust is now created. Highlight the trust, then click *Edit claim rules* on the right. +7. Click *Add a Rule*. +8. The default option should be *Send LDAP Attributes as Claims*. Click Next. +9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the emailaddress attribute. +10. Give the rule a name like *Get email address from AD*. Choose Active Directory as the attribute store from the dropdown list. Choose your source user attribute to pass to Expensify that has users’ email address info in it, usually either *E-Mail-Address* or *User-Principal-Name*. Select the outgoing claim type as “E-Mail Address”. Click OK. +11. Add another rule; this time, we want to *Transform an Incoming Claim*. Click Next. +12. Name the rule *Send email address*. The Incoming claim type should be *E-Mail Address*. The outgoing claim type should be *Name ID*, and the outgoing name ID format should be *Email*. Click OK. +13. You should now have two claim rules. + +Assuming you’ve also set up Expensify SAML configuration with your metadata, SAML logins on Expensify.com should now work. For reference, ADFS’ default metadata path is: https://yourservicename.yourdomainname.com/FederationMetadata/2007-06/FederationMetadata.xml. + +# FAQ +## What should I do if I’m getting an error when trying to set up SSO? +You can double check your configuration data for errors using samltool.com. If you’re still having issues, you can reach out to your Account Manager or contact Concierge for assistance. + +## What is the EntityID for Expensify? +The entityID for Expensify is https://expensify.com. Remember not to copy and paste any extra slashes or spaces. If you've enabled the Multi-Domain support (see below) then your entityID will be https://expensify.com/mydomainname.com. + +## Can you have multiple domains with only one entityID? +Yes. Please send a message to Concierge or your account manager and we will enable the ability to use the same entityID with multiple domains. + diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md b/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/SAML.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md b/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md index 834d0b159931..e55d99d70827 100644 --- a/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md +++ b/docs/articles/expensify-classic/send-payments/Reimbursing-Reports.md @@ -1,5 +1,75 @@ --- title: Reimbursing Reports -description: Reimbursing Reports +description: How to reimburse employee expense reports --- -## Resource Coming Soon! +# Overview + +One essential aspect of the Expensify workflow is the ability to reimburse reports. This process allows for the reimbursement of expenses that have been submitted for review to the person who made the request. Detailed explanations of the various methods for reimbursing reports within Expensify are provided below. + +# How to reimburse reports + +Reports can be reimbursed directly within Expensify by clicking the **Reimburse** button at the top of the report to reveal the available reimbursement options. + +## Direct Deposit + +To reimburse directly in Expensify, the following needs to be already configured: +- The employee that's receiving reimbursement needs to add a deposit bank account to their Expensify account (under **Settings > Account > Payments > Add a Deposit-only Bank Account**) +- The reimburser needs to add a business bank account to Expensify (under **Settings > Account > Payments > Add a Verified Business Bank Account**) +- The reimburser needs to ensure Expensify is whitelisted to withdraw funds from the bank account + +If all of those settings are in place, to reimburse a report, you will click **Reimburse** on the report and then select **Via Direct Deposit (ACH)**. + +## Indirect or Manual Reimbursement + +If you don't have the option to utilize direct reimbursement, you can choose to mark a report as reimbursed by clicking the **Reimburse** button at the top of the report and then selecting **I’ll do it manually – just mark as reimbursed**. + +This will effectively mark the report as reimbursed within Expensify, but you'll handle the payment elsewhere, outside of the platform. + +# Best Practices +- Plan ahead! Consider sharing a business bank account with multiple workspace admins so they can reimburse employee reports if you're unavailable. We recommend having at least two workspace admins with reimbursement permissions. + +- Understand there is a verification process when sharing a business bank account. The new reimburser will need access to the business bank account’s transaction history (or access to someone who has access to it) to verify the set of test transactions sent from Expensify. + +- Get into the routine of having every new employee connect a deposit-only bank account to their Expensify account. This will ensure reimbursements happen in a timely manner. + +- Employees can see the expected date of their reimbursement at the top of and in the comments section of their report. + +# How to cancel a reimbursement + +Reimbursed a report by mistake? No worries! Any workspace admin with access to the same Verified Bank Account can cancel the reimbursement from within the report until it is withdrawn from the payment account. + +**Steps to Cancel an ACH Reimbursement:** +1. On your web account, navigate to the Reports page +2. Open the report +3. Click **Cancel Reimbursement** +4. After the prompt, "Are you sure you want to cancel the reimbursement?" click **Cancel Reimbursement**. + +It's important to note that there is a small window of time (roughly less than 24 hours) when a reimbursement can be canceled. If you don't see the **Cancel Reimbursement** button on a report, this means your bank has already begun withdrawing the funds from the reimbursement account and the withdrawal cannot be canceled. + +In that case, you’ll want to contact your bank directly to see if they can cancel the reimbursement on their end - or manage the return of funds directly with your employee, outside of Expensify. + +If you cancel a reimbursement after the withdrawal has started, it will be automatically returned to your Verified Bank Account within 3-5 business days. + +# Deep Dive + +## Rapid Reimbursement +If your company uses Expensify's ACH reimbursement, we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement, it must fall under two limits: +- $100 per deposit only bank account per day for the individuals being reimbursed or businesses receiving payments for bills +- $10,000 per verified bank account for the company paying bills and reimbursing + +If neither limit is met, you can expect to see funds deposited into your bank account on the next business day. + +If either limit has been reached, you can expect funds deposited within your bank account within the typical ACH time frame of four to five business days. + +Rapid Reimbursement is not available for non-US-based reimbursement. If you are receiving a reimbursement to a non-US-based deposit account, you should expect to see the funds deposited in your bank account within four business days. + +# FAQ + +## Who can reimburse reports? +Only a workspace admin who has added a verified business bank account to their Expensify account can reimburse employees. + +## Why can’t I trigger direct ACH reimbursements in bulk? + +Instead of a bulk reimbursement option, you can set up automatic reimbursement. With this configured, reports below a certain threshold (defined by you) will be automatically reimbursed via ACH as soon as they're "final approved." + +To set your manual reimbursement threshold, head to **Settings > Workspace > Group > _[Workspace Name]_ > Reimbursement > Manual Reimbursement**. diff --git a/docs/assets/images/ExpensifyHelp_CardSettings.png b/docs/assets/images/ExpensifyHelp_CardSettings.png new file mode 100644 index 000000000000..c10a3d1cbc39 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CardSettings.png differ diff --git a/docs/assets/images/ExpensifyHelp_CreateExpense.png b/docs/assets/images/ExpensifyHelp_CreateExpense.png new file mode 100644 index 000000000000..bed2d508dfd7 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CreateExpense.png differ diff --git a/docs/assets/images/ExpensifyHelp_CreateExpense_Mobile.png b/docs/assets/images/ExpensifyHelp_CreateExpense_Mobile.png new file mode 100644 index 000000000000..aebee5d1a86e Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CreateExpense_Mobile.png differ diff --git a/docs/assets/images/ExpensifyHelp_ExpenseRules_01.png b/docs/assets/images/ExpensifyHelp_ExpenseRules_01.png new file mode 100644 index 000000000000..7a6c3c1b3a13 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ExpenseRules_01.png differ diff --git a/docs/assets/images/ExpensifyHelp_ExpenseRules_02.png b/docs/assets/images/ExpensifyHelp_ExpenseRules_02.png new file mode 100644 index 000000000000..28c6a7689b77 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ExpenseRules_02.png differ diff --git a/docs/assets/images/ExpensifyHelp_ExpenseRules_03.png b/docs/assets/images/ExpensifyHelp_ExpenseRules_03.png new file mode 100644 index 000000000000..90c9855c0c49 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ExpenseRules_03.png differ diff --git a/docs/assets/images/ExpensifyHelp_ManualDistance.png b/docs/assets/images/ExpensifyHelp_ManualDistance.png new file mode 100644 index 000000000000..607025ed1765 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ManualDistance.png differ diff --git a/docs/assets/images/ExpensifyHelp_ManualDistanceConfirm.png b/docs/assets/images/ExpensifyHelp_ManualDistanceConfirm.png new file mode 100644 index 000000000000..2bc61196531a Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ManualDistanceConfirm.png differ diff --git a/docs/assets/images/ExpensifyHelp_ManualDistanceMap.png b/docs/assets/images/ExpensifyHelp_ManualDistanceMap.png new file mode 100644 index 000000000000..78f2a722a7ca Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ManualDistanceMap.png differ diff --git a/docs/assets/images/ExpensifyHelp_ManualDistance_Mobile.png b/docs/assets/images/ExpensifyHelp_ManualDistance_Mobile.png new file mode 100644 index 000000000000..327f5de00129 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ManualDistance_Mobile.png differ diff --git a/docs/assets/images/ExpensifyHelp_Odometer_Mobile.png b/docs/assets/images/ExpensifyHelp_Odometer_Mobile.png new file mode 100644 index 000000000000..519f541b85c9 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_Odometer_Mobile.png differ diff --git a/docs/assets/images/ExpensifyHelp_SettlementExpanded.png b/docs/assets/images/ExpensifyHelp_SettlementExpanded.png new file mode 100644 index 000000000000..8672c8639202 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_SettlementExpanded.png differ diff --git a/docs/assets/images/ExpensifyHelp_SettlementExport.png b/docs/assets/images/ExpensifyHelp_SettlementExport.png new file mode 100644 index 000000000000..b8c9a36220d4 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_SettlementExport.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 227e46308b0e..cb04e9c1ef90 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.79 + 1.3.81 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.79.4 + 1.3.81.10 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 3bf408597d74..e597c10142d8 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.79 + 1.3.81 CFBundleSignature ???? CFBundleVersion - 1.3.79.4 + 1.3.81.10 diff --git a/ios/Podfile b/ios/Podfile index b30510572448..6aee4b94df04 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -2,17 +2,29 @@ # This value is used by $RNMapboxMaps $RNMapboxMapsImpl = 'mapbox' -# Resolve react_native_pods.rb with node to allow for hoisting -require Pod::Executable.execute_command('node', ['-p', - 'require.resolve( - "react-native/scripts/react_native_pods.rb", - {paths: [process.argv[1]]}, - )', __dir__]).strip +def node_require(script) + # Resolve script with node to allow for hoisting + require Pod::Executable.execute_command('node', ['-p', + "require.resolve( + '#{script}', + {paths: [process.argv[1]]}, + )", __dir__]).strip +end + +node_require('react-native/scripts/react_native_pods.rb') +node_require('react-native-permissions/scripts/setup.rb') # Our min supported iOS version is higher than the default (min_ios_version_supported) to support libraires such as Airship platform :ios, 13 prepare_react_native_project! +setup_permissions([ + 'Camera', + 'LocationAccuracy', + 'LocationAlways', + 'LocationWhenInUse' +]) + # If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set. # because `react-native-flipper` depends on (FlipperKit,...) that will be excluded # @@ -51,8 +63,6 @@ pre_install do |installer| end target 'NewExpensify' do - permissions_path = '../node_modules/react-native-permissions/ios' - project 'NewExpensify', 'DebugDevelopment' => :debug, 'DebugAdHoc' => :debug, @@ -61,11 +71,6 @@ target 'NewExpensify' do 'ReleaseAdHoc' => :release, 'ReleaseProduction' => :release - pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy" - pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways" - pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse" - pod 'Permission-Camera', :path => "#{permissions_path}/Camera" - config = use_native_modules! # Flags change depending on the env values. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6e0c44299398..54d0525fd3c9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -233,9 +233,9 @@ PODS: - libwebp/mux (1.2.4): - libwebp/demux - libwebp/webp (1.2.4) - - lottie-ios (3.4.4) - - lottie-react-native (5.1.6): - - lottie-ios (~> 3.4.0) + - lottie-ios (4.3.3) + - lottie-react-native (6.3.1): + - lottie-ios (~> 4.3.0) - React-Core - MapboxCommon (23.6.0) - MapboxCoreMaps (10.14.0): @@ -256,14 +256,6 @@ PODS: - Onfido (= 27.4.0) - React - OpenSSL-Universal (1.1.1100) - - Permission-Camera (3.6.1): - - RNPermissions - - Permission-LocationAccuracy (3.6.1): - - RNPermissions - - Permission-LocationAlways (3.6.1): - - RNPermissions - - Permission-LocationWhenInUse (3.6.1): - - RNPermissions - Plaid (4.1.0) - PromisesObjC (2.2.0) - RCT-Folly (2021.07.22.00): @@ -587,7 +579,7 @@ PODS: - React - react-native-image-picker (5.1.0): - React-Core - - react-native-key-command (1.0.1): + - react-native-key-command (1.0.6): - React-Core - react-native-netinfo (9.3.10): - React-Core @@ -730,7 +722,7 @@ PODS: - React-Core - RNCAsyncStorage (1.17.11): - React-Core - - RNCClipboard (1.5.1): + - RNCClipboard (1.12.1): - React-Core - RNCPicker (2.4.4): - React-Core @@ -781,7 +773,7 @@ PODS: - React - React-Core - Turf - - RNPermissions (3.6.1): + - RNPermissions (3.9.3): - React-Core - RNReactNativeHapticFeedback (1.14.0): - React-Core @@ -817,7 +809,7 @@ PODS: - RNScreens (3.21.0): - React-Core - React-RCTImage - - RNSVG (13.13.0): + - RNSVG (13.14.0): - React-Core - SDWebImage (5.11.1): - SDWebImage/Core (= 5.11.1) @@ -867,10 +859,6 @@ DEPENDENCIES: - lottie-react-native (from `../node_modules/lottie-react-native`) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" - OpenSSL-Universal (= 1.1.1100) - - Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`) - - Permission-LocationAccuracy (from `../node_modules/react-native-permissions/ios/LocationAccuracy`) - - Permission-LocationAlways (from `../node_modules/react-native-permissions/ios/LocationAlways`) - - Permission-LocationWhenInUse (from `../node_modules/react-native-permissions/ios/LocationWhenInUse`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) @@ -927,7 +915,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - - "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)" + - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -1018,14 +1006,6 @@ EXTERNAL SOURCES: :path: "../node_modules/lottie-react-native" onfido-react-native-sdk: :path: "../node_modules/@onfido/react-native-sdk" - Permission-Camera: - :path: "../node_modules/react-native-permissions/ios/Camera" - Permission-LocationAccuracy: - :path: "../node_modules/react-native-permissions/ios/LocationAccuracy" - Permission-LocationAlways: - :path: "../node_modules/react-native-permissions/ios/LocationAlways" - Permission-LocationWhenInUse: - :path: "../node_modules/react-native-permissions/ios/LocationWhenInUse" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTRequired: @@ -1135,7 +1115,7 @@ EXTERNAL SOURCES: RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" RNCClipboard: - :path: "../node_modules/@react-native-community/clipboard" + :path: "../node_modules/@react-native-clipboard/clipboard" RNCPicker: :path: "../node_modules/@react-native-picker/picker" RNDateTimePicker: @@ -1217,8 +1197,8 @@ SPEC CHECKSUMS: hermes-engine: 81191603c4eaa01f5e4ae5737a9efcf64756c7b2 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef - lottie-ios: 8f97d3271e155c2d688875c29cd3c74908aef5f8 - lottie-react-native: 8f9d4be452e23f6e5ca0fdc11669dc99ab52be81 + lottie-ios: 25e7b2675dad5c3ddad369ac9baab03560c5bfdd + lottie-react-native: c9f1db4f4124dcce9f8159e65d8dc6e8bcb11fb4 MapboxCommon: 4a0251dd470ee37e7fadda8e285c01921a5e1eb0 MapboxCoreMaps: eb07203bbb0b1509395db5ab89cd3ad6c2e3c04c MapboxMaps: af50ec61a7eb3b032c3f7962c6bd671d93d2a209 @@ -1227,10 +1207,6 @@ SPEC CHECKSUMS: Onfido: e36f284b865adcf99d9c905590a64ac09d4a576b onfido-react-native-sdk: 4ecde1a97435dcff9f00a878e3f8d1eb14fabbdc OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6 - Permission-LocationAccuracy: 76df17de5c6b8bc2eee34e61ee92cdd7a864c73d - Permission-LocationAlways: 8d99b025c9f73c696e0cdb367e42525f2e9a26f2 - Permission-LocationWhenInUse: 3ba99e45c852763f730eabecec2870c2382b7bd4 Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 @@ -1257,7 +1233,7 @@ SPEC CHECKSUMS: react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b - react-native-key-command: c2645ec01eb1fa664606c09480c05cb4220ef67b + react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df react-native-pdf: 7c0e91ada997bac8bac3bb5bea5b6b81f5a3caae @@ -1287,7 +1263,7 @@ SPEC CHECKSUMS: ReactCommon: 4b2bdcb50a3543e1c2b2849ad44533686610826d RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60 - RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495 + RNCClipboard: d77213bfa269013bf4b857b7a9ca37ee062d8ef1 RNCPicker: 0b65be85fe7954fbb2062ef079e3d1cde252d888 RNDateTimePicker: 7658208086d86d09e1627b5c34ba0cf237c60140 RNDeviceInfo: 4701f0bf2a06b34654745053db0ce4cb0c53ada7 @@ -1302,11 +1278,11 @@ SPEC CHECKSUMS: RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64 - RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c + RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: ab2e96c6d5591c3dfbb38a464f54c8d17fb34a87 RNScreens: d037903436160a4b039d32606668350d2a808806 - RNSVG: ed492aaf3af9ca01bc945f7a149d76d62e73ec82 + RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 @@ -1315,6 +1291,6 @@ SPEC CHECKSUMS: Yoga: 3efc43e0d48686ce2e8c60f99d4e6bd349aff981 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: 2daf34c870819a933f3fefe426801d54b2ff2a14 +PODFILE CHECKSUM: ff769666b7221c15936ebc5576a8c8e467dc6879 COCOAPODS: 1.12.1 diff --git a/jest.config.js b/jest.config.js index 6cf44b6b3695..c3125284837a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,7 @@ module.exports = { ], transform: { '^.+\\.jsx?$': 'babel-jest', + '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: ['/node_modules/(?!react-native)/'], testPathIgnorePatterns: ['/node_modules'], diff --git a/jest/setup.js b/jest/setup.js index f03c53540359..4def7d1efad5 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -1,6 +1,7 @@ import 'setimmediate'; import 'react-native-gesture-handler/jestSetup'; import * as reanimatedJestUtils from 'react-native-reanimated/src/reanimated2/jestUtils'; +import mockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; import setupMockImages from './setupMockImages'; setupMockImages(); @@ -10,6 +11,10 @@ reanimatedJestUtils.setUpTests(); // https://reactnavigation.org/docs/testing/#mocking-native-modules jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); +// Clipboard requires mocking as NativeEmitter will be undefined with jest-runner. +// https://github.com/react-native-clipboard/clipboard#mocking-clipboard +jest.mock('@react-native-clipboard/clipboard', () => mockClipboard); + // Mock react-native-onyx storage layer because the SQLite storage layer doesn't work in jest. // Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project, // and we only want to mock the storage layer, not the whole Onyx module. diff --git a/package-lock.json b/package-lock.json index 232deb9732ac..e61b21504b7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.79-4", + "version": "1.3.81-10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.79-4", + "version": "1.3.81-10", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -25,7 +25,7 @@ "@onfido/react-native-sdk": "7.4.0", "@react-native-async-storage/async-storage": "^1.17.10", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-community/clipboard": "^1.5.1", + "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/datetimepicker": "^3.5.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "^9.3.10", @@ -57,7 +57,7 @@ "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^5.1.6", + "lottie-react-native": "^6.3.1", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -90,7 +90,7 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", - "react-native-key-command": "^1.0.5", + "react-native-key-command": "^1.0.6", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", @@ -98,7 +98,7 @@ "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", - "react-native-permissions": "^3.0.1", + "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2", "react-native-plaid-link-sdk": "^10.0.0", "react-native-qrcode-svg": "^6.2.0", @@ -118,6 +118,7 @@ "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", + "react-webcam": "^7.1.1", "react-window": "^1.8.9", "save": "^2.4.0", "semver": "^7.5.2", @@ -209,6 +210,7 @@ "jest-circus": "29.4.1", "jest-cli": "29.4.1", "jest-environment-jsdom": "^29.4.1", + "jest-transformer-svg": "^2.0.1", "metro-react-native-babel-preset": "0.76.8", "mock-fs": "^4.13.0", "onchange": "^7.1.0", @@ -7007,6 +7009,15 @@ "react-native": ">=0.59" } }, + "node_modules/@react-native-clipboard/clipboard": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.12.1.tgz", + "integrity": "sha512-+PNk8kflpGte0W1Nz61/Dp8gHTxyuRjkVyRYBawymSIGTDHCC/zOJSbig6kGIkD8MeaGHC2vGYQJyUyCrgVPBQ==", + "peerDependencies": { + "react": ">=16.0", + "react-native": ">=0.57.0" + } + }, "node_modules/@react-native-community/cli": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-11.3.6.tgz", @@ -8623,16 +8634,6 @@ "node": ">= 4.0.0" } }, - "node_modules/@react-native-community/clipboard": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@react-native-community/clipboard/-/clipboard-1.5.1.tgz", - "integrity": "sha512-AHAmrkLEH5UtPaDiRqoULERHh3oNv7Dgs0bTC0hO5Z2GdNokAMPT5w8ci8aMcRemcwbtdHjxChgtjbeA38GBdA==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.0", - "react-native": ">=0.57.0" - } - }, "node_modules/@react-native-community/datetimepicker": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz", @@ -36490,6 +36491,16 @@ "node": ">=8" } }, + "node_modules/jest-transformer-svg": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jest-transformer-svg/-/jest-transformer-svg-2.0.1.tgz", + "integrity": "sha512-L3j70WjfQtAYXjZi/vyKW8A5pcEUnv7mR0cugSyP6Kqee+fjsMzUHs5UPbnLKH+y7lfSpOjXijMbfEcjLqCuaw==", + "dev": true, + "peerDependencies": { + "jest": ">= 28.1.0", + "react": "^17.0.0 || ^18.0.0" + } + }, "node_modules/jest-util": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.6.3.tgz", @@ -38003,22 +38014,11 @@ "loose-envify": "cli.js" } }, - "node_modules/lottie-ios": { - "version": "3.5.0", - "license": "Apache-2.0", - "peer": true - }, "node_modules/lottie-react-native": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-5.1.6.tgz", - "integrity": "sha512-vhdeZstXMfuVKwnddYWjJgQ/1whGL58IJEJu/iSf0XQ5gAb4pp/+vy91mdYQLezlb8Aw4Vu3fKnqErJL2hwchg==", - "license": "Apache-2.0", - "dependencies": { - "invariant": "^2.2.2", - "react-native-safe-modules": "^1.0.3" - }, + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", + "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", "peerDependencies": { - "lottie-ios": "^3.4.0", "react": "*", "react-native": ">=0.46", "react-native-windows": ">=0.63.x" @@ -38029,24 +38029,6 @@ } } }, - "node_modules/lottie-react-native/node_modules/dedent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.6.0.tgz", - "integrity": "sha512-cSfRWjXJtZQeRuZGVvDrJroCR5V2UvBNUMHsPCdNYzuAG8b9V8aAy3KUcdQrGQPXs17Y+ojbPh1aOCplg9YR9g==", - "license": "MIT" - }, - "node_modules/lottie-react-native/node_modules/react-native-safe-modules": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/react-native-safe-modules/-/react-native-safe-modules-1.0.3.tgz", - "integrity": "sha512-DUxti4Z+AgJ/ZsO5U7p3uSCUBko8JT8GvFlCeOXk9bMd+4qjpoDvMYpfbixXKgL88M+HwmU/KI1YFN6gsQZyBA==", - "license": "MIT", - "dependencies": { - "dedent": "^0.6.0" - }, - "peerDependencies": { - "react-native": "*" - } - }, "node_modules/lottie-web": { "version": "5.10.2", "license": "MIT" @@ -44647,9 +44629,9 @@ "license": "MIT" }, "node_modules/react-native-key-command": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.5.tgz", - "integrity": "sha512-SJWf1e8f3yGFrFDNCmJ+aiGmnwokGgtMicfvuyukhQtXkncCQb9pBI4uhBen0Bd30uMmUDgGAA9O56OyIdf5jw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.6.tgz", + "integrity": "sha512-N+/kmGMSnvTOF5DXupGg9ztkeOXjre//+Z+Mv4RU8RLYNvW7TtDgHlOxl4AngeGD1pG5gbI6hrlUukrRSCs6Ng==", "dependencies": { "eventemitter3": "^5.0.1", "underscore": "^1.13.4" @@ -44788,8 +44770,9 @@ } }, "node_modules/react-native-permissions": { - "version": "3.6.1", - "license": "MIT", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.9.3.tgz", + "integrity": "sha512-2UqG2Em4xHxLq0E1XynXMdQ//XZltxVUjTn/i4fPIZuuZ0cQ+ydAQmLXqDPxOXvG0sICwc3oe0orJmQdqpa1sQ==", "peerDependencies": { "react": ">=16.13.1", "react-native": ">=0.63.3", @@ -57959,6 +57942,12 @@ "integrity": "sha512-SMEhc+2hQWubwzxR6Zac0CmrJ2rdoHHBo0ibG2iNMsxR0dnU5AdRGnYF/tyK9i20/i7ZNxn+qsEJ69shpkd6gg==", "requires": {} }, + "@react-native-clipboard/clipboard": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.12.1.tgz", + "integrity": "sha512-+PNk8kflpGte0W1Nz61/Dp8gHTxyuRjkVyRYBawymSIGTDHCC/zOJSbig6kGIkD8MeaGHC2vGYQJyUyCrgVPBQ==", + "requires": {} + }, "@react-native-community/cli": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-11.3.6.tgz", @@ -59179,12 +59168,6 @@ "joi": "^17.2.1" } }, - "@react-native-community/clipboard": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@react-native-community/clipboard/-/clipboard-1.5.1.tgz", - "integrity": "sha512-AHAmrkLEH5UtPaDiRqoULERHh3oNv7Dgs0bTC0hO5Z2GdNokAMPT5w8ci8aMcRemcwbtdHjxChgtjbeA38GBdA==", - "requires": {} - }, "@react-native-community/datetimepicker": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz", @@ -79327,6 +79310,13 @@ } } }, + "jest-transformer-svg": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jest-transformer-svg/-/jest-transformer-svg-2.0.1.tgz", + "integrity": "sha512-L3j70WjfQtAYXjZi/vyKW8A5pcEUnv7mR0cugSyP6Kqee+fjsMzUHs5UPbnLKH+y7lfSpOjXijMbfEcjLqCuaw==", + "dev": true, + "requires": {} + }, "jest-util": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.6.3.tgz", @@ -80370,33 +80360,11 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, - "lottie-ios": { - "version": "3.5.0", - "peer": true - }, "lottie-react-native": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-5.1.6.tgz", - "integrity": "sha512-vhdeZstXMfuVKwnddYWjJgQ/1whGL58IJEJu/iSf0XQ5gAb4pp/+vy91mdYQLezlb8Aw4Vu3fKnqErJL2hwchg==", - "requires": { - "invariant": "^2.2.2", - "react-native-safe-modules": "^1.0.3" - }, - "dependencies": { - "dedent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.6.0.tgz", - "integrity": "sha512-cSfRWjXJtZQeRuZGVvDrJroCR5V2UvBNUMHsPCdNYzuAG8b9V8aAy3KUcdQrGQPXs17Y+ojbPh1aOCplg9YR9g==" - }, - "react-native-safe-modules": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/react-native-safe-modules/-/react-native-safe-modules-1.0.3.tgz", - "integrity": "sha512-DUxti4Z+AgJ/ZsO5U7p3uSCUBko8JT8GvFlCeOXk9bMd+4qjpoDvMYpfbixXKgL88M+HwmU/KI1YFN6gsQZyBA==", - "requires": { - "dedent": "^0.6.0" - } - } - } + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", + "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "requires": {} }, "lottie-web": { "version": "5.10.2" @@ -85249,9 +85217,9 @@ "from": "react-native-image-size@git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b" }, "react-native-key-command": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.5.tgz", - "integrity": "sha512-SJWf1e8f3yGFrFDNCmJ+aiGmnwokGgtMicfvuyukhQtXkncCQb9pBI4uhBen0Bd30uMmUDgGAA9O56OyIdf5jw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.6.tgz", + "integrity": "sha512-N+/kmGMSnvTOF5DXupGg9ztkeOXjre//+Z+Mv4RU8RLYNvW7TtDgHlOxl4AngeGD1pG5gbI6hrlUukrRSCs6Ng==", "requires": { "eventemitter3": "^5.0.1", "underscore": "^1.13.4" @@ -85324,7 +85292,9 @@ "requires": {} }, "react-native-permissions": { - "version": "3.6.1", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.9.3.tgz", + "integrity": "sha512-2UqG2Em4xHxLq0E1XynXMdQ//XZltxVUjTn/i4fPIZuuZ0cQ+ydAQmLXqDPxOXvG0sICwc3oe0orJmQdqpa1sQ==", "requires": {} }, "react-native-picker-select": { diff --git a/package.json b/package.json index b415007d13b2..57060257f0e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.79-4", + "version": "1.3.81-10", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -68,7 +68,7 @@ "@onfido/react-native-sdk": "7.4.0", "@react-native-async-storage/async-storage": "^1.17.10", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-community/clipboard": "^1.5.1", + "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/datetimepicker": "^3.5.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "^9.3.10", @@ -100,7 +100,7 @@ "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^5.1.6", + "lottie-react-native": "^6.3.1", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -114,8 +114,8 @@ "react-collapse": "^5.1.0", "react-content-loader": "^6.1.0", "react-dom": "18.1.0", - "react-map-gl": "^7.1.3", "react-error-boundary": "^4.0.11", + "react-map-gl": "^7.1.3", "react-native": "0.72.4", "react-native-android-location-enabler": "^1.2.2", "react-native-blob-util": "^0.17.3", @@ -133,7 +133,7 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", - "react-native-key-command": "^1.0.5", + "react-native-key-command": "^1.0.6", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", @@ -141,7 +141,7 @@ "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", - "react-native-permissions": "^3.0.1", + "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2", "react-native-plaid-link-sdk": "^10.0.0", "react-native-qrcode-svg": "^6.2.0", @@ -161,6 +161,7 @@ "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", + "react-webcam": "^7.1.1", "react-window": "^1.8.9", "save": "^2.4.0", "semver": "^7.5.2", @@ -252,6 +253,7 @@ "jest-circus": "29.4.1", "jest-cli": "29.4.1", "jest-environment-jsdom": "^29.4.1", + "jest-transformer-svg": "^2.0.1", "metro-react-native-babel-preset": "0.76.8", "mock-fs": "^4.13.0", "onchange": "^7.1.0", diff --git a/src/CONST.ts b/src/CONST.ts index 23957827d140..58fdea7654cb 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -108,6 +108,7 @@ const CONST = { // Sizes needed for report empty state background image handling EMPTY_STATE_BACKGROUND: { + ASPECT_RATIO: 3.72, SMALL_SCREEN: { IMAGE_HEIGHT: 300, CONTAINER_MINHEIGHT: 200, @@ -140,6 +141,7 @@ const CONST = { MONTH_DAY_ABBR_FORMAT: 'MMM d', SHORT_DATE_FORMAT: 'MM-dd', MONTH_DAY_YEAR_ABBR_FORMAT: 'MMM d, yyyy', + MONTH_DAY_YEAR_FORMAT: 'MMMM d, yyyy', FNS_TIMEZONE_FORMAT_STRING: "yyyy-MM-dd'T'HH:mm:ssXXX", FNS_DB_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss.SSS', LONG_DATE_FORMAT_WITH_WEEKDAY: 'eeee, MMMM d, yyyy', @@ -234,7 +236,6 @@ const CONST = { BETA_EXPENSIFY_WALLET: 'expensifyWallet', BETA_COMMENT_LINKING: 'commentLinking', INTERNATIONALIZATION: 'internationalization', - IOU_SEND: 'sendMoney', POLICY_ROOMS: 'policyRooms', PASSWORDLESS: 'passwordless', TASKS: 'tasks', @@ -304,7 +305,7 @@ const CONST = { }, type: KEYBOARD_SHORTCUT_NAVIGATION_TYPE, }, - SHORTCUT_MODAL: { + SHORTCUTS: { descriptionKey: 'openShortcutDialog', shortcutKey: 'J', modifiers: ['CTRL'], @@ -1264,7 +1265,7 @@ const CONST = { CARD_NUMBER: /^[0-9]{15,16}$/, CARD_SECURITY_CODE: /^[0-9]{3,4}$/, CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/, - ROOM_NAME: /^#[a-z0-9à-ÿ-]{1,80}$/, + ROOM_NAME: /^#[\p{Ll}0-9-]{1,80}$/u, // eslint-disable-next-line max-len, no-misleading-character-class EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu, @@ -1475,6 +1476,15 @@ const CONST = { MAKE_REQUEST_WITH_SIDE_EFFECTS: 'makeRequestWithSideEffects', }, + ERECEIPT_COLORS: { + YELLOW: 'Yellow', + ICE: 'Ice', + BLUE: 'Blue', + GREEN: 'Green', + TANGERINE: 'Tangerine', + PINK: 'Pink', + }, + MAP_PADDING: 50, MAP_MARKER_SIZE: 20, @@ -2737,6 +2747,12 @@ const CONST = { }, MISSING_TRANSLATION: 'MISSING TRANSLATION', + SEARCH_MAX_LENGTH: 500, + + /** + * The count of characters we'll allow the user to type after reaching SEARCH_MAX_LENGTH in an input. + */ + ADDITIONAL_ALLOWED_CHARACTERS: 20, } as const; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 9e6ae1ff27b4..642b8ceb456c 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -26,7 +26,6 @@ import Navigation from './libs/Navigation/Navigation'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; import SplashScreenHider from './components/SplashScreenHider'; -import KeyboardShortcutsModal from './components/KeyboardShortcutsModal'; import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; @@ -194,7 +193,6 @@ function Expensify(props) { {shouldInit && ( <> - diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0a17d3a1d2f7..f7c4a11bc52f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -169,9 +169,6 @@ const ONYXKEYS = { /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', - /** Is Keyboard shortcuts modal open? */ - IS_SHORTCUTS_MODAL_OPEN: 'isShortcutsModalOpen', - /** Is the test tools modal open? */ IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen', @@ -252,6 +249,8 @@ const ONYXKEYS = { REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', + SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_', + PRIVATE_NOTES_DRAFT: 'privateNotesDraft_', // Manual request tab selector SELECTED_TAB: 'selectedTab_', @@ -291,6 +290,7 @@ const ONYXKEYS = { PRIVATE_NOTES_FORM: 'privateNotesForm', I_KNOW_A_TEACHER_FORM: 'iKnowTeacherForm', INTRO_SCHOOL_PRINCIPAL_FORM: 'introSchoolPrincipalForm', + REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm', }, } as const; @@ -351,7 +351,6 @@ type OnyxValues = { [ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID]: string; [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; - [ONYXKEYS.IS_SHORTCUTS_MODAL_OPEN]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2b64dd9c5465..7127c1483c26 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -75,11 +75,19 @@ export default { route: '/settings/wallet/card/:domain', getRoute: (domain: string) => `/settings/wallet/card/${domain}`, }, + SETTINGS_REPORT_FRAUD: { + route: '/settings/wallet/cards/:domain/report-virtual-fraud', + getRoute: (domain: string) => `/settings/wallet/cards/${domain}/report-virtual-fraud`, + }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', + SETTINGS_WALLET_CARD_ACTIVATE: { + route: 'settings/wallet/cards/:domain/activate', + getRoute: (domain: string) => `settings/wallet/cards/${domain}/activate`, + }, SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: 'settings/profile/personal-details/date-of-birth', @@ -104,6 +112,8 @@ export default { SETTINGS_STATUS: 'settings/profile/status', SETTINGS_STATUS_SET: 'settings/profile/status/set', + KEYBOARD_SHORTCUTS: 'keyboard-shortcuts', + NEW: 'new', NEW_CHAT: 'new/chat', NEW_ROOM: 'new/room', @@ -161,6 +171,14 @@ export default { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`, }, + EDIT_SPLIT_BILL: { + route: `r/:reportID/split/:reportActionID/edit/:field`, + getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}`, + }, + EDIT_SPLIT_BILL_CURRENCY: { + route: 'r/:reportID/split/:reportActionID/edit/currency', + getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}`, + }, TASK_TITLE: { route: 'r/:reportID/title', getRoute: (reportID: string) => `r/${reportID}/title`, @@ -286,6 +304,10 @@ export default { route: 'workspace/:policyID/settings', getRoute: (policyID: string) => `workspace/${policyID}/settings`, }, + WORKSPACE_SETTINGS_CURRENCY: { + route: 'workspace/:policyID/settings/currency', + getRoute: (policyID: string) => `workspace/${policyID}/settings/currency`, + }, WORKSPACE_CARD: { route: 'workspace/:policyID/card', getRoute: (policyID: string) => `workspace/${policyID}/card`, diff --git a/src/components/AnonymousReportFooter.js b/src/components/AnonymousReportFooter.js index 034d14eb508b..dd1a0864b0cf 100644 --- a/src/components/AnonymousReportFooter.js +++ b/src/components/AnonymousReportFooter.js @@ -6,9 +6,9 @@ import AvatarWithDisplayName from './AvatarWithDisplayName'; import ExpensifyWordmark from './ExpensifyWordmark'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import reportPropTypes from '../pages/reportPropTypes'; -import CONST from '../CONST'; import styles from '../styles/styles'; import * as Session from '../libs/actions/Session'; +import participantPropTypes from './participantPropTypes'; const propTypes = { /** The report currently being looked at */ @@ -16,12 +16,16 @@ const propTypes = { isSmallSizeLayout: PropTypes.bool, + /** Personal details of all the users */ + personalDetails: PropTypes.objectOf(participantPropTypes), + ...withLocalizePropTypes, }; const defaultProps = { report: {}, isSmallSizeLayout: false, + personalDetails: {}, }; function AnonymousReportFooter(props) { @@ -30,7 +34,7 @@ function AnonymousReportFooter(props) { diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 1cc12fca24ae..b8bfb4c36122 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -31,11 +31,14 @@ import useWindowDimensions from '../hooks/useWindowDimensions'; import Navigation from '../libs/Navigation/Navigation'; import ROUTES from '../ROUTES'; import useNativeDriver from '../libs/useNativeDriver'; -import * as ReportUtils from '../libs/ReportUtils'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; +import * as ReportUtils from '../libs/ReportUtils'; import ONYXKEYS from '../ONYXKEYS'; import * as Policy from '../libs/actions/Policy'; import useNetwork from '../hooks/useNetwork'; +import * as IOU from '../libs/actions/IOU'; +import transactionPropTypes from './transactionPropTypes'; +import * as TransactionUtils from '../libs/TransactionUtils'; /** * Modal render prop component that exposes modal launching triggers that can be used @@ -79,6 +82,9 @@ const propTypes = { /** The report that has this attachment */ report: reportPropTypes, + /** The transaction associated with the receipt attachment, if any */ + transaction: transactionPropTypes, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -97,6 +103,7 @@ const defaultProps = { allowDownload: false, headerTitle: null, report: {}, + transaction: {}, onModalShow: () => {}, onModalHide: () => {}, onCarouselAttachmentChange: () => {}, @@ -108,6 +115,7 @@ function AttachmentModal(props) { const [isModalOpen, setIsModalOpen] = useState(props.defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired); const [isAttachmentReceipt, setIsAttachmentReceipt] = useState(false); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); @@ -205,12 +213,22 @@ function AttachmentModal(props) { }, [isModalOpen, isConfirmButtonDisabled, props.onConfirm, file, source]); /** - * Close the confirm modal. + * Close the confirm modals. */ const closeConfirmModal = useCallback(() => { setIsAttachmentInvalid(false); + setIsDeleteReceiptConfirmModalVisible(false); }, []); + /** + * Detach the receipt and close the modal. + */ + const deleteAndCloseModal = useCallback(() => { + IOU.detachReceipt(props.transaction.transactionID, props.report.reportID); + setIsDeleteReceiptConfirmModalVisible(false); + Navigation.dismissModal(props.report.reportID); + }, [props.transaction, props.report]); + /** * @param {Object} _file * @returns {Boolean} @@ -358,9 +376,18 @@ function AttachmentModal(props) { text: props.translate('common.download'), onSelected: () => downloadAttachment(source), }); + if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction)) { + menuItems.push({ + icon: Expensicons.Trashcan, + text: props.translate('receipt.deleteReceipt'), + onSelected: () => { + setIsDeleteReceiptConfirmModalVisible(true); + }, + }); + } return menuItems; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAttachmentReceipt, props.parentReport, props.parentReportActions, props.policy]); + }, [isAttachmentReceipt, props.parentReport, props.parentReportActions, props.policy, props.transaction]); return ( <> @@ -442,18 +469,30 @@ function AttachmentModal(props) { )} )} + {isAttachmentReceipt ? ( + + ) : ( + + )} - - {props.children && props.children({ displayFileInModal: validateAndDisplayFileToUpload, @@ -470,6 +509,16 @@ export default compose( withWindowDimensions, withLocalize, withOnyx({ + transaction: { + key: ({report}) => { + if (!report) { + return undefined; + } + const parentReportAction = ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID); + const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0); + return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + }, + }, parentReport: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`, }, diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index 8b1bb54da920..063314a4268c 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -1,10 +1,12 @@ import _ from 'underscore'; import React, {useState, useRef, useCallback, useMemo} from 'react'; -import {View, Alert, Linking} from 'react-native'; +import PropTypes from 'prop-types'; +import {View, Alert} from 'react-native'; import RNDocumentPicker from 'react-native-document-picker'; import RNFetchBlob from 'react-native-blob-util'; +import lodashCompact from 'lodash/compact'; import {launchImageLibrary} from 'react-native-image-picker'; -import {propTypes as basePropTypes, defaultProps} from './attachmentPickerPropTypes'; +import {propTypes as basePropTypes, defaultProps as baseDefaultProps} from './attachmentPickerPropTypes'; import CONST from '../../CONST'; import * as FileUtils from '../../libs/fileDownload/FileUtils'; import * as Expensicons from '../Icon/Expensicons'; @@ -19,6 +21,14 @@ import useArrowKeyFocusManager from '../../hooks/useArrowKeyFocusManager'; const propTypes = { ...basePropTypes, + + /** If this value is true, then we exclude Camera option. */ + shouldHideCameraOption: PropTypes.bool, +}; + +const defaultProps = { + ...baseDefaultProps, + shouldHideCameraOption: false, }; /** @@ -90,7 +100,7 @@ const getDataForUpload = (fileData) => { * @param {propTypes} props * @returns {JSX.Element} */ -function AttachmentPicker({type, children}) { +function AttachmentPicker({type, children, shouldHideCameraOption}) { const [isVisible, setIsVisible] = useState(false); const completeAttachmentSelection = useRef(); @@ -100,27 +110,6 @@ function AttachmentPicker({type, children}) { const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); - /** - * Inform the users when they need to grant camera access and guide them to settings - */ - const showPermissionsAlert = useCallback(() => { - Alert.alert( - translate('attachmentPicker.cameraPermissionRequired'), - translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'), - [ - { - text: translate('common.cancel'), - style: 'cancel', - }, - { - text: translate('common.settings'), - onPress: () => Linking.openSettings(), - }, - ], - {cancelable: false}, - ); - }, [translate]); - /** * A generic handling when we don't know the exact reason for an error */ @@ -145,7 +134,7 @@ function AttachmentPicker({type, children}) { if (response.errorCode) { switch (response.errorCode) { case 'permission': - showPermissionsAlert(); + FileUtils.showCameraPermissionsAlert(); return resolve(); default: showGeneralAlert(); @@ -158,7 +147,7 @@ function AttachmentPicker({type, children}) { return resolve(response.assets); }); }), - [showGeneralAlert, showPermissionsAlert, type], + [showGeneralAlert, type], ); /** @@ -180,8 +169,8 @@ function AttachmentPicker({type, children}) { ); const menuItemData = useMemo(() => { - const data = [ - { + const data = lodashCompact([ + !shouldHideCameraOption && { icon: Expensicons.Camera, textTranslationKey: 'attachmentPicker.takePhoto', pickAttachment: () => showImagePicker(launchCamera), @@ -191,18 +180,15 @@ function AttachmentPicker({type, children}) { textTranslationKey: 'attachmentPicker.chooseFromGallery', pickAttachment: () => showImagePicker(launchImageLibrary), }, - ]; - - if (type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { - data.push({ + type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE && { icon: Expensicons.Paperclip, textTranslationKey: 'attachmentPicker.chooseDocument', pickAttachment: showDocumentPicker, - }); - } + }, + ]); return data; - }, [showDocumentPicker, showImagePicker, type]); + }, [showDocumentPicker, showImagePicker, type, shouldHideCameraOption]); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); diff --git a/src/components/BaseMiniContextMenuItem.js b/src/components/BaseMiniContextMenuItem.js index c5c0892324c3..3c423ffc80ea 100644 --- a/src/components/BaseMiniContextMenuItem.js +++ b/src/components/BaseMiniContextMenuItem.js @@ -73,6 +73,7 @@ function BaseMiniContextMenuItem(props) { style={({hovered, pressed}) => [ styles.reportActionContextMenuMiniButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, props.isDelayButtonStateComplete)), + props.isDelayButtonStateComplete && styles.cursorDefault, ]} > {(pressableState) => ( diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index ef8b1d71ad1d..13abf057e4b1 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -32,6 +32,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC }, [selectedCategory]); const sections = useMemo(() => { + const validPolicyRecentlyUsedCategories = _.filter(policyRecentlyUsedCategories, (p) => !_.isEmpty(p)); const {categoryOptions} = OptionsListUtils.getFilteredOptions( {}, {}, @@ -43,7 +44,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC false, true, policyCategories, - policyRecentlyUsedCategories, + validPolicyRecentlyUsedCategories, false, ); diff --git a/src/components/ConfirmationPage.js b/src/components/ConfirmationPage.js index ffa3c780f154..4549d6ca6072 100644 --- a/src/components/ConfirmationPage.js +++ b/src/components/ConfirmationPage.js @@ -1,7 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; -import Lottie from 'lottie-react-native'; +import Lottie from './Lottie'; import * as LottieAnimations from './LottieAnimations'; import Text from './Text'; import styles from '../styles/styles'; diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js index 2314f2fcf64e..4c740caea78a 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.js @@ -100,6 +100,7 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, style={getContextMenuItemStyles(windowWidth)} isAnonymousAction={isAnonymousAction} focused={isFocused} + interactive={isThrottledButtonActive} /> ); } diff --git a/src/components/EReceipt.js b/src/components/EReceipt.js new file mode 100644 index 000000000000..e6b3a9809c7e --- /dev/null +++ b/src/components/EReceipt.js @@ -0,0 +1,107 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import ONYXKEYS from '../ONYXKEYS'; +import * as StyleUtils from '../styles/StyleUtils'; +import transactionPropTypes from './transactionPropTypes'; +import styles from '../styles/styles'; +import * as Expensicons from './Icon/Expensicons'; +import Icon from './Icon'; +import Text from './Text'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; +import * as CardUtils from '../libs/CardUtils'; +import variables from '../styles/variables'; +import useLocalize from '../hooks/useLocalize'; +import EReceiptThumbnail from './EReceiptThumbnail'; +import CONST from '../CONST'; + +const propTypes = { + /* TransactionID of the transaction this EReceipt corresponds to */ + transactionID: PropTypes.string.isRequired, + + /* Onyx Props */ + transaction: transactionPropTypes, +}; + +const defaultProps = { + transaction: {}, +}; + +function EReceipt({transaction, transactionID}) { + const {translate} = useLocalize(); + + // Get receipt colorway, or default to Yellow. + const {backgroundColor: primaryColor, color: secondaryColor} = StyleUtils.getEReceiptColorStyles(StyleUtils.getEReceiptColorCode(transaction)); + + const { + amount: transactionAmount, + currency: transactionCurrency, + merchant: transactionMerchant, + created: transactionDate, + cardID: transactionCardID, + } = ReportUtils.getTransactionDetails(transaction, CONST.DATE.MONTH_DAY_YEAR_FORMAT); + const formattedAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); + const currency = CurrencyUtils.getCurrencySymbol(transactionCurrency); + const amount = formattedAmount.replace(currency, ''); + const cardDescription = CardUtils.getCardDescription(transactionCardID); + + const secondaryTextColorStyle = StyleUtils.getColorStyle(secondaryColor); + + return ( + + + + + + + + + + + + {currency} + + + {amount} + + + {transactionMerchant} + + + + {translate('eReceipt.transactionDate')} + {transactionDate} + + + {translate('common.card')} + {cardDescription} + + + + + {translate('eReceipt.guaranteed')} + + + + ); +} + +EReceipt.displayName = 'EReceipt'; +EReceipt.propTypes = propTypes; +EReceipt.defaultProps = defaultProps; + +export default withOnyx({ + transaction: { + key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + }, +})(EReceipt); diff --git a/src/components/EReceiptThumbnail.js b/src/components/EReceiptThumbnail.js new file mode 100644 index 000000000000..f1bb5b025e2f --- /dev/null +++ b/src/components/EReceiptThumbnail.js @@ -0,0 +1,124 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import ONYXKEYS from '../ONYXKEYS'; +import * as StyleUtils from '../styles/StyleUtils'; +import transactionPropTypes from './transactionPropTypes'; +import styles from '../styles/styles'; +import * as Expensicons from './Icon/Expensicons'; +import * as MCCIcons from './Icon/MCCIcons'; +import Icon from './Icon'; +import * as ReportUtils from '../libs/ReportUtils'; +import variables from '../styles/variables'; +import * as eReceiptBGs from './Icon/EReceiptBGs'; +import Image from './Image'; +import CONST from '../CONST'; + +const propTypes = { + /* TransactionID of the transaction this EReceipt corresponds to */ + // eslint-disable-next-line react/no-unused-prop-types + transactionID: PropTypes.string.isRequired, + + /* Onyx Props */ + transaction: transactionPropTypes, +}; + +const defaultProps = { + transaction: {}, +}; + +const backgroundImages = { + [CONST.ERECEIPT_COLORS.YELLOW]: eReceiptBGs.EReceiptBG_Yellow, + [CONST.ERECEIPT_COLORS.ICE]: eReceiptBGs.EReceiptBG_Ice, + [CONST.ERECEIPT_COLORS.BLUE]: eReceiptBGs.EReceiptBG_Blue, + [CONST.ERECEIPT_COLORS.GREEN]: eReceiptBGs.EReceiptBG_Green, + [CONST.ERECEIPT_COLORS.TANGERINE]: eReceiptBGs.EReceiptBG_Tangerine, + [CONST.ERECEIPT_COLORS.PINK]: eReceiptBGs.EReceiptBG_Pink, +}; + +function getBackgroundImage(transaction) { + return backgroundImages[StyleUtils.getEReceiptColorCode(transaction)]; +} + +function EReceiptThumbnail({transaction}) { + // Get receipt colorway, or default to Yellow. + const {backgroundColor: primaryColor, color: secondaryColor} = StyleUtils.getEReceiptColorStyles(StyleUtils.getEReceiptColorCode(transaction)); + + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + + const onContainerLayout = (event) => { + const {width, height} = event.nativeEvent.layout; + setContainerWidth(width); + setContainerHeight(height); + }; + + const {mccGroup: transactionMCCGroup} = ReportUtils.getTransactionDetails(transaction); + const MCCIcon = MCCIcons[`${transactionMCCGroup}`]; + + const isSmall = containerWidth && containerWidth < variables.eReceiptThumbnailSmallBreakpoint; + const isMedium = containerWidth && containerWidth < variables.eReceiptThumbnailMediumBreakpoint; + + let receiptIconWidth = variables.eReceiptIconWidth; + let receiptIconHeight = variables.eReceiptIconHeight; + let receiptMCCSize = variables.eReceiptMCCHeightWidth; + + if (isSmall) { + receiptIconWidth = variables.eReceiptIconWidthSmall; + receiptIconHeight = variables.eReceiptIconHeightSmall; + receiptMCCSize = variables.eReceiptMCCHeightWidthSmall; + } else if (isMedium) { + receiptIconWidth = variables.eReceiptIconWidthMedium; + receiptIconHeight = variables.eReceiptIconHeightMedium; + receiptMCCSize = variables.eReceiptMCCHeightWidthMedium; + } + + return ( + + + + + + {MCCIcon ? ( + + ) : null} + + + + ); +} + +EReceiptThumbnail.displayName = 'EReceiptThumbnail'; +EReceiptThumbnail.propTypes = propTypes; +EReceiptThumbnail.defaultProps = defaultProps; + +export default withOnyx({ + transaction: { + key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + }, +})(EReceiptThumbnail); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index e7af97145347..3dfc5f59bb38 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -195,7 +195,7 @@ class EmojiPickerMenu extends Component { return; } const emoji = lodashGet(item, ['types', this.props.preferredSkinTone], item.code); - this.addToFrequentAndSelectEmoji(emoji, item); + this.props.onEmojiSelected(emoji, item); return; } @@ -258,16 +258,6 @@ class EmojiPickerMenu extends Component { document.removeEventListener('mousemove', this.mouseMoveHandler); } - /** - * @param {String} emoji - * @param {Object} emojiObject - */ - addToFrequentAndSelectEmoji(emoji, emojiObject) { - const frequentEmojiList = EmojiUtils.getFrequentlyUsedEmojis(emojiObject); - User.updateFrequentlyUsedEmojis(frequentEmojiList); - this.props.onEmojiSelected(emoji, emojiObject); - } - /** * Focuses the search Input and has the text selected */ @@ -466,7 +456,7 @@ class EmojiPickerMenu extends Component { return ( this.addToFrequentAndSelectEmoji(emoji, item)} + onPress={(emoji) => this.props.onEmojiSelected(emoji, item)} onHoverIn={() => this.setState({highlightedIndex: index, isUsingKeyboardMovement: false})} onHoverOut={() => { if (this.state.arePointerEventsDisabled) { diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index dd5c18439cc1..fe8c3e275ad2 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -86,16 +86,6 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t setHeaderIndices(undefined); }, 300); - /** - * @param {String} emoji - * @param {Object} emojiObject - */ - const addToFrequentAndSelectEmoji = (emoji, emojiObject) => { - const frequentEmojiList = EmojiUtils.getFrequentlyUsedEmojis(emojiObject); - User.updateFrequentlyUsedEmojis(frequentEmojiList); - onEmojiSelected(emoji, emojiObject); - }; - /** * @param {Number} skinTone */ @@ -152,7 +142,7 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t return ( addToFrequentAndSelectEmoji(emoji, item))} + onPress={singleExecution((emoji) => onEmojiSelected(emoji, item))} emoji={emojiCode} /> ); diff --git a/src/components/EmojiPicker/EmojiSkinToneList.js b/src/components/EmojiPicker/EmojiSkinToneList.js index a239f197cdd0..0c876910e746 100644 --- a/src/components/EmojiPicker/EmojiSkinToneList.js +++ b/src/components/EmojiPicker/EmojiSkinToneList.js @@ -46,11 +46,11 @@ function EmojiSkinToneList(props) { {!isSkinToneListVisible && ( - + {currentSkinTone.code} {props.translate('emojiPicker.skinTonePickerLabel')} diff --git a/src/components/Form.js b/src/components/Form.js index 9836bd818536..b4e639dcf964 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -76,6 +76,10 @@ const propTypes = { /** Container styles */ style: stylePropTypes, + /** Submit button container styles */ + // eslint-disable-next-line react/forbid-prop-types + submitButtonStyles: PropTypes.arrayOf(PropTypes.object), + /** Custom content to display in the footer after submit button */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), @@ -98,6 +102,7 @@ const defaultProps = { shouldValidateOnBlur: true, footerContent: null, style: [], + submitButtonStyles: [], validate: () => ({}), }; @@ -447,7 +452,7 @@ function Form(props) { focusInput.focus(); } }} - containerStyles={[styles.mh0, styles.mt5, styles.flex1]} + containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...props.submitButtonStyles]} enabledWhenOffline={props.enabledWhenOffline} isSubmitActionDangerous={props.isSubmitActionDangerous} disablePressOnEnter @@ -472,6 +477,7 @@ function Form(props) { props.isSubmitActionDangerous, props.isSubmitButtonVisible, props.submitButtonText, + props.submitButtonStyles, ], ); diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 5261d1258ad0..76471aeab51a 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -106,7 +106,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c const onValidate = useCallback( (values) => { - const validateErrors = validate(values); + const validateErrors = validate(values) || {}; setErrors(validateErrors); return validateErrors; }, diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index bba62cc4f4e0..c806bedc31c7 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -5,8 +5,8 @@ import PropTypes from 'prop-types'; import htmlRenderers from './HTMLRenderers'; import * as HTMLEngineUtils from './htmlEngineUtils'; import styles from '../../styles/styles'; -import fontFamily from '../../styles/fontFamily'; import convertToLTR from '../../libs/convertToLTR'; +import singleFontFamily from '../../styles/fontFamily/singleFontFamily'; const propTypes = { /** Whether text elements should be selectable */ @@ -60,18 +60,13 @@ function BaseHTMLEngineProvider(props) { // We need to memoize this prop to make it referentially stable. const defaultTextProps = useMemo(() => ({selectable: props.textSelectable, allowFontScaling: false, textBreakStrategy: 'simple'}), [props.textSelectable]); - // We need to pass multiple system-specific fonts for emojis but - // we can't apply multiple fonts at once so we need to pass fallback fonts. - const fallbackFonts = {'ExpensifyNeue-Regular': fontFamily.EXP_NEUE}; - return ( (text.data = convertToLTR(text.data)), diff --git a/src/components/HeaderPageLayout.js b/src/components/HeaderPageLayout.js index 37c7eb18dad1..adfe0a8784e2 100644 --- a/src/components/HeaderPageLayout.js +++ b/src/components/HeaderPageLayout.js @@ -31,16 +31,26 @@ const propTypes = { /** Style to apply to the header image container */ // eslint-disable-next-line react/forbid-prop-types headerContainerStyles: PropTypes.arrayOf(PropTypes.object), + + /** Style to apply to the ScrollView container */ + // eslint-disable-next-line react/forbid-prop-types + scrollViewContainerStyles: PropTypes.arrayOf(PropTypes.object), + + /** Style to apply to the children container */ + // eslint-disable-next-line react/forbid-prop-types + childrenContainerStyles: PropTypes.arrayOf(PropTypes.object), }; const defaultProps = { backgroundColor: themeColors.appBG, header: null, headerContainerStyles: [], + scrollViewContainerStyles: [], + childrenContainerStyles: [], footer: null, }; -function HeaderPageLayout({backgroundColor, children, footer, headerContainerStyles, style, headerContent, ...propsToPassToHeader}) { +function HeaderPageLayout({backgroundColor, children, footer, headerContainerStyles, scrollViewContainerStyles, childrenContainerStyles, style, headerContent, ...propsToPassToHeader}) { const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); const {isOffline} = useNetwork(); const appBGColor = StyleUtils.getBackgroundColorStyle(themeColors.appBG); @@ -77,14 +87,14 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty )} {!Browser.isSafari() && } {headerContent} - {children} + {children} {!_.isNull(footer) && {footer}} diff --git a/src/components/IFrame.js b/src/components/IFrame.js index 129af4254c42..5f7f657b0c09 100644 --- a/src/components/IFrame.js +++ b/src/components/IFrame.js @@ -1,5 +1,8 @@ /* eslint-disable es/no-nullish-coalescing-operators */ import React, {useEffect, useState} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import ONYXKEYS from '../ONYXKEYS'; function getNewDotURL(url) { const urlObj = new URL(url); @@ -50,6 +53,11 @@ function getOldDotURL(url) { const pathname = urlObj.pathname; const paths = pathname.slice(1).split('/'); + // TODO: temporary measure until linking config is adjusted + if (pathname.startsWith('/r')) { + return 'inbox'; + } + if (pathname === 'home') { return 'inbox'; } @@ -78,8 +86,19 @@ function getOldDotURL(url) { return pathname; } -export default function ReportScreen() { - const [oldDotURL, setOldDotURL] = useState('https://www.expensify.com.dev'); +const propTypes = { + // The session of the logged in person + session: PropTypes.shape({ + // The email of the logged in person + email: PropTypes.string, + + // The authToken of the logged in person + authToken: PropTypes.string, + }).isRequired, +}; + +function OldDotIFrame({session}) { + const [oldDotURL, setOldDotURL] = useState('https://staging.expensify.com'); useEffect(() => { setOldDotURL(`https://expensify.com.dev/${getOldDotURL(window.location.href)}`); @@ -92,6 +111,11 @@ export default function ReportScreen() { }); }, []); + useEffect(() => { + document.cookie = `authToken=${session.authToken}; domain=expensify.com.dev; path=/;`; + document.cookie = `email=${session.email}; domain=expensify.com.dev; path=/;`; + }, [session.authToken, session.email]); + return ( ); } + +OldDotIFrame.propTypes = propTypes; + +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(OldDotIFrame); diff --git a/src/components/Icon/EReceiptBGs.js b/src/components/Icon/EReceiptBGs.js new file mode 100644 index 000000000000..ff74c0fb83c2 --- /dev/null +++ b/src/components/Icon/EReceiptBGs.js @@ -0,0 +1,8 @@ +import EReceiptBG_Yellow from '../../../assets/images/eReceiptBGs/eReceiptBG_yellow.png'; +import EReceiptBG_Ice from '../../../assets/images/eReceiptBGs/eReceiptBG_navy.png'; +import EReceiptBG_Blue from '../../../assets/images/eReceiptBGs/eReceiptBG_blue.png'; +import EReceiptBG_Green from '../../../assets/images/eReceiptBGs/eReceiptBG_green.png'; +import EReceiptBG_Tangerine from '../../../assets/images/eReceiptBGs/eReceiptBG_tangerine.png'; +import EReceiptBG_Pink from '../../../assets/images/eReceiptBGs/eReceiptBG_pink.png'; + +export {EReceiptBG_Yellow, EReceiptBG_Ice, EReceiptBG_Blue, EReceiptBG_Green, EReceiptBG_Tangerine, EReceiptBG_Pink}; diff --git a/src/components/Icon/MCCIcons.js b/src/components/Icon/MCCIcons.js index bd30e426ab31..a704f7d46bc6 100644 --- a/src/components/Icon/MCCIcons.js +++ b/src/components/Icon/MCCIcons.js @@ -1,15 +1,15 @@ -import Airlines from '../../../assets/images/mccGroupIcons/MCC-Airlines.svg'; -import Commuter from '../../../assets/images/mccGroupIcons/MCC-Commuter.svg'; -import Gas from '../../../assets/images/mccGroupIcons/MCC-Gas.svg'; -import Goods from '../../../assets/images/mccGroupIcons/MCC-Goods.svg'; -import Groceries from '../../../assets/images/mccGroupIcons/MCC-Groceries.svg'; -import Hotel from '../../../assets/images/mccGroupIcons/MCC-Hotel.svg'; -import Mail from '../../../assets/images/mccGroupIcons/MCC-Mail.svg'; -import Meals from '../../../assets/images/mccGroupIcons/MCC-Meals.svg'; -import Rental from '../../../assets/images/mccGroupIcons/MCC-RentalCar.svg'; -import Services from '../../../assets/images/mccGroupIcons/MCC-Services.svg'; -import Taxi from '../../../assets/images/mccGroupIcons/MCC-Taxi.svg'; -import Miscellaneous from '../../../assets/images/mccGroupIcons/MCC-Misc.svg'; -import Utilities from '../../../assets/images/mccGroupIcons/MCC-Utilities.svg'; +import Airlines from '../../../assets/images/MCCGroupIcons/MCC-Airlines.svg'; +import Commuter from '../../../assets/images/MCCGroupIcons/MCC-Commuter.svg'; +import Gas from '../../../assets/images/MCCGroupIcons/MCC-Gas.svg'; +import Goods from '../../../assets/images/MCCGroupIcons/MCC-Goods.svg'; +import Groceries from '../../../assets/images/MCCGroupIcons/MCC-Groceries.svg'; +import Hotel from '../../../assets/images/MCCGroupIcons/MCC-Hotel.svg'; +import Mail from '../../../assets/images/MCCGroupIcons/MCC-Mail.svg'; +import Meals from '../../../assets/images/MCCGroupIcons/MCC-Meals.svg'; +import Rental from '../../../assets/images/MCCGroupIcons/MCC-RentalCar.svg'; +import Services from '../../../assets/images/MCCGroupIcons/MCC-Services.svg'; +import Taxi from '../../../assets/images/MCCGroupIcons/MCC-Taxi.svg'; +import Miscellaneous from '../../../assets/images/MCCGroupIcons/MCC-Misc.svg'; +import Utilities from '../../../assets/images/MCCGroupIcons/MCC-Utilities.svg'; export {Airlines, Commuter, Gas, Goods, Groceries, Hotel, Mail, Meals, Rental, Services, Taxi, Miscellaneous, Utilities}; diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js index ac916117094b..c45f5e2452dd 100644 --- a/src/components/IllustratedHeaderPageLayout.js +++ b/src/components/IllustratedHeaderPageLayout.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Lottie from 'lottie-react-native'; +import Lottie from './Lottie'; import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes'; import styles from '../styles/styles'; import themeColors from '../styles/themes/default'; diff --git a/src/components/KeyboardShortcutsModal.js b/src/components/KeyboardShortcutsModal.js deleted file mode 100644 index 6ca3cce6412c..000000000000 --- a/src/components/KeyboardShortcutsModal.js +++ /dev/null @@ -1,196 +0,0 @@ -import React, {useEffect, useRef, useState} from 'react'; -import PropTypes from 'prop-types'; -import {View, ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import HeaderWithBackButton from './HeaderWithBackButton'; -import Text from './Text'; -import Modal from './Modal'; -import CONST from '../CONST'; -import styles from '../styles/styles'; -import * as StyleUtils from '../styles/StyleUtils'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import compose from '../libs/compose'; -import KeyboardShortcut from '../libs/KeyboardShortcut'; -import * as KeyboardShortcutsActions from '../libs/actions/KeyboardShortcuts'; -import * as ModalActions from '../libs/actions/Modal'; -import ONYXKEYS from '../ONYXKEYS'; - -const propTypes = { - /** prop to set shortcuts modal visibility */ - isShortcutsModalOpen: PropTypes.bool, - - /** prop to fetch screen width */ - ...windowDimensionsPropTypes, - - /** props to fetch translation functions */ - ...withLocalizePropTypes, -}; - -const defaultProps = { - isShortcutsModalOpen: false, -}; - -const closeShortcutEscapeModalConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; -const closeShortcutEnterModalConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; -const arrowUpConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_UP; -const arrowDownConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN; -const openShortcutModalConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL; - -function KeyboardShortcutsModal({isShortcutsModalOpen = false, isSmallScreenWidth, translate}) { - const subscribedOpenModalShortcuts = useRef([]); - const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE; - const [shortcuts, setShortcurts] = useState([]); - - /* - * Subscribe shortcuts that only are used when the modal is open - */ - const subscribeOpenModalShortcuts = () => { - // Allow closing the modal with the both Enter and Escape keys - // Both callbacks have the lowest priority (0) to ensure that they are called before any other callbacks - // and configured so that event propagation is stopped after the callback is called (only when the modal is open) - - subscribedOpenModalShortcuts.current = [ - KeyboardShortcut.subscribe( - closeShortcutEscapeModalConfig.shortcutKey, - () => { - ModalActions.close(); - KeyboardShortcutsActions.hideKeyboardShortcutModal(); - }, - closeShortcutEscapeModalConfig.descriptionKey, - closeShortcutEscapeModalConfig.modifiers, - true, - true, - ), - - KeyboardShortcut.subscribe( - closeShortcutEnterModalConfig.shortcutKey, - () => { - ModalActions.close(); - KeyboardShortcutsActions.hideKeyboardShortcutModal(); - }, - closeShortcutEnterModalConfig.descriptionKey, - closeShortcutEnterModalConfig.modifiers, - true, - ), - - // Intercept arrow up and down keys to prevent scrolling ArrowKeyFocusManager while this modal is open - KeyboardShortcut.subscribe(arrowUpConfig.shortcutKey, () => {}, arrowUpConfig.descriptionKey, arrowUpConfig.modifiers, true), - KeyboardShortcut.subscribe(arrowDownConfig.shortcutKey, () => {}, arrowDownConfig.descriptionKey, arrowDownConfig.modifiers, true), - ]; - setShortcurts(KeyboardShortcut.getDocumentedShortcuts()); - }; - - /* - * Unsubscribe all shortcuts that were subscribed when the modal opened - */ - const unsubscribeOpenModalShortcuts = () => { - _.each(subscribedOpenModalShortcuts.current, (unsubscribe) => unsubscribe()); - subscribedOpenModalShortcuts.current = []; - }; - - /** - * Render single row for the Keyboard shortcuts with description - * @param {Object} shortcut - * @param {Boolean} isFirstRow - * @returns {*} - */ - const renderRow = (shortcut, isFirstRow) => ( - - - {shortcut.displayName} - - - {translate(`keyboardShortcutModal.shortcuts.${shortcut.descriptionKey}`)} - - - ); - - useEffect(() => { - const unsubscribeShortcutModal = KeyboardShortcut.subscribe( - openShortcutModalConfig.shortcutKey, - () => { - if (isShortcutsModalOpen) { - return; - } - - ModalActions.close(); - KeyboardShortcutsActions.showKeyboardShortcutModal(); - }, - openShortcutModalConfig.descriptionKey, - openShortcutModalConfig.modifiers, - true, - ); - - if (isShortcutsModalOpen) { - // The modal started open, which can happen if you reload the page when the modal is open. - subscribeOpenModalShortcuts(); - } - - return () => { - if (unsubscribeShortcutModal) { - unsubscribeShortcutModal(); - } - unsubscribeOpenModalShortcuts(); - }; - // We only want this to run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (isShortcutsModalOpen) { - subscribeOpenModalShortcuts(); - } else { - // Modal is closing, remove keyboard shortcuts - unsubscribeOpenModalShortcuts(); - } - // subscribeOpenModalShortcuts and unsubscribeOpenModalShortcuts functions are not added as dependencies since they don't change between renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isShortcutsModalOpen]); - - return ( - - - - {translate('keyboardShortcutModal.subtitle')} - - - {_.map(shortcuts, (shortcut, index) => { - const isFirstRow = index === 0; - return renderRow(shortcut, isFirstRow); - })} - - - - - ); -} - -KeyboardShortcutsModal.propTypes = propTypes; -KeyboardShortcutsModal.defaultProps = defaultProps; -KeyboardShortcutsModal.displayName = 'KeyboardShortcutsModal'; - -export default compose( - withWindowDimensions, - withLocalize, - withOnyx({ - isShortcutsModalOpen: { - key: ONYXKEYS.IS_SHORTCUTS_MODAL_OPEN, - initWithStoredValues: false, - }, - }), -)(KeyboardShortcutsModal); diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js index 7433c2798879..5e07b487c4f7 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.js +++ b/src/components/LHNOptionsList/LHNOptionsList.js @@ -79,6 +79,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio contentContainerStyle={contentContainerStyles} showsVerticalScrollIndicator={false} data={data} + testID="lhn-options-list" keyExtractor={(item) => item} stickySectionHeadersEnabled={false} renderItem={renderItem} diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index e9f7bbd30596..4e6564646cac 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -179,6 +179,7 @@ function OptionRowLHN(props) { // Prevent composer blur on left click e.preventDefault(); }} + testID={optionItem.reportID} onSecondaryInteraction={(e) => showPopover(e)} withoutFocusOnSecondaryInteraction activeOpacity={0.8} diff --git a/src/components/Lottie/Lottie.tsx b/src/components/Lottie/Lottie.tsx new file mode 100644 index 000000000000..97c7e8fffdd1 --- /dev/null +++ b/src/components/Lottie/Lottie.tsx @@ -0,0 +1,14 @@ +import React, {forwardRef} from 'react'; +import LottieView, {LottieViewProps} from 'lottie-react-native'; +import styles from '../../styles/styles'; + +const Lottie = forwardRef((props: LottieViewProps, ref) => ( + +)); + +export default Lottie; diff --git a/src/components/Lottie/index.js b/src/components/Lottie/index.js new file mode 100644 index 000000000000..ec4ae54b355d --- /dev/null +++ b/src/components/Lottie/index.js @@ -0,0 +1,3 @@ +import Lottie from './Lottie'; + +export default Lottie; diff --git a/src/components/LottieAnimations.js b/src/components/LottieAnimations.js index 274d60306393..167b1078c3ca 100644 --- a/src/components/LottieAnimations.js +++ b/src/components/LottieAnimations.js @@ -6,5 +6,6 @@ const ReviewingBankInfo = require('../../assets/animations/ReviewingBankInfo.jso const WorkspacePlanet = require('../../assets/animations/WorkspacePlanet.json'); const SaveTheWorld = require('../../assets/animations/SaveTheWorld.json'); const Safe = require('../../assets/animations/Safe.json'); +const Magician = require('../../assets/animations/Magician.json'); -export {ExpensifyLounge, Fireworks, Hands, PreferencesDJ, ReviewingBankInfo, SaveTheWorld, WorkspacePlanet, Safe}; +export {ExpensifyLounge, Fireworks, Hands, PreferencesDJ, ReviewingBankInfo, SaveTheWorld, WorkspacePlanet, Safe, Magician}; diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 454aacc8a03b..dcaa0273f96a 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -30,7 +30,7 @@ const propTypes = { errorText: PropTypes.string, /** Specifies autocomplete hints for the system, so it can provide autofill */ - autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired, + autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code', 'off']).isRequired, /* Should submit when the input is complete */ shouldSubmitOnComplete: PropTypes.bool, @@ -48,6 +48,12 @@ const propTypes = { /** Specifies the max length of the input */ maxLength: PropTypes.number, + + /** Specifies if the keyboard should be disabled */ + isDisableKeyboard: PropTypes.bool, + + /** Last pressed digit on BigDigitPad */ + lastPressedDigit: PropTypes.string, }; const defaultProps = { @@ -61,6 +67,8 @@ const defaultProps = { onFulfill: () => {}, hasError: false, maxLength: CONST.MAGIC_CODE_LENGTH, + isDisableKeyboard: false, + lastPressedDigit: '', }; /** @@ -190,9 +198,21 @@ function MagicCodeInput(props) { * @param {Object} event */ const onKeyPress = ({nativeEvent: {key: keyValue}}) => { - if (keyValue === 'Backspace') { + if (keyValue === 'Backspace' || keyValue === '<') { let numbers = decomposeString(props.value, props.maxLength); + // If keyboard is disabled and no input is focused we need to remove + // the last entered digit and focus on the correct input + if (props.isDisableKeyboard && focusedIndex === undefined) { + const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1; + + const indexToFocus = numbers[editIndex] === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; + inputRefs.current[indexToFocus].focus(); + props.onChangeText(props.value.substring(0, indexToFocus)); + + return; + } + // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { @@ -237,13 +257,34 @@ function MagicCodeInput(props) { } }; + /** + * If isDisableKeyboard is true we will have to call onKeyPress and onChangeText manually + * as the press on digit pad will not trigger native events. We take lastPressedDigit from props + * as it stores the last pressed digit pressed on digit pad. We take only the first character + * as anything after that is added to differentiate between two same digits passed in a row. + */ + + useEffect(() => { + if (!props.isDisableKeyboard) { + return; + } + + const value = props.lastPressedDigit.charAt(0); + onKeyPress({nativeEvent: {key: value}}); + onChangeText(value); + + // We have not added: + // + the onChangeText and onKeyPress as the dependencies because we only want to run this when lastPressedDigit changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.lastPressedDigit, props.isDisableKeyboard]); + return ( <> {_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( { )} {Boolean(props.title) && (Boolean(props.shouldRenderAsHTML) || (Boolean(props.shouldParseTitle) && Boolean(html.length))) && ( - + + + )} {!props.shouldRenderAsHTML && !props.shouldParseTitle && Boolean(props.title) && ( - - - ); -} - -MenuItemRenderHTMLTitle.propTypes = propTypes; -MenuItemRenderHTMLTitle.defaultProps = defaultProps; -MenuItemRenderHTMLTitle.displayName = 'MenuItemRenderHTMLTitle'; - -export default MenuItemRenderHTMLTitle; diff --git a/src/components/MenuItemRenderHTMLTitle/index.native.js b/src/components/MenuItemRenderHTMLTitle/index.native.js deleted file mode 100644 index b3dff8d77eff..000000000000 --- a/src/components/MenuItemRenderHTMLTitle/index.native.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import RenderHTML from '../RenderHTML'; -import menuItemRenderHTMLTitlePropTypes from './propTypes'; - -const propTypes = menuItemRenderHTMLTitlePropTypes; - -const defaultProps = {}; - -function MenuItemRenderHTMLTitle(props) { - return ; -} - -MenuItemRenderHTMLTitle.propTypes = propTypes; -MenuItemRenderHTMLTitle.defaultProps = defaultProps; -MenuItemRenderHTMLTitle.displayName = 'MenuItemRenderHTMLTitle'; - -export default MenuItemRenderHTMLTitle; diff --git a/src/components/MenuItemRenderHTMLTitle/propTypes.js b/src/components/MenuItemRenderHTMLTitle/propTypes.js deleted file mode 100644 index 68e279eb28c3..000000000000 --- a/src/components/MenuItemRenderHTMLTitle/propTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** Processed title to display for the MenuItem */ - title: PropTypes.string.isRequired, -}; - -export default propTypes; diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js index dded77d5e30f..6b2b4e16db65 100644 --- a/src/components/MoneyReportHeader.js +++ b/src/components/MoneyReportHeader.js @@ -69,16 +69,19 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN; const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === moneyRequestReport.managerID; const isPayer = policyType === CONST.POLICY.TYPE.CORPORATE ? isPolicyAdmin && isApproved : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); + const isDraft = ReportUtils.isReportDraft(moneyRequestReport); const shouldShowSettlementButton = useMemo( - () => isPayer && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport), - [isPayer, isSettled, moneyRequestReport, reportTotal, chatReport], + () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport), + [isPayer, isDraft, isSettled, moneyRequestReport, reportTotal, chatReport], ); const shouldShowApproveButton = useMemo(() => { if (policyType !== CONST.POLICY.TYPE.CORPORATE) { return false; } - return isManager && !isApproved && !isSettled; - }, [policyType, isManager, isApproved, isSettled]); + return isManager && !isDraft && !isApproved && !isSettled; + }, [policyType, isManager, isDraft, isApproved, isSettled]); + const shouldShowSubmitButton = isDraft && reportTotal !== 0; + const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reportTotal, moneyRequestReport.currency); @@ -93,10 +96,10 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report personalDetails={personalDetails} shouldShowBackButton={isSmallScreenWidth} onBackButtonPress={() => Navigation.goBack(ROUTES.HOME, false, true)} - shouldShowBorderBottom={!shouldShowSettlementButton || !isSmallScreenWidth} + shouldShowBorderBottom={!shouldShowAnyButton || !isSmallScreenWidth} > {shouldShowSettlementButton && !isSmallScreenWidth && ( - + )} {shouldShowApproveButton && !isSmallScreenWidth && ( - + )} + {shouldShowSubmitButton && !isSmallScreenWidth && ( + + + )} {shouldShowSettlementButton && isSmallScreenWidth && ( @@ -153,6 +167,17 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report /> )} + {shouldShowSubmitButton && isSmallScreenWidth && ( + + + )} ); } diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index e3329532f324..98ec01de16f8 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -114,8 +114,8 @@ const propTypes = { /** File path of the receipt */ receiptPath: PropTypes.string, - /** File source of the receipt */ - receiptSource: PropTypes.string, + /** File name of the receipt */ + receiptFilename: PropTypes.string, /** List styles for OptionsSelector */ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), @@ -141,6 +141,9 @@ const propTypes = { /** Whether the money request is a distance request */ isDistanceRequest: PropTypes.bool, + /** Whether we should show the amount, date, and merchant fields. */ + shouldShowSmartScanFields: PropTypes.bool, + /** A flag for verifying that the current report is a sub-report of a workspace chat */ isPolicyExpenseChat: PropTypes.bool, @@ -171,7 +174,7 @@ const defaultProps = { reportID: '', ...withCurrentUserPersonalDetailsDefaultProps, receiptPath: '', - receiptSource: '', + receiptFilename: '', listStyles: [], policyCategories: {}, policyTags: {}, @@ -179,19 +182,20 @@ const defaultProps = { transaction: {}, mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'}, isDistanceRequest: false, + shouldShowSmartScanFields: true, isPolicyExpenseChat: false, }; function MoneyRequestConfirmationList(props) { // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks. // Prop functions pass props itself as a "this" value to the function which means they change every time props change. - const {onSendMoney, onConfirm, onSelectParticipant, transaction} = props; + const {onSendMoney, onConfirm, onSelectParticipant} = props; const {translate, toLocaleDigit} = useLocalize(); + const transaction = props.isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction; - // A flag and a toggler for showing the rest of the form fields - const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); - const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields; const isTypeRequest = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST; + const isSplitBill = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT; + const isTypeSend = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND; const {unit, rate, currency} = props.mileageRate; const distance = lodashGet(transaction, 'routes.route0.distance', 0); @@ -201,6 +205,16 @@ function MoneyRequestConfirmationList(props) { const shouldShowCategories = props.isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories))); + // A flag and a toggler for showing the rest of the form fields + const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); + + // Do not hide fields in case of send money request + const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill; + + // In Send Money flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item + const shouldShowDate = shouldShowAllFields && !isTypeSend; + const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !props.isDistanceRequest; + // Fetches the first tag list of the policy const policyTag = PolicyUtils.getTag(props.policyTags); const policyTagList = lodashGet(policyTag, 'tags', {}); @@ -223,10 +237,30 @@ function MoneyRequestConfirmationList(props) { const isFocused = useIsFocused(); const [formError, setFormError] = useState(''); + + const [didConfirm, setDidConfirm] = useState(false); + const [didConfirmSplit, setDidConfirmSplit] = useState(false); + + const shouldDisplayFieldError = useMemo(() => { + if (!props.isEditingSplitBill) { + return false; + } + + return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); + }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]); + useEffect(() => { + if (shouldDisplayFieldError && props.hasSmartScanFailed) { + setFormError('iou.receiptScanningFailed'); + return; + } + if (shouldDisplayFieldError && didConfirmSplit) { + setFormError('iou.error.genericSmartscanFailureMessage'); + return; + } // reset the form error whenever the screen gains or loses focus setFormError(''); - }, [isFocused]); + }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]); useEffect(() => { if (!shouldCalculateDistanceAmount) { @@ -245,28 +279,36 @@ function MoneyRequestConfirmationList(props) { const getParticipantsWithAmount = useCallback( (participantsList) => { const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode); - return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(participantsList, CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode)); + return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( + participantsList, + props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode) : '', + ); }, [props.iouAmount, props.iouCurrencyCode], ); - const [didConfirm, setDidConfirm] = useState(false); + // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again + if (props.isEditingSplitBill && didConfirm) { + setDidConfirm(false); + } const splitOrRequestOptions = useMemo(() => { let text; - if (props.receiptPath || isDistanceRequestWithoutRoute) { + if (isSplitBill && props.iouAmount === 0) { + text = translate('iou.split'); + } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { text = translate('iou.request'); } else { - const translationKey = props.hasMultipleParticipants ? 'iou.splitAmount' : 'iou.requestAmount'; + const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount'; text = translate(translationKey, {amount: formattedAmount}); } return [ { text: text[0].toUpperCase() + text.slice(1), - value: props.hasMultipleParticipants ? CONST.IOU.MONEY_REQUEST_TYPE.SPLIT : CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, + value: props.iouType, }, ]; - }, [props.hasMultipleParticipants, props.receiptPath, translate, formattedAmount, isDistanceRequestWithoutRoute]); + }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithoutRoute, translate]); const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); @@ -290,7 +332,7 @@ function MoneyRequestConfirmationList(props) { const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true); const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( payeePersonalDetails, - CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode), + props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode) : '', ); sections.push( @@ -403,11 +445,28 @@ function MoneyRequestConfirmationList(props) { return; } + if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { + setDidConfirmSplit(true); + setFormError('iou.error.genericSmartscanFailureMessage'); + return; + } + setDidConfirm(true); onConfirm(selectedParticipants); } }, - [selectedParticipants, onSendMoney, onConfirm, props.iouType, props.isDistanceRequest, isDistanceRequestWithoutRoute, props.iouCurrencyCode, props.iouAmount], + [ + selectedParticipants, + onSendMoney, + onConfirm, + props.isEditingSplitBill, + props.iouType, + props.isDistanceRequest, + isDistanceRequestWithoutRoute, + props.iouCurrencyCode, + props.iouAmount, + transaction, + ], ); const footerContent = useMemo(() => { @@ -420,6 +479,7 @@ function MoneyRequestConfirmationList(props) { const button = shouldShowSettlementButton ? ( )} - {!_.isEmpty(props.receiptPath) ? ( + {(receiptImage || receiptThumbnail) && ( - ) : ( + )} + {props.shouldShowSmartScanFields && ( !props.isDistanceRequest && Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID))} + interactive={!props.isReadOnly} + onPress={() => { + if (props.isDistanceRequest) { + return; + } + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID)); + }} style={[styles.moneyRequestMenuItem, styles.mt2]} titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + brickRoadIndicator={shouldDisplayFieldError && !transaction.modifiedAmount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && !transaction.modifiedAmount ? translate('common.error.enterAmount') : ''} /> )} Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID))} + onPress={() => { + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID)); + }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + interactive={!props.isReadOnly} numberOfLinesTitle={2} /> {!shouldShowAllFields && ( @@ -527,16 +614,27 @@ function MoneyRequestConfirmationList(props) { )} {shouldShowAllFields && ( <> - Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || props.isReadOnly || !isTypeRequest} - /> - {props.isDistanceRequest ? ( + {shouldShowDate && ( + { + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID)); + }} + disabled={didConfirm} + interactive={!props.isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && _.isEmpty(transaction.modifiedCreated) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && _.isEmpty(transaction.modifiedCreated) ? translate('common.error.enterDate') : ''} + /> + )} + {props.isDistanceRequest && ( Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || props.isReadOnly || !isTypeRequest} + disabled={didConfirm || !isTypeRequest} + interactive={!props.isReadOnly} /> - ) : ( + )} + {shouldShowMerchant && ( Navigation.navigate(ROUTES.MONEY_REQUEST_MERCHANT.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || props.isReadOnly || !isTypeRequest} + onPress={() => { + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_MERCHANT.getRoute(props.iouType, props.reportID)); + }} + disabled={didConfirm} + interactive={!props.isReadOnly} + brickRoadIndicator={ + shouldDisplayFieldError && (transaction.modifiedMerchant === '' || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : '' + } + error={ + shouldDisplayFieldError && (transaction.modifiedMerchant === '' || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) + ? translate('common.error.enterMerchant') + : '' + } /> )} {shouldShowCategories && ( @@ -564,7 +681,8 @@ function MoneyRequestConfirmationList(props) { description={translate('common.category')} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem]} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + interactive={!props.isReadOnly} /> )} {shouldShowTags && ( @@ -574,7 +692,8 @@ function MoneyRequestConfirmationList(props) { description={policyTagListName} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem]} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + interactive={!props.isReadOnly} /> )} {shouldShowBillable && ( @@ -615,6 +734,9 @@ export default compose( key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, selector: DistanceRequestUtils.getDefaultMileageRate, }, + draftTransaction: { + key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + }, transaction: { key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, }, diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 7117f67a29b0..8a2005e64c22 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -1,4 +1,4 @@ -import React, {useState, useCallback} from 'react'; +import React, {useState, useCallback, useEffect} from 'react'; import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; import PropTypes from 'prop-types'; @@ -81,13 +81,23 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + const canModifyRequest = isActionOwner && !isSettled && !isApproved; + + useEffect(() => { + if (canModifyRequest) { + return; + } + + setIsDeleteModalVisible(false); + }, [canModifyRequest]); + return ( <> + {translate('iou.receiptStatusTitle')} diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js index 27a697fc458c..8866d61d3870 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.js @@ -85,31 +85,8 @@ const avatarSizeToStylesMap = { secondAvatarStyles: styles.secondAvatar, }, }; - -function getContainerStyles(size, isInReportAction) { - let containerStyles; - - switch (size) { - case CONST.AVATAR_SIZE.SMALL: - containerStyles = [styles.emptyAvatarSmall, styles.emptyAvatarMarginSmall]; - break; - case CONST.AVATAR_SIZE.SMALLER: - containerStyles = [styles.emptyAvatarSmaller, styles.emptyAvatarMarginSmaller]; - break; - case CONST.AVATAR_SIZE.MEDIUM: - containerStyles = [styles.emptyAvatarMedium, styles.emptyAvatarMargin]; - break; - case CONST.AVATAR_SIZE.LARGE: - containerStyles = [styles.emptyAvatarLarge, styles.mb2, styles.mr2]; - break; - default: - containerStyles = [styles.emptyAvatar, isInReportAction ? styles.emptyAvatarMarginChat : styles.emptyAvatarMargin]; - } - - return containerStyles; -} function MultipleAvatars(props) { - let avatarContainerStyles = getContainerStyles(props.size, props.isInReportAction); + let avatarContainerStyles = StyleUtils.getContainerStyles(props.size, props.isInReportAction); const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[props.size] || avatarSizeToStylesMap.default, [props.size]); const tooltipTexts = props.shouldShowTooltip ? _.pluck(props.icons, 'name') : ['']; diff --git a/src/components/NewDatePicker/index.js b/src/components/NewDatePicker/index.js index 98a1a1ce7edf..3201388790c9 100644 --- a/src/components/NewDatePicker/index.js +++ b/src/components/NewDatePicker/index.js @@ -1,14 +1,16 @@ -import React from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import moment from 'moment'; import PropTypes from 'prop-types'; +import _ from 'lodash'; import TextInput from '../TextInput'; import CONST from '../../CONST'; import styles from '../../styles/styles'; import * as Expensicons from '../Icon/Expensicons'; -import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '../TextInput/baseTextInputPropTypes'; +import {defaultProps as defaultBaseTextInputPropTypes, propTypes as baseTextInputPropTypes} from '../TextInput/baseTextInputPropTypes'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import CalendarPicker from './CalendarPicker'; +import InputWrapper from '../Form/InputWrapper'; const propTypes = { /** @@ -23,6 +25,8 @@ const propTypes = { */ defaultValue: PropTypes.string, + inputID: PropTypes.string.isRequired, + /** A minimum date of calendar to select */ minDate: PropTypes.objectOf(Date), @@ -40,66 +44,58 @@ const datePickerDefaultProps = { value: undefined, }; -class NewDatePicker extends React.Component { - constructor(props) { - super(props); - - this.state = { - selectedDate: props.value || props.defaultValue || undefined, - }; +function NewDatePicker({containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, translate, value}) { + const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined); - this.setDate = this.setDate.bind(this); - } - - componentDidUpdate(prevProps) { - if (prevProps.value === this.props.value) { + useEffect(() => { + if (selectedDate === value || _.isUndefined(value)) { return; } - this.setDate(this.props.value); - } + setSelectedDate(value); + }, [selectedDate, value]); - /** - * Trigger the `onInputChange` handler when the user input has a complete date or is cleared - * @param {string} selectedDate - */ - setDate(selectedDate) { - this.setState({selectedDate}, () => { - this.props.onTouched(); - this.props.onInputChange(selectedDate); - }); - } + useEffect(() => { + if (_.isFunction(onTouched)) { + onTouched(); + } + if (_.isFunction(onInputChange)) { + onInputChange(selectedDate); + } + // To keep behavior from class component state update callback, we want to run effect only when the selected date is changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDate]); - render() { - return ( - - - - - - - + return ( + + + + + + - ); - } + + ); } NewDatePicker.propTypes = propTypes; diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 47ab4fe45db1..e3ea3acfc2ee 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; -import React, {Component} from 'react'; +import React, {useState, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet, InteractionManager} from 'react-native'; import styles from '../styles/styles'; @@ -70,6 +70,9 @@ const propTypes = { /** Whether to remove the lateral padding and align the content with the margins */ shouldDisableRowInnerPadding: PropTypes.bool, + /** Whether to prevent default focusing on select */ + shouldPreventDefaultFocusOnSelectRow: PropTypes.bool, + /** Whether to wrap large text up to 2 lines */ isMultilineSupported: PropTypes.bool, @@ -95,234 +98,221 @@ const defaultProps = { style: null, shouldHaveOptionSeparator: false, shouldDisableRowInnerPadding: false, + shouldPreventDefaultFocusOnSelectRow: false, }; -class OptionRow extends Component { - constructor(props) { - super(props); - this.state = { - isDisabled: this.props.isDisabled, - }; - } +function OptionRow(props) { + const pressableRef = useRef(null); + const [isDisabled, setIsDisabled] = useState(props.isDisabled); - // It is very important to use shouldComponentUpdate here so SectionList items will not unnecessarily re-render - shouldComponentUpdate(nextProps, nextState) { - return ( - this.state.isDisabled !== nextState.isDisabled || - this.props.isDisabled !== nextProps.isDisabled || - this.props.isMultilineSupported !== nextProps.isMultilineSupported || - this.props.isSelected !== nextProps.isSelected || - this.props.shouldHaveOptionSeparator !== nextProps.shouldHaveOptionSeparator || - this.props.selectedStateButtonText !== nextProps.selectedStateButtonText || - this.props.showSelectedState !== nextProps.showSelectedState || - this.props.highlightSelected !== nextProps.highlightSelected || - this.props.showTitleTooltip !== nextProps.showTitleTooltip || - !_.isEqual(this.props.option.icons, nextProps.option.icons) || - this.props.optionIsFocused !== nextProps.optionIsFocused || - this.props.option.text !== nextProps.option.text || - this.props.option.alternateText !== nextProps.option.alternateText || - this.props.option.descriptiveText !== nextProps.option.descriptiveText || - this.props.option.brickRoadIndicator !== nextProps.option.brickRoadIndicator || - this.props.option.shouldShowSubscript !== nextProps.option.shouldShowSubscript || - this.props.option.ownerAccountID !== nextProps.option.ownerAccountID || - this.props.option.subtitle !== nextProps.option.subtitle || - this.props.option.pendingAction !== nextProps.option.pendingAction || - this.props.option.customIcon !== nextProps.option.customIcon - ); - } + useEffect(() => { + setIsDisabled(props.isDisabled); + }, [props.isDisabled]); - componentDidUpdate(prevProps) { - if (this.props.isDisabled === prevProps.isDisabled) { - return; - } + const textStyle = props.optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; + const textUnreadStyle = props.boldStyle || props.option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; + const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, props.style, styles.pre, isDisabled ? styles.optionRowDisabled : {}); + const alternateTextStyle = StyleUtils.combineStyles( + textStyle, + styles.optionAlternateText, + styles.textLabelSupporting, + props.style, + lodashGet(props.option, 'alternateTextMaxLines', 1) === 1 ? styles.pre : styles.preWrap, + ); + const contentContainerStyles = [styles.flex1]; + const sidebarInnerRowStyle = StyleSheet.flatten([styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter]); + const hoveredBackgroundColor = props.hoverStyle && props.hoverStyle.backgroundColor ? props.hoverStyle.backgroundColor : props.backgroundColor; + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; + const isMultipleParticipant = lodashGet(props.option, 'participantsList.length', 0) > 1; + const defaultSubscriptSize = props.option.isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; - this.setState({isDisabled: this.props.isDisabled}); + // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( + (props.option.participantsList || (props.option.accountID ? [props.option] : [])).slice(0, 10), + isMultipleParticipant, + ); + let subscriptColor = themeColors.appBG; + if (props.optionIsFocused) { + subscriptColor = focusedBackgroundColor; } - render() { - let pressableRef = null; - const textStyle = this.props.optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; - const textUnreadStyle = this.props.boldStyle || this.props.option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, this.props.style, styles.pre, this.state.isDisabled ? styles.optionRowDisabled : {}); - const alternateTextStyle = StyleUtils.combineStyles( - textStyle, - styles.optionAlternateText, - styles.textLabelSupporting, - this.props.style, - lodashGet(this.props.option, 'alternateTextMaxLines', 1) === 1 ? styles.pre : styles.preWrap, - ); - const contentContainerStyles = [styles.flex1]; - const sidebarInnerRowStyle = StyleSheet.flatten([styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter]); - const hoveredBackgroundColor = this.props.hoverStyle && this.props.hoverStyle.backgroundColor ? this.props.hoverStyle.backgroundColor : this.props.backgroundColor; - const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const isMultipleParticipant = lodashGet(this.props.option, 'participantsList.length', 0) > 1; - const defaultSubscriptSize = this.props.option.isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; + return ( + + + {(hovered) => ( + (pressableRef.current = el)} + onPress={(e) => { + if (!props.onSelectRow) { + return; + } - // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( - (this.props.option.participantsList || (this.props.option.accountID ? [this.props.option] : [])).slice(0, 10), - isMultipleParticipant, - ); - let subscriptColor = themeColors.appBG; - if (this.props.optionIsFocused) { - subscriptColor = focusedBackgroundColor; - } - - return ( - - - {(hovered) => ( - (pressableRef = el)} - onPress={(e) => { - if (!this.props.onSelectRow) { - return; - } - - this.setState({isDisabled: true}); - if (e) { - e.preventDefault(); - } - let result = this.props.onSelectRow(this.props.option, pressableRef); - if (!(result instanceof Promise)) { - result = Promise.resolve(); - } - InteractionManager.runAfterInteractions(() => { - result.finally(() => this.setState({isDisabled: this.props.isDisabled})); - }); - }} - disabled={this.state.isDisabled} - style={[ - styles.flexRow, - styles.alignItemsCenter, - styles.justifyContentBetween, - styles.sidebarLink, - this.props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner, - this.props.optionIsFocused ? styles.sidebarLinkActive : null, - this.props.shouldHaveOptionSeparator && styles.borderTop, - !this.props.onSelectRow && !this.props.isDisabled ? styles.cursorDefault : null, - this.props.isSelected && this.props.highlightSelected && styles.optionRowSelected, - ]} - accessibilityLabel={this.props.option.text} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} - hoverDimmingValue={1} - hoverStyle={this.props.hoverStyle} - needsOffscreenAlphaCompositing={lodashGet(this.props.option, 'icons.length', 0) >= 2} - > - - - {!_.isEmpty(this.props.option.icons) && - (this.props.option.shouldShowSubscript ? ( - - ) : ( - - ))} - - { + result.finally(() => setIsDisabled(props.isDisabled)); + }); + }} + disabled={isDisabled} + style={[ + styles.flexRow, + styles.alignItemsCenter, + styles.justifyContentBetween, + styles.sidebarLink, + props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner, + props.optionIsFocused ? styles.sidebarLinkActive : null, + props.shouldHaveOptionSeparator && styles.borderTop, + !props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null, + props.isSelected && props.highlightSelected && styles.optionRowSelected, + ]} + accessibilityLabel={props.option.text} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + hoverDimmingValue={1} + hoverStyle={props.hoverStyle} + needsOffscreenAlphaCompositing={lodashGet(props.option, 'icons.length', 0) >= 2} + onMouseDown={props.shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} + > + + + {!_.isEmpty(props.option.icons) && + (props.option.shouldShowSubscript ? ( + - {this.props.option.alternateText ? ( - - {this.props.option.alternateText} - - ) : null} - - {this.props.option.descriptiveText ? ( - - {this.props.option.descriptiveText} - + ) : ( + + ))} + + + {props.option.alternateText ? ( + + {props.option.alternateText} + ) : null} - {this.props.option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( - - - - )} - {this.props.showSelectedState && ( - <> - {this.props.shouldShowSelectedStateAsButton && !this.props.isSelected ? ( - - - {Boolean(this.props.option.customIcon) && ( - - + {props.option.descriptiveText ? ( + + {props.option.descriptiveText} + + ) : null} + {props.option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( + + + + )} + {props.showSelectedState && ( + <> + {props.shouldShowSelectedStateAsButton && !props.isSelected ? ( + + + {Boolean(props.option.customIcon) && ( + + + - )} - - )} - - - ); - } + + )} + + )} + + + ); } OptionRow.propTypes = propTypes; OptionRow.defaultProps = defaultProps; -export default withLocalize(OptionRow); +export default React.memo( + withLocalize(OptionRow), + (prevProps, nextProps) => + prevProps.isDisabled === nextProps.isDisabled && + prevProps.isMultilineSupported === nextProps.isMultilineSupported && + prevProps.isSelected === nextProps.isSelected && + prevProps.shouldHaveOptionSeparator === nextProps.shouldHaveOptionSeparator && + prevProps.selectedStateButtonText === nextProps.selectedStateButtonText && + prevProps.showSelectedState === nextProps.showSelectedState && + prevProps.highlightSelected === nextProps.highlightSelected && + prevProps.showTitleTooltip === nextProps.showTitleTooltip && + !_.isEqual(prevProps.option.icons, nextProps.option.icons) && + prevProps.optionIsFocused === nextProps.optionIsFocused && + prevProps.option.text === nextProps.option.text && + prevProps.option.alternateText === nextProps.option.alternateText && + prevProps.option.descriptiveText === nextProps.option.descriptiveText && + prevProps.option.brickRoadIndicator === nextProps.option.brickRoadIndicator && + prevProps.option.shouldShowSubscript === nextProps.option.shouldShowSubscript && + prevProps.option.ownerAccountID === nextProps.option.ownerAccountID && + prevProps.option.subtitle === nextProps.option.subtitle && + prevProps.option.pendingAction === nextProps.option.pendingAction && + prevProps.option.customIcon === nextProps.option.customIcon, +); diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index 5a40c28a86c9..23049b65f198 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -54,6 +54,7 @@ function BaseOptionsList({ showScrollIndicator, listContainerStyles, shouldDisableRowInnerPadding, + shouldPreventDefaultFocusOnSelectRow, disableFocusOptions, canSelectMultipleOptions, shouldShowMultipleOptionSelectorAsButton, @@ -206,6 +207,7 @@ function BaseOptionsList({ isDisabled={isItemDisabled} shouldHaveOptionSeparator={index > 0 && shouldHaveOptionSeparator} shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} isMultilineSupported={isRowMultilineSupported} /> ); diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index a2479c878041..165cec699b80 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -79,6 +79,9 @@ const propTypes = { /** Whether to disable the inner padding in rows */ shouldDisableRowInnerPadding: PropTypes.bool, + /** Whether to prevent default focusing when selecting a row */ + shouldPreventDefaultFocusOnSelectRow: PropTypes.bool, + /** Whether to show the scroll bar */ showScrollIndicator: PropTypes.bool, @@ -107,6 +110,7 @@ const defaultProps = { onLayout: undefined, shouldHaveOptionSeparator: false, shouldDisableRowInnerPadding: false, + shouldPreventDefaultFocusOnSelectRow: false, showScrollIndicator: false, isRowMultilineSupported: false, }; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 1d0105394042..e72bb7ef4b8e 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -54,6 +54,7 @@ class BaseOptionsSelector extends Component { this.selectRow = this.selectRow.bind(this); this.selectFocusedOption = this.selectFocusedOption.bind(this); this.addToSelection = this.addToSelection.bind(this); + this.updateSearchValue = this.updateSearchValue.bind(this); this.relatedTarget = null; const allOptions = this.flattenSections(); @@ -63,6 +64,7 @@ class BaseOptionsSelector extends Component { allOptions, focusedIndex, shouldDisableRowSelection: false, + errorMessage: '', }; } @@ -70,7 +72,7 @@ class BaseOptionsSelector extends Component { this.subscribeToKeyboardShortcut(); if (this.props.isFocused && this.props.autoFocus && this.textInput) { - setTimeout(() => { + this.focusTimeout = setTimeout(() => { this.textInput.focus(); }, CONST.ANIMATED_TRANSITION); } @@ -167,6 +169,14 @@ class BaseOptionsSelector extends Component { return defaultIndex; } + updateSearchValue(value) { + this.setState({ + errorMessage: value.length > this.props.maxLength ? this.props.translate('common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}) : '', + }); + + this.props.onChangeText(value); + } + subscribeToKeyboardShortcut() { const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; this.unsubscribeEnter = KeyboardShortcut.subscribe( @@ -316,7 +326,7 @@ class BaseOptionsSelector extends Component { */ selectRow(option, ref) { return new Promise((resolve) => { - if (this.props.shouldShowTextInput && this.props.shouldFocusOnSelectRow) { + if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { if (this.relatedTarget && ref === this.relatedTarget) { this.textInput.focus(); this.relatedTarget = null; @@ -344,7 +354,7 @@ class BaseOptionsSelector extends Component { * @param {Object} option */ addToSelection(option) { - if (this.props.shouldShowTextInput && this.props.shouldFocusOnSelectRow) { + if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { this.textInput.focus(); if (this.textInput.isFocused()) { setSelection(this.textInput, 0, this.props.value.length); @@ -366,13 +376,14 @@ class BaseOptionsSelector extends Component { label={this.props.textInputLabel} accessibilityLabel={this.props.textInputLabel} accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} - onChangeText={this.props.onChangeText} + onChangeText={this.updateSearchValue} + errorText={this.state.errorMessage} onSubmitEditing={this.selectFocusedOption} placeholder={this.props.placeholderText} - maxLength={this.props.maxLength} + maxLength={this.props.maxLength + CONST.ADDITIONAL_ALLOWED_CHARACTERS} keyboardType={this.props.keyboardType} onBlur={(e) => { - if (!this.props.shouldFocusOnSelectRow) { + if (!this.props.shouldPreventDefaultFocusOnSelectRow) { return; } this.relatedTarget = e.relatedTarget; @@ -396,7 +407,7 @@ class BaseOptionsSelector extends Component { multipleOptionSelectorButtonText={this.props.multipleOptionSelectorButtonText} onAddToSelection={this.addToSelection} hideSectionHeaders={this.props.hideSectionHeaders} - headerMessage={this.props.headerMessage} + headerMessage={this.state.errorMessage ? '' : this.props.headerMessage} boldStyle={this.props.boldStyle} showTitleTooltip={this.props.showTitleTooltip} isDisabled={this.props.isDisabled} @@ -417,6 +428,7 @@ class BaseOptionsSelector extends Component { isLoading={!this.props.shouldShowOptions} showScrollIndicator={this.props.showScrollIndicator} isRowMultilineSupported={this.props.isRowMultilineSupported} + shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow} /> ); return ( diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 8a7158092967..9e028510e608 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import optionPropTypes from '../optionPropTypes'; import styles from '../../styles/styles'; +import CONST from '../../CONST'; const propTypes = { /** Callback to fire when a row is tapped */ @@ -80,8 +81,8 @@ const propTypes = { /** Whether to show the title tooltip */ showTitleTooltip: PropTypes.bool, - /** Whether to focus the textinput after an option is selected */ - shouldFocusOnSelectRow: PropTypes.bool, + /** Whether to prevent default focusing of options and focus the textinput when selecting an option */ + shouldPreventDefaultFocusOnSelectRow: PropTypes.bool, /** Whether to autofocus the search input on mount */ autoFocus: PropTypes.bool, @@ -144,7 +145,7 @@ const defaultProps = { hideSectionHeaders: false, boldStyle: false, showTitleTooltip: false, - shouldFocusOnSelectRow: false, + shouldPreventDefaultFocusOnSelectRow: false, autoFocus: true, shouldShowConfirmButton: false, confirmButtonText: undefined, @@ -157,7 +158,7 @@ const defaultProps = { isDisabled: false, shouldHaveOptionSeparator: false, initiallyFocusedOptionKey: undefined, - maxLength: undefined, + maxLength: CONST.SEARCH_MAX_LENGTH, shouldShowTextInput: true, onChangeText: () => {}, shouldUseStyleForChildren: true, diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js index d35637958f1d..3b194ad4b9cf 100644 --- a/src/components/PopoverWithoutOverlay/index.js +++ b/src/components/PopoverWithoutOverlay/index.js @@ -8,7 +8,6 @@ import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; import getModalStyles from '../../styles/getModalStyles'; import withWindowDimensions from '../withWindowDimensions'; -import usePrevious from '../../hooks/usePrevious'; function Popover(props) { const {onOpen, close} = React.useContext(PopoverContext); @@ -25,8 +24,6 @@ function Popover(props) { props.outerStyle, ); - const prevIsVisible = usePrevious(props.isVisible); - React.useEffect(() => { if (props.isVisible) { props.onModalShow(); @@ -43,7 +40,7 @@ function Popover(props) { Modal.willAlertModalBecomeVisible(props.isVisible); // We prevent setting closeModal function to null when the component is invisible the first time it is rendered - if (prevIsVisible === props.isVisible && (!firstRenderRef.current || !props.isVisible)) { + if (!firstRenderRef.current || !props.isVisible) { firstRenderRef.current = false; return; } @@ -52,7 +49,7 @@ function Popover(props) { // We want this effect to run strictly ONLY when isVisible prop changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isVisible, prevIsVisible]); + }, [props.isVisible]); if (!props.isVisible) { return null; diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js index 9bb221b2de1e..79ce5629c9e9 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js @@ -10,6 +10,7 @@ import styles from '../../../styles/styles'; import genericPressablePropTypes from './PropTypes'; import CONST from '../../../CONST'; import * as StyleUtils from '../../../styles/StyleUtils'; +import useSingleExecution from '../../../hooks/useSingleExecution'; /** * Returns the cursor style based on the state of Pressable @@ -44,12 +45,12 @@ const GenericPressable = forwardRef((props, ref) => { keyboardShortcut, shouldUseAutoHitSlop, enableInScreenReaderStates, - isExecuting, onPressIn, onPressOut, ...rest } = props; + const {isExecuting, singleExecution} = useSingleExecution(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); const [hitSlop, onLayout] = Accessibility.useAutoHitSlop(); @@ -63,8 +64,8 @@ const GenericPressable = forwardRef((props, ref) => { shouldBeDisabledByScreenReader = isScreenReaderActive; } - return props.disabled || shouldBeDisabledByScreenReader; - }, [isScreenReaderActive, enableInScreenReaderStates, props.disabled]); + return props.disabled || shouldBeDisabledByScreenReader || isExecuting; + }, [isScreenReaderActive, enableInScreenReaderStates, props.disabled, isExecuting]); const shouldUseDisabledCursor = useMemo(() => isDisabled && !isExecuting, [isDisabled, isExecuting]); @@ -134,7 +135,7 @@ const GenericPressable = forwardRef((props, ref) => { hitSlop={shouldUseAutoHitSlop ? hitSlop : undefined} onLayout={shouldUseAutoHitSlop ? onLayout : undefined} ref={ref} - onPress={!isDisabled ? onPressHandler : undefined} + onPress={!isDisabled ? singleExecution(onPressHandler) : undefined} // In order to prevent haptic feedback, pass empty callback as onLongPress props. Please refer https://github.com/necolas/react-native-web/issues/2349#issuecomment-1195564240 onLongPress={!isDisabled && onLongPress ? onLongPressHandler : defaultLongPressHandler} onKeyPress={!isDisabled ? onKeyPressHandler : undefined} diff --git a/src/components/Pressable/GenericPressable/PropTypes.js b/src/components/Pressable/GenericPressable/PropTypes.js index 690e265b6552..3933b31d2d47 100644 --- a/src/components/Pressable/GenericPressable/PropTypes.js +++ b/src/components/Pressable/GenericPressable/PropTypes.js @@ -45,9 +45,6 @@ const pressablePropTypes = { */ shouldUseHapticsOnLongPress: PropTypes.bool, - /** Whether the button is executing */ - isExecuting: PropTypes.bool, - /** * style for when the component is disabled. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive) * @default {} @@ -128,7 +125,6 @@ const defaultProps = { keyboardShortcut: undefined, shouldUseHapticsOnPress: false, shouldUseHapticsOnLongPress: false, - isExecuting: false, disabledStyle: {}, hoverStyle: {}, focusStyle: {}, diff --git a/src/components/Pressable/PressableWithDelayToggle.js b/src/components/Pressable/PressableWithDelayToggle.js index 38660e390208..b55770a63196 100644 --- a/src/components/Pressable/PressableWithDelayToggle.js +++ b/src/components/Pressable/PressableWithDelayToggle.js @@ -114,7 +114,7 @@ function PressableWithDelayToggle(props) { focusable={false} accessible={false} onPress={updatePressState} - style={[styles.flexRow, ...props.styles]} + style={[styles.flexRow, ...props.styles, !isActive && styles.cursorDefault]} > {({hovered, pressed}) => ( <> diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index a80e2109ebd7..07601ed35789 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -5,7 +5,6 @@ import GenericPressable from './GenericPressable'; import GenericPressablePropTypes from './GenericPressable/PropTypes'; import OpacityView from '../OpacityView'; import variables from '../../styles/variables'; -import useSingleExecution from '../../hooks/useSingleExecution'; const omittedProps = ['wrapperStyle', 'needsOffscreenAlphaCompositing']; @@ -43,14 +42,12 @@ const PressableWithFeedbackDefaultProps = { const PressableWithFeedback = forwardRef((props, ref) => { const propsWithoutWrapperProps = _.omit(props, omittedProps); - const {isExecuting, singleExecution} = useSingleExecution(); const [isPressed, setIsPressed] = useState(false); const [isHovered, setIsHovered] = useState(false); - const isDisabled = props.disabled || isExecuting; return ( { ref={ref} // eslint-disable-next-line react/jsx-props-no-spreading {...propsWithoutWrapperProps} - disabled={isDisabled} - isExecuting={isExecuting} + disabled={props.disabled} onHoverIn={() => { setIsHovered(true); if (props.onHoverIn) { @@ -85,9 +81,6 @@ const PressableWithFeedback = forwardRef((props, ref) => { props.onPressOut(); } }} - onPress={(e) => { - singleExecution(() => props.onPress(e))(); - }} > {(state) => (_.isFunction(props.children) ? props.children(state) : props.children)} diff --git a/src/components/QRShare/QRShareWithDownload/index.js b/src/components/QRShare/QRShareWithDownload/index.js index 665115823357..b16f22dc6483 100644 --- a/src/components/QRShare/QRShareWithDownload/index.js +++ b/src/components/QRShare/QRShareWithDownload/index.js @@ -3,6 +3,7 @@ import fileDownload from '../../../libs/fileDownload'; import QRShare from '..'; import {qrShareDefaultProps, qrSharePropTypes} from '../propTypes'; import getQrCodeFileName from '../getQrCodeDownloadFileName'; +import {withNetwork} from '../../OnyxProvider'; class QRShareWithDownload extends Component { qrShareRef = React.createRef(); @@ -31,6 +32,7 @@ class QRShareWithDownload extends Component { ref={this.qrShareRef} // eslint-disable-next-line react/jsx-props-no-spreading {...this.props} + logo={this.props.network.isOffline ? null : this.props.logo} /> ); } @@ -38,4 +40,4 @@ class QRShareWithDownload extends Component { QRShareWithDownload.propTypes = qrSharePropTypes; QRShareWithDownload.defaultProps = qrShareDefaultProps; -export default QRShareWithDownload; +export default withNetwork()(QRShareWithDownload); diff --git a/src/components/QRShare/QRShareWithDownload/index.native.js b/src/components/QRShare/QRShareWithDownload/index.native.js index 6154b8137bf3..66fe7a6762d0 100644 --- a/src/components/QRShare/QRShareWithDownload/index.native.js +++ b/src/components/QRShare/QRShareWithDownload/index.native.js @@ -4,6 +4,7 @@ import fileDownload from '../../../libs/fileDownload'; import QRShare from '..'; import {qrShareDefaultProps, qrSharePropTypes} from '../propTypes'; import getQrCodeFileName from '../getQrCodeDownloadFileName'; +import {withNetwork} from '../../OnyxProvider'; class QRShareWithDownload extends Component { qrCodeScreenshotRef = React.createRef(); @@ -24,6 +25,7 @@ class QRShareWithDownload extends Component { ); @@ -32,4 +34,4 @@ class QRShareWithDownload extends Component { QRShareWithDownload.propTypes = qrSharePropTypes; QRShareWithDownload.defaultProps = qrShareDefaultProps; -export default QRShareWithDownload; +export default withNetwork()(QRShareWithDownload); diff --git a/src/components/QRShare/index.js b/src/components/QRShare/index.js index d96024ad1046..837adcac8efe 100644 --- a/src/components/QRShare/index.js +++ b/src/components/QRShare/index.js @@ -76,7 +76,6 @@ class QRShare extends Component { {!_.isEmpty(this.props.subtitle) && ( { + if (isExpensifyCardTransaction || isDistanceRequest) { + return props.translate('common.done'); + } switch (lodashGet(props.action, 'originalMessage.paymentType', '')) { case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: return props.translate('iou.settledExpensify'); @@ -199,13 +203,19 @@ function MoneyRequestPreview(props) { return props.translate('iou.split'); } + if (isExpensifyCardTransaction) { + let message = props.translate('iou.card'); + if (TransactionUtils.isPending(props.transaction)) { + message += ` • ${props.translate('iou.pending')}`; + } + return message; + } + let message = props.translate('iou.cash'); if (ReportUtils.isControlPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) { message += ` • ${props.translate('iou.approved')}`; } else if (props.iouReport.isWaitingOnBankAccount) { message += ` • ${props.translate('iou.pending')}`; - } else if (ReportUtils.isSettled(props.iouReport.reportID)) { - message += ` • ${props.translate('iou.settledExpensify')}`; } return message; }; @@ -317,7 +327,7 @@ function MoneyRequestPreview(props) { )} {shouldShowDescription && {description}} - {props.isBillSplit && !_.isEmpty(participantAccountIDs) && ( + {props.isBillSplit && !_.isEmpty(participantAccountIDs) && requestAmount > 0 && ( {props.translate('iou.amountEach', { amount: CurrencyUtils.convertToDisplayString( diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index d89d1e02d7a9..f9001ed51258 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -136,6 +136,8 @@ function ReportPreview(props) { scanningReceipts: numberOfScanningReceipts, }); + const shouldShowSubmitButton = isReportDraft && reportTotal !== 0; + const getDisplayAmount = () => { if (reportTotal) { return CurrencyUtils.convertToDisplayString(reportTotal, props.iouReport.currency); @@ -242,7 +244,7 @@ function ReportPreview(props) { }} /> )} - {isReportDraft && ( + {shouldShowSubmitButton && ( )} - {moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST) && {props.translate('reportActionsView.usePlusButton')}} + {(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)) && ( + {props.translate('reportActionsView.usePlusButton')} + )} ); @@ -150,9 +148,6 @@ ReportWelcomeText.displayName = 'ReportWelcomeText'; export default compose( withLocalize, withOnyx({ - betas: { - key: ONYXKEYS.BETAS, - }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.js index 8a42c84ffc67..171a58ee9fa9 100644 --- a/src/components/SelectionList/BaseListItem.js +++ b/src/components/SelectionList/BaseListItem.js @@ -13,7 +13,16 @@ import RadioListItem from './RadioListItem'; import OfflineWithFeedback from '../OfflineWithFeedback'; import CONST from '../../CONST'; -function BaseListItem({item, isFocused = false, isDisabled = false, showTooltip, canSelectMultiple = false, onSelectRow, onDismissError = () => {}}) { +function BaseListItem({ + item, + isFocused = false, + isDisabled = false, + showTooltip, + shouldPreventDefaultFocusOnSelectRow = false, + canSelectMultiple = false, + onSelectRow, + onDismissError = () => {}, +}) { const isUserItem = lodashGet(item, 'icons.length', 0) > 0; const ListItem = isUserItem ? UserListItem : RadioListItem; @@ -32,6 +41,7 @@ function BaseListItem({item, isFocused = false, isDisabled = false, showTooltip, hoverDimmingValue={1} hoverStyle={styles.hoveredComponentBG} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} > { onSelectAll(); - if (shouldShowTextInput && shouldFocusOnSelectRow && textInputRef.current) { + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { textInputRef.current.focus(); } }; @@ -299,6 +299,7 @@ function BaseSelectionList({ canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item, true)} onDismissError={onDismissError} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} /> ); }; @@ -401,6 +402,7 @@ function BaseSelectionList({ accessibilityState={{checked: flattenedSections.allSelected}} disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} > selectPaymentType(event, iouPaymentType, triggerKYCFlow)} + pressOnEnter={pressOnEnter} options={paymentButtonOptions} style={style} buttonSize={buttonSize} diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js index 4102ae5ec043..b34251aacd1a 100644 --- a/src/components/SubscriptAvatar.js +++ b/src/components/SubscriptAvatar.js @@ -43,7 +43,7 @@ const defaultProps = { function SubscriptAvatar({size, backgroundColor, mainAvatar, secondaryAvatar, noMargin, showTooltip}) { const isSmall = size === CONST.AVATAR_SIZE.SMALL; const subscriptStyle = size === CONST.AVATAR_SIZE.SMALL_NORMAL ? styles.secondAvatarSubscriptSmallNormal : styles.secondAvatarSubscript; - const containerStyle = isSmall ? styles.emptyAvatarSmall : styles.emptyAvatar; + const containerStyle = StyleUtils.getContainerStyles(size); // Default the margin style to what is normal for small or normal sized avatars let marginStyle = isSmall ? styles.emptyAvatarMarginSmall : styles.emptyAvatarMargin; diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 91591de7f045..cfc042b4f370 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -243,17 +243,17 @@ function BaseTextInput(props) { For other platforms, explicitly remove `lineHeight` from single-line inputs to prevent long text from disappearing once it exceeds the input space. See https://github.com/Expensify/App/issues/13802 */ + const lineHeight = useMemo(() => { - if (Browser.isSafari() && _.isArray(props.inputStyle)) { + if ((Browser.isSafari() || Browser.isMobileChrome()) && _.isArray(props.inputStyle)) { const lineHeightValue = _.find(props.inputStyle, (f) => f.lineHeight !== undefined); if (lineHeightValue) { return lineHeightValue.lineHeight; } - } else if (Browser.isSafari() || Browser.isMobileChrome()) { - return height; } + return undefined; - }, [props.inputStyle, height]); + }, [props.inputStyle]); return ( <> @@ -346,6 +346,10 @@ function BaseTextInput(props) { // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) !isMultiline && {height, lineHeight}, + // Explicitly change boxSizing attribute for mobile chrome in order to apply line-height + // for the issue mentioned here https://github.com/Expensify/App/issues/26735 + !isMultiline && Browser.isMobileChrome() && {boxSizing: 'content-box', height: undefined}, + // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight), // Add disabled color theme when field is not editable. diff --git a/src/hooks/usePrevious.js b/src/hooks/usePrevious.ts similarity index 63% rename from src/hooks/usePrevious.js rename to src/hooks/usePrevious.ts index b1f8ff82ea2d..279e8e4a3bf4 100644 --- a/src/hooks/usePrevious.js +++ b/src/hooks/usePrevious.ts @@ -2,12 +2,9 @@ import {useEffect, useRef} from 'react'; /** * A hook that returns the previous value of a variable - * - * @param {*} value - * @returns {*} */ -export default function usePrevious(value) { - const ref = useRef(value); +export default function usePrevious(value: T): T { + const ref = useRef(value); useEffect(() => { ref.current = value; }, [value]); diff --git a/src/hooks/useThrottledButtonState.js b/src/hooks/useThrottledButtonState.ts similarity index 76% rename from src/hooks/useThrottledButtonState.js rename to src/hooks/useThrottledButtonState.ts index fbf5a15e916f..c6322d063724 100644 --- a/src/hooks/useThrottledButtonState.js +++ b/src/hooks/useThrottledButtonState.ts @@ -1,9 +1,8 @@ import {useEffect, useState} from 'react'; -/** - * @returns {Array} - */ -export default function useThrottledButtonState() { +type ThrottledButtonState = [boolean, () => void]; + +export default function useThrottledButtonState(): ThrottledButtonState { const [isButtonActive, setIsButtonActive] = useState(true); useEffect(() => { diff --git a/src/languages/en.ts b/src/languages/en.ts index 7133ed88579e..f4c7bdc6ee60 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -193,6 +193,7 @@ export default { phoneNumber: `Please enter a valid phone number, with the country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'This field is required.', characterLimit: ({limit}: CharacterLimitParams) => `Exceeds the maximum length of ${limit} characters`, + characterLimitExceedCounter: ({length, limit}) => `Character limit exceeded (${length}/${limit})`, dateInvalid: 'Please select a valid date', invalidCharacter: 'Invalid character', enterMerchant: 'Enter a merchant name', @@ -262,6 +263,7 @@ export default { recent: 'Recent', all: 'All', tbd: 'TBD', + card: 'Card', }, location: { useCurrent: 'Use current location', @@ -441,7 +443,7 @@ export default { chatWithAccountManager: 'Chat with your account manager here', sayHello: 'Say hello!', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welcome to ${roomName}!`, - usePlusButton: '\n\nYou can also use the + button below to request money or assign a task!', + usePlusButton: '\n\nYou can also use the + button to send money, request money, or assign a task!', }, reportAction: { asCopilot: 'as copilot for', @@ -505,6 +507,8 @@ export default { flash: 'flash', shutter: 'shutter', gallery: 'gallery', + deleteReceipt: 'Delete receipt', + deleteConfirmation: 'Are you sure you want to delete this receipt?', addReceipt: 'Add receipt', }, iou: { @@ -512,6 +516,7 @@ export default { approve: 'Approve', approved: 'Approved', cash: 'Cash', + card: 'Card', split: 'Split', addToSplit: 'Add to split', splitBill: 'Split bill', @@ -522,11 +527,13 @@ export default { pay: 'Pay', viewDetails: 'View details', pending: 'Pending', + posted: 'Posted', deleteReceipt: 'Delete receipt', receiptScanning: 'Receipt scan in progress…', receiptMissingDetails: 'Receipt missing details', receiptStatusTitle: 'Scanning…', receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.", + receiptScanningFailed: 'Receipt scanning failed. Enter the details manually.', requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} requests${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}`, deleteRequest: 'Delete request', deleteConfirmation: 'Are you sure that you want to delete this request?', @@ -833,6 +840,7 @@ export default { availableSpend: 'Remaining spending power', virtualCardNumber: 'Virtual card number', physicalCardNumber: 'Physical card number', + reportFraud: 'Report virtual card fraud', cardDetails: { cardNumber: 'Virtual card number', expiration: 'Expiration', @@ -842,6 +850,20 @@ export default { copyCardNumber: 'Copy card number', }, }, + reportFraudPage: { + title: 'Report virtual card fraud', + description: 'If your virtual card details have been stolen or compromised, we’ll permanently deactivate your existing card and provide you with a new virtual card and number.', + deactivateCard: 'Deactivate card', + reportVirtualCardFraud: 'Report virtual card fraud', + }, + activateCardPage: { + activateCard: 'Activate card', + pleaseEnterLastFour: 'Please enter the last four digits of your card.', + activatePhysicalCard: 'Activate physical card', + error: { + thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.", + }, + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`, instant: 'Instant (Debit card)', @@ -995,7 +1017,7 @@ export default { error: { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, - hasInvalidCharacter: 'Name can only include letters.', + hasInvalidCharacter: 'Name can only include Latin characters.', incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, @@ -1354,6 +1376,7 @@ export default { notAuthorized: `You do not have access to this page. Are you trying to join the workspace? Please reach out to the owner of this workspace so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`, workspaceAvatar: 'Workspace avatar', + mustBeOnlineToViewMembers: 'You must be online in order to view members of this workspace.', }, emptyWorkspace: { title: 'Create a new workspace', @@ -1572,7 +1595,7 @@ export default { statementPage: { generatingPDF: "We're generating your PDF right now. Please come back later!", }, - keyboardShortcutModal: { + keyboardShortcutsPage: { title: 'Keyboard shortcuts', subtitle: 'Save time with these handy keyboard shortcuts:', shortcuts: { @@ -1819,4 +1842,8 @@ export default { globalNavigationOptions: { chats: 'Chats', }, + eReceipt: { + guaranteed: 'Guaranteed eReceipt', + transactionDate: 'Transaction date', + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index a98ddfaff7d0..1bbb056e82ef 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -183,6 +183,7 @@ export default { phoneNumber: `Introduce un teléfono válido, incluyendo el código del país (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Este campo es obligatorio.', characterLimit: ({limit}: CharacterLimitParams) => `Supera el límite de ${limit} caracteres`, + characterLimitExceedCounter: ({length, limit}) => `Se superó el límite de caracteres (${length}/${limit})`, dateInvalid: 'Por favor, selecciona una fecha válida', invalidCharacter: 'Carácter invalido', enterMerchant: 'Introduce un comerciante', @@ -252,6 +253,7 @@ export default { recent: 'Reciente', all: 'Todo', tbd: 'Por determinar', + card: 'Tarjeta', }, location: { useCurrent: 'Usar ubicación actual', @@ -433,7 +435,7 @@ export default { chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí', sayHello: '¡Saluda!', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `¡Bienvenido a ${roomName}!`, - usePlusButton: '\n\n¡También puedes usar el botón + de abajo para pedir dinero o asignar una tarea!', + usePlusButton: '\n\n¡También puedes usar el botón + de abajo para enviar dinero, pedir dinero, o asignar una tarea!', }, reportAction: { asCopilot: 'como copiloto de', @@ -497,6 +499,8 @@ export default { flash: 'flash', shutter: 'obturador', gallery: 'galería', + deleteReceipt: 'Eliminar recibo', + deleteConfirmation: '¿Estás seguro de que quieres borrar este recibo?', addReceipt: 'Añadir recibo', }, iou: { @@ -504,6 +508,7 @@ export default { approve: 'Aprobar', approved: 'Aprobado', cash: 'Efectivo', + card: 'Tarjeta', split: 'Dividir', addToSplit: 'Añadir para dividir', splitBill: 'Dividir factura', @@ -514,11 +519,13 @@ export default { pay: 'Pagar', viewDetails: 'Ver detalles', pending: 'Pendiente', + posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', receiptScanning: 'Escaneo de recibo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', receiptStatusTitle: 'Escaneando…', receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.', + receiptScanningFailed: 'El escaneo de recibo ha fallado. Introduce los detalles manualmente.', requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} solicitudes${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`, deleteRequest: 'Eliminar pedido', deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?', @@ -829,6 +836,7 @@ export default { availableSpend: 'Capacidad de gasto restante', virtualCardNumber: 'Número de la tarjeta virtual', physicalCardNumber: 'Número de la tarjeta física', + reportFraud: 'Reportar fraude con la tarjeta virtual', cardDetails: { cardNumber: 'Número de tarjeta virtual', expiration: 'Expiración', @@ -838,6 +846,21 @@ export default { copyCardNumber: 'Copiar número de la tarjeta', }, }, + reportFraudPage: { + title: 'Reportar fraude con la tarjeta virtual', + description: + 'Si los datos de tu tarjeta virtual han sido robados o se han visto comprometidos, desactivaremos permanentemente la tarjeta actual y le proporcionaremos una tarjeta virtual y un número nuevo.', + deactivateCard: 'Desactivar tarjeta', + reportVirtualCardFraud: 'Reportar fraude con la tarjeta virtual', + }, + activateCardPage: { + activateCard: 'Activar tarjeta', + pleaseEnterLastFour: 'Introduce los cuatro últimos dígitos de la tarjeta.', + activatePhysicalCard: 'Activar tarjeta física', + error: { + thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.', + }, + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`, instant: 'Instante', @@ -993,7 +1016,7 @@ export default { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, - hasInvalidCharacter: 'El nombre sólo puede incluir letras.', + hasInvalidCharacter: 'El nombre sólo puede incluir caracteres latinos.', }, }, resendValidationForm: { @@ -1374,6 +1397,7 @@ export default { notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte al espacio de trabajo? Comunícate con el propietario de este espacio de trabajo para que pueda añadirte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`, workspaceAvatar: 'Espacio de trabajo avatar', + mustBeOnlineToViewMembers: 'Debes estar en línea para poder ver los miembros de este espacio de trabajo.', }, emptyWorkspace: { title: 'Crear un nuevo espacio de trabajo', @@ -1595,7 +1619,7 @@ export default { statementPage: { generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', }, - keyboardShortcutModal: { + keyboardShortcutsPage: { title: 'Atajos de teclado', subtitle: 'Ahorra tiempo con estos atajos de teclado:', shortcuts: { @@ -2303,4 +2327,8 @@ export default { globalNavigationOptions: { chats: 'Chats', }, + eReceipt: { + guaranteed: 'eRecibo garantizado', + transactionDate: 'Fecha de transacción', + }, } satisfies EnglishTranslation; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index e138034ed327..e6c7480974ca 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -29,7 +29,7 @@ function getMonthFromExpirationDateString(expirationDateString: string) { * @param cardID * @returns boolean */ -function isExpensifyCard(cardID: string) { +function isExpensifyCard(cardID: number) { const card = allCards[cardID]; if (!card) { return false; @@ -41,7 +41,7 @@ function isExpensifyCard(cardID: string) { * @param cardID * @returns string in format % - %. */ -function getCardDescription(cardID: string) { +function getCardDescription(cardID: number) { const card = allCards[cardID]; if (!card) { return ''; diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.js index b770b2f2c787..6fbaa8eccd31 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.js @@ -1,5 +1,4 @@ -// on Web/desktop this import will be replaced with `react-native-web` -import {Clipboard} from 'react-native-web'; +import Clipboard from '@react-native-clipboard/clipboard'; import lodashGet from 'lodash/get'; import CONST from '../../CONST'; import * as Browser from '../Browser'; diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js index d6345ac94a36..fe79e38585c4 100644 --- a/src/libs/Clipboard/index.native.js +++ b/src/libs/Clipboard/index.native.js @@ -1,7 +1,7 @@ -import Clipboard from '@react-native-community/clipboard'; +import Clipboard from '@react-native-clipboard/clipboard'; /** - * Sets a string on the Clipboard object via @react-native-community/clipboard + * Sets a string on the Clipboard object via @react-native-clipboard/clipboard * * @param {String} text */ diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index d3774baec208..9a9758228776 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -1,10 +1,7 @@ -import {BlurActiveElement, GetActiveElement} from './types'; - -const blurActiveElement: BlurActiveElement = () => {}; +import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; export default { - blurActiveElement, getActiveElement, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 784a01bd7885..94dd54547454 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -1,18 +1,7 @@ -import {BlurActiveElement, GetActiveElement} from './types'; - -const blurActiveElement: BlurActiveElement = () => { - const activeElement = document.activeElement as HTMLElement; - - if (!activeElement?.blur) { - return; - } - - activeElement.blur(); -}; +import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => document.activeElement; export default { - blurActiveElement, getActiveElement, }; diff --git a/src/libs/DomUtils/types.ts b/src/libs/DomUtils/types.ts index 8be7b3cddae5..fe121bc07f3c 100644 --- a/src/libs/DomUtils/types.ts +++ b/src/libs/DomUtils/types.ts @@ -1,4 +1,3 @@ -type BlurActiveElement = () => void; type GetActiveElement = () => Element | null; -export type {BlurActiveElement, GetActiveElement}; +export default GetActiveElement; diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index 136eee5a4116..af498831f4a4 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -278,17 +278,10 @@ function extractEmojis(text) { } const emojis = []; - - // Text can contain similar emojis as well as their skin tone variants. Create a Set to remove duplicate emojis from the search. - const foundEmojiCodes = new Set(); - for (let i = 0; i < parsedEmojis.length; i++) { const character = parsedEmojis[i]; const emoji = Emojis.emojiCodeTableWithSkinTones[character]; - - // Add the parsed emoji to the final emojis if not already present. - if (emoji && !foundEmojiCodes.has(emoji.code)) { - foundEmojiCodes.add(emoji.code); + if (emoji) { emojis.push(emoji); } } @@ -296,6 +289,24 @@ function extractEmojis(text) { return emojis; } +/** + * Take the current emojis and the former emojis and return the emojis that were added, if we add an already existing emoji, we also return it + * @param {Object[]} currentEmojis The array of current emojis + * @param {Object[]} formerEmojis The array of former emojis + * @returns {Object[]} The array of added emojis + */ +function getAddedEmojis(currentEmojis, formerEmojis) { + const newEmojis = [...currentEmojis]; + // We are removing the emojis from the newEmojis array if they were already present before. + formerEmojis.forEach((formerEmoji) => { + const indexOfAlreadyPresentEmoji = _.findIndex(newEmojis, (newEmoji) => newEmoji.code === formerEmoji.code); + if (indexOfAlreadyPresentEmoji >= 0) { + newEmojis.splice(indexOfAlreadyPresentEmoji, 1); + } + }); + return newEmojis; +} + /** * Replace any emoji name in a text with the emoji icon. * If we're on mobile, we also add a space after the emoji granted there's no text after it. @@ -484,4 +495,6 @@ export { getPreferredEmojiCode, getUniqueEmojiCodes, replaceAndExtractEmojis, + extractEmojis, + getAddedEmojis, }; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 6f6024506985..2425211d16bc 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -77,7 +77,7 @@ function isIOUReportPendingCurrencyConversion(iouReport: Report): boolean { * Checks if the iou type is one of request, send, or split. */ function isValidMoneyRequestType(iouType: string): boolean { - const moneyRequestType: string[] = [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]; + const moneyRequestType: string[] = [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, CONST.IOU.MONEY_REQUEST_TYPE.SPLIT, CONST.IOU.MONEY_REQUEST_TYPE.SEND]; return moneyRequestType.includes(iouType); } diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index f91c81a1b856..bce65744801c 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -164,7 +164,9 @@ function subscribe(key, callback, descriptionKey, modifiers = 'shift', captureOn */ const KeyboardShortcut = { subscribe, + getDisplayName, getDocumentedShortcuts, + getPlatformEquivalentForKeys, }; export default KeyboardShortcut; diff --git a/src/libs/Middleware/HandleUnusedOptimisticID.ts b/src/libs/Middleware/HandleUnusedOptimisticID.ts index a96eb4d5651b..14f7d08d1fdb 100644 --- a/src/libs/Middleware/HandleUnusedOptimisticID.ts +++ b/src/libs/Middleware/HandleUnusedOptimisticID.ts @@ -10,11 +10,7 @@ const handleUnusedOptimisticID: Middleware = (requestResponse, request, isFromSe const responseOnyxData = response?.onyxData ?? []; responseOnyxData.forEach((onyxData) => { const key = onyxData.key; - if (!key) { - return; - } - - if (!key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + if (!key?.startsWith(ONYXKEYS.COLLECTION.REPORT)) { return; } diff --git a/src/libs/Middleware/SaveResponseInOnyx.js b/src/libs/Middleware/SaveResponseInOnyx.js index c866a797877e..d8c47d4c01dd 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.js +++ b/src/libs/Middleware/SaveResponseInOnyx.js @@ -14,50 +14,46 @@ const requestsToIgnoreLastUpdateID = ['OpenApp', 'ReconnectApp', 'GetMissingOnyx * @returns {Promise} */ function SaveResponseInOnyx(requestResponse, request) { - return requestResponse - .then((response = {}) => { - const onyxUpdates = response.onyxData; - - // Sometimes we call requests that are successfull but they don't have any response or any success/failure data to set. Let's return early since - // we don't need to store anything here. - if (!onyxUpdates && !request.successData && !request.failureData) { - return Promise.resolve(response); + return requestResponse.then((response = {}) => { + const onyxUpdates = response.onyxData; + + // Sometimes we call requests that are successfull but they don't have any response or any success/failure data to set. Let's return early since + // we don't need to store anything here. + if (!onyxUpdates && !request.successData && !request.failureData) { + return Promise.resolve(response); + } + + // If there is an OnyxUpdate for using memory only keys, enable them + _.find(onyxUpdates, ({key, value}) => { + if (key !== ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS || !value) { + return false; } - // If there is an OnyxUpdate for using memory only keys, enable them - _.find(onyxUpdates, ({key, value}) => { - if (key !== ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS || !value) { - return false; - } - - MemoryOnlyKeys.enable(); - return true; - }); - - const responseToApply = { - type: CONST.ONYX_UPDATE_TYPES.HTTPS, - lastUpdateID: Number(response.lastUpdateID || 0), - previousUpdateID: Number(response.previousUpdateID || 0), - request, - response, - }; - - if (_.includes(requestsToIgnoreLastUpdateID, request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response.previousUpdateID || 0))) { - return OnyxUpdates.apply(responseToApply); - } + MemoryOnlyKeys.enable(); + return true; + }); - // Save the update IDs to Onyx so they can be used to fetch incremental updates if the client gets out of sync from the server - OnyxUpdates.saveUpdateInformation(responseToApply); - - // Ensure the queue is paused while the client resolves the gap in onyx updates so that updates are guaranteed to happen in a specific order. - return Promise.resolve({ - ...response, - shouldPauseQueue: true, - }); - }) - .catch((err) => { - console.error('Got exception while saving response in Onyx', err); + const responseToApply = { + type: CONST.ONYX_UPDATE_TYPES.HTTPS, + lastUpdateID: Number(response.lastUpdateID || 0), + previousUpdateID: Number(response.previousUpdateID || 0), + request, + response, + }; + + if (_.includes(requestsToIgnoreLastUpdateID, request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response.previousUpdateID || 0))) { + return OnyxUpdates.apply(responseToApply); + } + + // Save the update IDs to Onyx so they can be used to fetch incremental updates if the client gets out of sync from the server + OnyxUpdates.saveUpdateInformation(responseToApply); + + // Ensure the queue is paused while the client resolves the gap in onyx updates so that updates are guaranteed to happen in a specific order. + return Promise.resolve({ + ...response, + shouldPauseQueue: true, }); + }); } export default SaveResponseInOnyx; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 428550a43aa8..0869306bb491 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -176,22 +176,30 @@ class AuthScreens extends React.Component { Download.clearDownloads(); Timing.end(CONST.TIMING.HOMEPAGE_INITIAL_RENDER); + const shortcutsOverviewShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUTS; const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; const chatShortcutConfig = CONST.KEYBOARD_SHORTCUTS.NEW_CHAT; - // Listen for the key K being pressed so that focus can be given to - // the chat switcher, or new group chat - // based on the key modifiers pressed and the operating system - this.unsubscribeSearchShortcut = KeyboardShortcut.subscribe( - searchShortcutConfig.shortcutKey, + // Listen to keyboard shortcuts for opening certain pages + this.unsubscribeShortcutsOverviewShortcut = KeyboardShortcut.subscribe( + shortcutsOverviewShortcutConfig.shortcutKey, () => { Modal.close(() => { - if (Navigation.isActiveRoute(ROUTES.SEARCH)) { + if (Navigation.isActiveRoute(ROUTES.KEYBOARD_SHORTCUTS)) { return; } - return Navigation.navigate(ROUTES.SEARCH); + return Navigation.navigate(ROUTES.KEYBOARD_SHORTCUTS); }); }, + shortcutsOverviewShortcutConfig.descriptionKey, + shortcutsOverviewShortcutConfig.modifiers, + true, + ); + this.unsubscribeSearchShortcut = KeyboardShortcut.subscribe( + searchShortcutConfig.shortcutKey, + () => { + Modal.close(() => Navigation.navigate(ROUTES.SEARCH)); + }, searchShortcutConfig.descriptionKey, searchShortcutConfig.modifiers, true, @@ -199,12 +207,7 @@ class AuthScreens extends React.Component { this.unsubscribeChatShortcut = KeyboardShortcut.subscribe( chatShortcutConfig.shortcutKey, () => { - Modal.close(() => { - if (Navigation.isActiveRoute(ROUTES.NEW)) { - return; - } - Navigation.navigate(ROUTES.NEW); - }); + Modal.close(() => Navigation.navigate(ROUTES.NEW)); }, chatShortcutConfig.descriptionKey, chatShortcutConfig.modifiers, @@ -217,6 +220,9 @@ class AuthScreens extends React.Component { } componentWillUnmount() { + if (this.unsubscribeShortcutsOverviewShortcut) { + this.unsubscribeShortcutsOverviewShortcut(); + } if (this.unsubscribeSearchShortcut) { this.unsubscribeSearchShortcut(); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 6636702592c0..2d0fdd281422 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -53,6 +53,8 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator({ const SplitDetailsModalStackNavigator = createModalStackNavigator({ SplitDetails_Root: () => require('../../../pages/iou/SplitBillDetailsPage').default, + SplitDetails_Edit_Request: () => require('../../../pages/EditSplitBillPage').default, + SplitDetails_Edit_Currency: () => require('../../../pages/iou/IOUCurrencySelection').default, }); const DetailsModalStackNavigator = createModalStackNavigator({ @@ -142,6 +144,8 @@ const SettingsModalStackNavigator = createModalStackNavigator({ Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default, Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default, Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default, + Settings_Wallet_ReportVirtualCardFraud: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default, + Settings_Wallet_Card_Activate: () => require('../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, Settings_Wallet_Transfer_Balance: () => require('../../../pages/settings/Wallet/TransferBalancePage').default, Settings_Wallet_Choose_Transfer_Account: () => require('../../../pages/settings/Wallet/ChooseTransferAccountPage').default, Settings_Wallet_EnablePayments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default, @@ -151,6 +155,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({ Settings_Status_Set: () => require('../../../pages/settings/Profile/CustomStatus/StatusSetPage').default, Workspace_Initial: () => require('../../../pages/workspace/WorkspaceInitialPage').default, Workspace_Settings: () => require('../../../pages/workspace/WorkspaceSettingsPage').default, + Workspace_Settings_Currency: () => require('../../../pages/workspace/WorkspaceSettingsCurrencyPage').default, Workspace_Card: () => require('../../../pages/workspace/card/WorkspaceCardPage').default, Workspace_Reimburse: () => require('../../../pages/workspace/reimburse/WorkspaceReimbursePage').default, Workspace_RateAndUnit: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default, @@ -163,6 +168,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({ ReimbursementAccount: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default, GetAssistance: () => require('../../../pages/GetAssistancePage').default, Settings_TwoFactorAuth: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default, + KeyboardShortcuts: () => require('../../../pages/KeyboardShortcutsPage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 3e3dc59dcd80..912e7b23b3dc 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -3,7 +3,6 @@ import lodashGet from 'lodash/get'; import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; import {getActionFromState} from '@react-navigation/core'; import Log from '../Log'; -import DomUtils from '../DomUtils'; import linkTo from './linkTo'; import ROUTES from '../../ROUTES'; import linkingConfig from './linkingConfig'; @@ -92,11 +91,6 @@ function navigate(route = ROUTES.HOME, type) { return; } - // A pressed navigation button will remain focused, keeping its tooltip visible, even if it's supposed to be out of view. - // To prevent that we blur the button manually (especially for Safari, where the mouse leave event is missing). - // More info: https://github.com/Expensify/App/issues/13146 - DomUtils.blurActiveElement(); - linkTo(navigationRef.current, route, type); } diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index bf069aba314e..fde5fe400c76 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -73,6 +73,10 @@ export default { path: ROUTES.SETTINGS_WALLET_DOMAINCARDS.route, exact: true, }, + Settings_Wallet_ReportVirtualCardFraud: { + path: ROUTES.SETTINGS_REPORT_FRAUD.route, + exact: true, + }, Settings_Wallet_EnablePayments: { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, @@ -85,6 +89,10 @@ export default { path: ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT, exact: true, }, + Settings_Wallet_Card_Activate: { + path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route, + exact: true, + }, Settings_Add_Debit_Card: { path: ROUTES.SETTINGS_ADD_DEBIT_CARD, exact: true, @@ -177,6 +185,9 @@ export default { Workspace_Settings: { path: ROUTES.WORKSPACE_SETTINGS.route, }, + Workspace_Settings_Currency: { + path: ROUTES.WORKSPACE_SETTINGS_CURRENCY.route, + }, Workspace_Card: { path: ROUTES.WORKSPACE_CARD.route, }, @@ -211,6 +222,9 @@ export default { GetAssistance: { path: ROUTES.GET_ASSISTANCE.route, }, + KeyboardShortcuts: { + path: ROUTES.KEYBOARD_SHORTCUTS, + }, }, }, Private_Notes: { @@ -344,6 +358,8 @@ export default { SplitDetails: { screens: { SplitDetails_Root: ROUTES.SPLIT_BILL_DETAILS.route, + SplitDetails_Edit_Request: ROUTES.EDIT_SPLIT_BILL.route, + SplitDetails_Edit_Currency: ROUTES.EDIT_SPLIT_BILL_CURRENCY.route, }, }, Task_Details: { diff --git a/src/libs/NetworkConnection.js b/src/libs/NetworkConnection.ts similarity index 90% rename from src/libs/NetworkConnection.js rename to src/libs/NetworkConnection.ts index 9ca09c9154bc..663a9c1b37d5 100644 --- a/src/libs/NetworkConnection.js +++ b/src/libs/NetworkConnection.ts @@ -1,6 +1,6 @@ -import _ from 'underscore'; import Onyx from 'react-native-onyx'; import NetInfo from '@react-native-community/netinfo'; +import throttle from 'lodash/throttle'; import AppStateMonitor from './AppStateMonitor'; import Log from './Log'; import * as NetworkActions from './actions/Network'; @@ -13,15 +13,17 @@ let hasPendingNetworkCheck = false; // Holds all of the callbacks that need to be triggered when the network reconnects let callbackID = 0; -const reconnectionCallbacks = {}; +const reconnectionCallbacks: Record Promise> = {}; /** * Loop over all reconnection callbacks and fire each one */ -const triggerReconnectionCallbacks = _.throttle( +const triggerReconnectionCallbacks = throttle( (reason) => { Log.info(`[NetworkConnection] Firing reconnection callbacks because ${reason}`); - _.each(reconnectionCallbacks, (callback) => callback()); + Object.values(reconnectionCallbacks).forEach((callback) => { + callback(); + }); }, 5000, {trailing: false}, @@ -30,10 +32,8 @@ const triggerReconnectionCallbacks = _.throttle( /** * Called when the offline status of the app changes and if the network is "reconnecting" (going from offline to online) * then all of the reconnection callbacks are triggered - * - * @param {Boolean} isCurrentlyOffline */ -function setOfflineStatus(isCurrentlyOffline) { +function setOfflineStatus(isCurrentlyOffline: boolean): void { NetworkActions.setIsOffline(isCurrentlyOffline); // When reconnecting, ie, going from offline to online, all the reconnection callbacks @@ -72,7 +72,7 @@ Onyx.connect({ * internet connectivity or not. This is more reliable than the Pusher * `disconnected` event which takes about 10-15 seconds to emit. */ -function subscribeToNetInfo() { +function subscribeToNetInfo(): void { // Note: We are disabling the configuration for NetInfo when using the local web API since requests can get stuck in a 'Pending' state and are not reliable indicators for "offline". // If you need to test the "recheck" feature then switch to the production API proxy server. if (!CONFIG.IS_USING_LOCAL_WEB) { @@ -101,7 +101,7 @@ function subscribeToNetInfo() { // Subscribe to the state change event via NetInfo so we can update // whether a user has internet connectivity or not. NetInfo.addEventListener((state) => { - Log.info('[NetworkConnection] NetInfo state change', false, state); + Log.info('[NetworkConnection] NetInfo state change', false, {...state}); if (shouldForceOffline) { Log.info('[NetworkConnection] Not setting offline status because shouldForceOffline = true'); return; @@ -120,11 +120,9 @@ function listenForReconnect() { /** * Register callback to fire when we reconnect - * - * @param {Function} callback - must return a Promise - * @returns {Function} unsubscribe method + * @returns unsubscribe method */ -function onReconnect(callback) { +function onReconnect(callback: () => Promise): () => void { const currentID = callbackID; callbackID++; reconnectionCallbacks[currentID] = callback; @@ -135,7 +133,7 @@ function onReconnect(callback) { * Delete all queued reconnection callbacks */ function clearReconnectionCallbacks() { - _.each(_.keys(reconnectionCallbacks), (key) => delete reconnectionCallbacks[key]); + Object.keys(reconnectionCallbacks).forEach((key) => delete reconnectionCallbacks[key]); } /** diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.js b/src/libs/Notification/LocalNotification/BrowserNotifications.js index 520f0de17bea..3199e4c6388d 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.js +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.js @@ -111,7 +111,7 @@ export default { const plainTextMessage = (_.find(message, (f) => f.type === 'COMMENT') || {}).text; if (isChatRoom) { - const roomName = _.get(report, 'reportName', ''); + const roomName = ReportUtils.getReportName(report); title = roomName; body = `${plainTextPerson}: ${plainTextMessage}`; } else { diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts index 477ac8464d57..6e8d0fa4362b 100644 --- a/src/libs/NumberUtils.ts +++ b/src/libs/NumberUtils.ts @@ -71,4 +71,14 @@ function generateRandomInt(a: number, b: number): number { return Math.floor(lower + Math.random() * (upper - lower + 1)); } -export {rand64, generateHexadecimalValue, generateRandomInt, clampWorklet}; +/** + * Parses a numeric string value containing a decimal separator from any locale. + * + * @param value the string value to parse + * @returns a floating point number parsed from the string value + */ +function parseFloatAnyLocale(value: string): number { + return parseFloat(value ? value.replace(',', '.') : value); +} + +export {rand64, generateHexadecimalValue, generateRandomInt, clampWorklet, parseFloatAnyLocale}; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 82285545b303..4467746475aa 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -91,33 +91,6 @@ Onyx.connect({ }, }); -/** - * Get the option for a policy expense report. - * @param {Object} report - * @returns {Object} - */ -function getPolicyExpenseReportOption(report) { - const expenseReport = policyExpenseReports[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]; - const policyExpenseChatAvatarSource = ReportUtils.getWorkspaceAvatar(expenseReport); - const reportName = ReportUtils.getReportName(expenseReport); - return { - ...expenseReport, - keyForList: expenseReport.policyID, - text: reportName, - alternateText: Localize.translateLocal('workspace.common.workspace'), - icons: [ - { - source: policyExpenseChatAvatarSource, - name: reportName, - type: CONST.ICON_TYPE_WORKSPACE, - }, - ], - selected: report.selected, - isPolicyExpenseChat: true, - searchText: report.searchText, - }; -} - /** * Adds expensify SMS domain (@expensify.sms) if login is a phone number and if it's not included yet * @@ -562,6 +535,28 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { return result; } +/** + * Get the option for a policy expense report. + * @param {Object} report + * @returns {Object} + */ +function getPolicyExpenseReportOption(report) { + const expenseReport = policyExpenseReports[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]; + + const option = createOption( + expenseReport.participantAccountIDs, + allPersonalDetails, + expenseReport, + {}, + { + showChatPreviewLine: false, + forcePolicyNamePreview: false, + }, + ); + option.selected = report.selected; + return option; +} + /** * Searches for a match when provided with a value * diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 05322472a407..13489c396c3c 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -17,13 +17,6 @@ function canUseDefaultRooms(betas: Beta[]): boolean { return betas?.includes(CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas); } -/** - * IOU Send feature is temporarily disabled. - */ -function canUseIOUSend(): boolean { - return false; -} - function canUseWallet(betas: Beta[]): boolean { return betas?.includes(CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas); } @@ -68,7 +61,6 @@ export default { canUseChronos, canUsePayWithExpensify, canUseDefaultRooms, - canUseIOUSend, canUseWallet, canUseCommentLinking, canUsePolicyRooms, diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 591656b5c06a..84381b49aea2 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1356,12 +1356,13 @@ function getMoneyRequestReportName(report, policy = undefined) { * into a flat object. Used for displaying transactions and sending them in API commands * * @param {Object} transaction + * @param {Object} createdDateFormat * @returns {Object} */ -function getTransactionDetails(transaction) { +function getTransactionDetails(transaction, createdDateFormat = CONST.DATE.FNS_FORMAT_STRING) { const report = getReport(transaction.reportID); return { - created: TransactionUtils.getCreated(transaction), + created: TransactionUtils.getCreated(transaction, createdDateFormat), amount: TransactionUtils.getAmount(transaction, isExpenseReport(report)), currency: TransactionUtils.getCurrency(transaction), comment: TransactionUtils.getDescription(transaction), @@ -1370,6 +1371,8 @@ function getTransactionDetails(transaction) { category: TransactionUtils.getCategory(transaction), billable: TransactionUtils.getBillable(transaction), tag: TransactionUtils.getTag(transaction), + mccGroup: TransactionUtils.getMCCGroup(transaction), + cardID: TransactionUtils.getCardID(transaction), }; } @@ -1900,15 +1903,11 @@ function getParentNavigationSubtitle(report) { function navigateToDetailsPage(report) { const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); - if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report) || isMoneyRequestReport(report)) { - Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); - return; - } - if (participantAccountIDs.length === 1) { + if (isDM(report) && participantAccountIDs.length === 1) { Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountIDs[0])); return; } - Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(report.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); } /** @@ -2122,6 +2121,7 @@ function buildOptimisticIOUReport(payeeAccountID, payerAccountID, total, chatRep reportID: generateReportID(), state: CONST.REPORT.STATE.SUBMITTED, stateNum: isSendingMoney ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.PROCESSING, + statusNum: isSendingMoney ? CONST.REPORT.STATUS.REIMBURSED : CONST.REPORT.STATE_NUM.PROCESSING, total, // We don't translate reportName because the server response is always in English @@ -2414,6 +2414,7 @@ function buildOptimisticReportPreview(chatReport, iouReport, comment = '', trans const hasReceipt = TransactionUtils.hasReceipt(transaction); const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); const message = getReportPreviewMessage(iouReport); + const created = DateUtils.getDBTime(); return { reportActionID: NumberUtils.rand64(), reportID: chatReport.reportID, @@ -2430,13 +2431,13 @@ function buildOptimisticReportPreview(chatReport, iouReport, comment = '', trans type: CONST.REPORT.MESSAGE.TYPE.COMMENT, }, ], - created: DateUtils.getDBTime(), + created, accountID: iouReport.managerID || 0, // The preview is initially whispered if created with a receipt, so the actor is the current user as well actorAccountID: hasReceipt ? currentUserAccountID : iouReport.managerID || 0, childMoneyRequestCount: 1, childLastMoneyRequestComment: comment, - childLastReceiptTransactionIDs: hasReceipt ? transaction.transactionID : '', + childRecentReceiptTransactionIDs: hasReceipt ? {[transaction.transactionID]: created} : [], whisperedToAccountIDs: isReceiptBeingScanned ? [currentUserAccountID] : [], }; } @@ -2495,8 +2496,9 @@ function buildOptimisticModifiedExpenseReportAction(transactionThread, oldTransa */ function updateReportPreview(iouReport, reportPreviewAction, isPayRequest = false, comment = '', transaction = undefined) { const hasReceipt = TransactionUtils.hasReceipt(transaction); - const lastReceiptTransactionIDs = lodashGet(reportPreviewAction, 'childLastReceiptTransactionIDs', ''); - const previousTransactionIDs = lastReceiptTransactionIDs.split(',').slice(0, 2); + const recentReceiptTransactions = lodashGet(reportPreviewAction, 'childRecentReceiptTransactionIDs', {}); + const transactionsToKeep = TransactionUtils.getRecentTransactions(recentReceiptTransactions); + const previousTransactions = _.mapObject(recentReceiptTransactions, (value, key) => (_.contains(transactionsToKeep, key) ? value : null)); const message = getReportPreviewMessage(iouReport, reportPreviewAction); return { @@ -2512,7 +2514,12 @@ function updateReportPreview(iouReport, reportPreviewAction, isPayRequest = fals ], childLastMoneyRequestComment: comment || reportPreviewAction.childLastMoneyRequestComment, childMoneyRequestCount: reportPreviewAction.childMoneyRequestCount + (isPayRequest ? 0 : 1), - childLastReceiptTransactionIDs: hasReceipt ? [transaction.transactionID, ...previousTransactionIDs].join(',') : lastReceiptTransactionIDs, + childRecentReceiptTransactionIDs: hasReceipt + ? { + [transaction.transactionID]: transaction.created, + ...previousTransactions, + } + : recentReceiptTransactions, // As soon as we add a transaction without a receipt to the report, it will have ready money requests, // so we remove the whisper whisperedToAccountIDs: hasReceipt ? reportPreviewAction.whisperedToAccountIDs : [], @@ -3367,10 +3374,9 @@ function canRequestMoney(report, participants) { * * @param {Object} report * @param {Array} reportParticipants - * @param {Array} betas * @returns {Array} */ -function getMoneyRequestOptions(report, reportParticipants, betas) { +function getMoneyRequestOptions(report, reportParticipants) { // In any thread or task report, we do not allow any new money requests yet if (isChatThread(report) || isTaskReport(report)) { return []; @@ -3402,7 +3408,7 @@ function getMoneyRequestOptions(report, reportParticipants, betas) { ...(canRequestMoney(report, participants) ? [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST] : []), // Send money option should be visible only in DMs - ...(Permissions.canUseIOUSend(betas) && isChatReport(report) && !isPolicyExpenseChat(report) && hasSingleParticipantInReport ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []), + ...(isChatReport(report) && !isPolicyExpenseChat(report) && hasSingleParticipantInReport ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []), ]; } @@ -3589,14 +3595,6 @@ function getWorkspaceChats(policyID, accountIDs) { return _.filter(allReports, (report) => isPolicyExpenseChat(report) && lodashGet(report, 'policyID', '') === policyID && _.contains(accountIDs, lodashGet(report, 'ownerAccountID', ''))); } -/* - * @param {Object|null} report - * @returns {Boolean} - */ -function shouldDisableSettings(report) { - return !isMoneyRequestReport(report) && !isPolicyExpenseChat(report) && !isChatRoom(report) && !isChatThread(report); -} - /** * @param {Object|null} report * @param {Object|null} policy - the workspace the report is on, null if the user isn't a member of the workspace @@ -3765,13 +3763,15 @@ function getParticipantsIDs(report) { * @returns {Object} */ function getReportPreviewDisplayTransactions(reportPreviewAction) { - const transactionIDs = lodashGet(reportPreviewAction, ['childLastReceiptTransactionIDs'], '').split(','); + const transactionIDs = lodashGet(reportPreviewAction, ['childRecentReceiptTransactionIDs']); return _.reduce( - transactionIDs, + _.keys(transactionIDs), (transactions, transactionID) => { - const transaction = TransactionUtils.getTransaction(transactionID); - if (TransactionUtils.hasReceipt(transaction)) { - transactions.push(transaction); + if (transactionIDs[transactionID] !== null) { + const transaction = TransactionUtils.getTransaction(transactionID); + if (TransactionUtils.hasReceipt(transaction)) { + transactions.push(transaction); + } } return transactions; }, @@ -3824,7 +3824,7 @@ function getIOUReportActionDisplayMessage(reportAction) { * @returns {Boolean} */ function isReportDraft(report) { - return lodashGet(report, 'stateNum') === CONST.REPORT.STATE_NUM.OPEN && lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.OPEN; + return isExpenseReport(report) && lodashGet(report, 'stateNum') === CONST.REPORT.STATE_NUM.OPEN && lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.OPEN; } export { @@ -3955,7 +3955,6 @@ export { getPolicy, getPolicyExpenseChatReportIDByOwner, getWorkspaceChats, - shouldDisableSettings, shouldDisableRename, hasSingleParticipant, getReportRecipientAccountIDs, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index beb1f9c323d6..a3c8f289d1c5 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -3,6 +3,7 @@ import {format, parseISO, isValid} from 'date-fns'; import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; import DateUtils from './DateUtils'; +import {isExpensifyCard} from './CardUtils'; import * as NumberUtils from './NumberUtils'; import {RecentWaypoint, ReportAction, Transaction} from '../types/onyx'; import {Receipt, Comment, WaypointCollection} from '../types/onyx/Transaction'; @@ -83,20 +84,25 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean { } function areRequiredFieldsEmpty(transaction: Transaction): boolean { - return ( + const isMerchantEmpty = + transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === ''; + + const isModifiedMerchantEmpty = + !transaction.modifiedMerchant || transaction.modifiedMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || - (transaction.modifiedMerchant === '' && - (transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === '' || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) || - (transaction.modifiedAmount === 0 && transaction.amount === 0) || - (transaction.modifiedCreated === '' && transaction.created === '') - ); + transaction.modifiedMerchant === ''; + + const isModifiedAmountEmpty = !transaction.modifiedAmount || transaction.modifiedAmount === 0; + const isModifiedCreatedEmpty = !transaction.modifiedCreated || transaction.modifiedCreated === ''; + + return (isModifiedMerchantEmpty && isMerchantEmpty) || (isModifiedAmountEmpty && transaction.amount === 0) || (isModifiedCreatedEmpty && transaction.created === ''); } /** * Given the edit made to the money request, return an updated transaction object. */ -function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean): Transaction { +function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, shouldUpdateReceiptState = true): Transaction { // Only changing the first level fields so no need for deep clone now const updatedTransaction = {...transaction}; let shouldStopSmartscan = false; @@ -143,7 +149,13 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra updatedTransaction.tag = transactionChanges.tag; } - if (shouldStopSmartscan && transaction?.receipt && Object.keys(transaction.receipt).length > 0 && transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN) { + if ( + shouldUpdateReceiptState && + shouldStopSmartscan && + transaction?.receipt && + Object.keys(transaction.receipt).length > 0 && + transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN + ) { updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN; } @@ -244,6 +256,13 @@ function getCategory(transaction: Transaction): string { return transaction?.category ?? ''; } +/** + * Return the cardID from the transaction. + */ +function getCardID(transaction: Transaction): number { + return transaction?.cardID ?? 0; +} + /** * Return the billable field from the transaction. This "billable" field has no "modified" complement. */ @@ -261,11 +280,11 @@ function getTag(transaction: Transaction): string { /** * Return the created field from the transaction, return the modifiedCreated if present. */ -function getCreated(transaction: Transaction): string { +function getCreated(transaction: Transaction, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING): string { const created = transaction?.modifiedCreated ? transaction.modifiedCreated : transaction?.created || ''; const createdDate = parseISO(created); if (isValid(createdDate)) { - return format(createdDate, CONST.DATE.FNS_FORMAT_STRING); + return format(createdDate, dateFormat); } return ''; @@ -277,6 +296,27 @@ function isDistanceRequest(transaction: Transaction): boolean { return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE; } +function isExpensifyCardTransaction(transaction: Transaction): boolean { + if (!transaction.cardID) { + return false; + } + return isExpensifyCard(transaction.cardID); +} + +function isPending(transaction: Transaction): boolean { + if (!transaction.status) { + return false; + } + return transaction.status === CONST.TRANSACTION.STATUS.PENDING; +} + +function isPosted(transaction: Transaction): boolean { + if (!transaction.status) { + return false; + } + return transaction.status === CONST.TRANSACTION.STATUS.POSTED; +} + function isReceiptBeingScanned(transaction: Transaction): boolean { return [CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING].some((value) => value === transaction.receipt.state); } @@ -374,6 +414,15 @@ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = fal }, {}); } +/** + * Returns the most recent transactions in an object + */ +function getRecentTransactions(transactions: Record, size = 2): string[] { + return Object.keys(transactions) + .sort((transactionID1, transactionID2) => (new Date(transactions[transactionID1]) < new Date(transactions[transactionID2]) ? 1 : -1)) + .slice(0, size); +} + export { buildOptimisticTransaction, getUpdatedTransaction, @@ -381,6 +430,7 @@ export { getDescription, getAmount, getCurrency, + getCardID, getMerchant, getMCCGroup, getCreated, @@ -395,8 +445,13 @@ export { isReceiptBeingScanned, getValidWaypoints, isDistanceRequest, + isExpensifyCardTransaction, + isPending, + isPosted, getWaypoints, + areRequiredFieldsEmpty, hasMissingSmartscanFields, getWaypointIndex, waypointHasValidAddress, + getRecentTransactions, }; diff --git a/src/libs/__mocks__/Permissions.ts b/src/libs/__mocks__/Permissions.ts index 2c062590573e..66ef64bbb994 100644 --- a/src/libs/__mocks__/Permissions.ts +++ b/src/libs/__mocks__/Permissions.ts @@ -12,6 +12,5 @@ export default { ...jest.requireActual('../Permissions'), canUseDefaultRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.DEFAULT_ROOMS), canUsePolicyRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.POLICY_ROOMS), - canUseIOUSend: (betas: Beta[]) => betas.includes(CONST.BETAS.IOU_SEND), canUseCustomStatus: (betas: Beta[]) => betas.includes(CONST.BETAS.CUSTOM_STATUS), }; diff --git a/src/libs/actions/CanvasSize.js b/src/libs/actions/CanvasSize.js index ed83562a3e43..0c4cd88fea70 100644 --- a/src/libs/actions/CanvasSize.js +++ b/src/libs/actions/CanvasSize.js @@ -1,16 +1,25 @@ import Onyx from 'react-native-onyx'; import canvasSize from 'canvas-size'; import ONYXKEYS from '../../ONYXKEYS'; +import * as Browser from '../Browser'; /** * Calculate the max area of canvas on this specific platform and save it in onyx */ function retrieveMaxCanvasArea() { - canvasSize.maxArea({ - onSuccess: (width, height) => { - Onyx.merge(ONYXKEYS.MAX_CANVAS_AREA, width * height); - }, - }); + // We're limiting the maximum value on mobile web to prevent a crash related to rendering large canvas elements. + // More information at: https://github.com/jhildenbiddle/canvas-size/issues/13 + canvasSize + .maxArea({ + max: Browser.isMobile() ? 8192 : null, + usePromise: true, + useWorker: false, + }) + .then(() => ({ + onSuccess: (width, height) => { + Onyx.merge(ONYXKEYS.MAX_CANVAS_AREA, width * height); + }, + })); } /** diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js new file mode 100644 index 000000000000..a060c1bc67fa --- /dev/null +++ b/src/libs/actions/Card.js @@ -0,0 +1,104 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as API from '../API'; + +/** + * @param {Number} cardID + */ +function reportVirtualExpensifyCardFraud(cardID) { + API.write( + 'ReportVirtualExpensifyCardFraud', + { + cardID, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, + value: { + isLoading: true, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, + value: { + isLoading: false, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, + value: { + isLoading: false, + }, + }, + ], + }, + ); +} + +/** + * Activates the physical Expensify card based on the last four digits of the card number + * + * @param {Number} lastFourDigits + * @param {Number} cardID + */ +function activatePhysicalExpensifyCard(lastFourDigits, cardID) { + API.write( + 'ActivatePhysicalExpensifyCard', + {lastFourDigits, cardID}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + errors: null, + isLoading: true, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + isLoading: false, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + isLoading: false, + }, + }, + }, + ], + }, + ); +} + +/** + * Clears errors for a specific cardID + * + * @param {Number} cardID + */ +function clearCardListErrors(cardID) { + Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: {errors: null, isLoading: false}}); +} + +export {reportVirtualExpensifyCardFraud, activatePhysicalExpensifyCard, clearCardListErrors}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 2c046bfc2a24..c469ed02a084 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -24,6 +24,14 @@ import ReceiptGeneric from '../../../assets/images/receipt-generic.png'; import * as LocalePhoneNumber from '../LocalePhoneNumber'; import * as Policy from './Policy'; +let allPersonalDetails; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (val) => { + allPersonalDetails = val || {}; + }, +}); + let allReports; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -45,6 +53,15 @@ Onyx.connect({ }, }); +let allDraftSplitTransactions; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + allDraftSplitTransactions = val || {}; + }, +}); + let allRecentlyUsedTags = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS, @@ -116,7 +133,7 @@ function resetMoneyRequestInfo(id = '') { tag: '', created, receiptPath: '', - receiptSource: '', + receiptFilename: '', transactionID: '', billable: null, }); @@ -472,7 +489,10 @@ function getMoneyRequestInformation( billable, ); - const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); + let optimisticPolicyRecentlyUsedCategories = []; + if (category) { + optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); + } const optimisticPolicyRecentlyUsedTags = {}; const policyTags = allPolicyTags[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${iouReport.policyID}`]; @@ -1259,6 +1279,448 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou Report.notifyNewAction(splitData.chatReportID, currentUserAccountID); } +/** Used exclusively for starting a split bill request that contains a receipt, the split request will be completed once the receipt is scanned + * or user enters details manually. + * + * @param {Array} participants + * @param {String} currentUserLogin + * @param {Number} currentUserAccountID + * @param {String} comment + * @param {Object} receipt + * @param {String} existingSplitChatReportID - Either a group DM or a workspace chat + */ +function startSplitBill(participants, currentUserLogin, currentUserAccountID, comment, receipt, existingSplitChatReportID = '') { + const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin); + const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID)); + const existingSplitChatReport = + existingSplitChatReportID || participants[0].reportID + ? allReports[`${ONYXKEYS.COLLECTION.REPORT}${existingSplitChatReportID || participants[0].reportID}`] + : ReportUtils.getChatByParticipants(participantAccountIDs); + const splitChatReport = existingSplitChatReport || ReportUtils.buildOptimisticChatReport(participantAccountIDs); + const isOwnPolicyExpenseChat = splitChatReport.isOwnPolicyExpenseChat || false; + + const {name: filename, source, state = CONST.IOU.RECEIPT_STATE.SCANREADY} = receipt; + const receiptObject = {state, source}; + + // ReportID is -2 (aka "deleted") on the group transaction + const splitTransaction = TransactionUtils.buildOptimisticTransaction(0, CONST.CURRENCY.USD, CONST.REPORT.SPLIT_REPORTID, comment, '', '', '', '', receiptObject, filename); + + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat + const splitChatCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const splitIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.SPLIT, + 0, + CONST.CURRENCY.USD, + comment, + participants, + splitTransaction.transactionID, + '', + '', + false, + false, + receiptObject, + isOwnPolicyExpenseChat, + ); + + splitChatReport.lastReadTime = DateUtils.getDBTime(); + splitChatReport.lastMessageText = splitIOUReportAction.message[0].text; + splitChatReport.lastMessageHtml = splitIOUReportAction.message[0].html; + + // If we have an existing splitChatReport (group chat or workspace) use it's pending fields, otherwise indicate that we are adding a chat + if (!existingSplitChatReport) { + splitChatReport.pendingFields = { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + } + + const optimisticData = [ + { + // Use set for new reports because it doesn't exist yet, is faster, + // and we need the data to be available when we navigate to the chat page + onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: splitChatReport, + }, + { + onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + ...(existingSplitChatReport ? {} : {[splitChatCreatedReportAction.reportActionID]: splitChatCreatedReportAction}), + [splitIOUReportAction.reportActionID]: splitIOUReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: splitTransaction, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + ...(existingSplitChatReport ? {} : {[splitChatCreatedReportAction.reportActionID]: {pendingAction: null}}), + [splitIOUReportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: {pendingAction: null}, + }, + ]; + + if (!existingSplitChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: {pendingFields: {createChat: null}}, + }); + } + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + ]; + + if (existingSplitChatReport) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + }); + } else { + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitChatCreatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + [splitIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateFailureMessage'), + }, + }, + }, + ); + } + + const splits = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID}]; + + _.each(participants, (participant) => { + const email = participant.isOwnPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login || participant.text).toLowerCase(); + const accountID = participant.isOwnPolicyExpenseChat ? 0 : Number(participant.accountID); + if (email === currentUserEmailForIOUSplit) { + return; + } + + // When splitting with a workspace chat, we only need to supply the policyID and the workspace reportID as it's needed so we can update the report preview + if (participant.isOwnPolicyExpenseChat) { + splits.push({ + policyID: participant.policyID, + chatReportID: splitChatReport.reportID, + }); + return; + } + + const participantPersonalDetails = allPersonalDetails[participant.accountID]; + if (!participantPersonalDetails) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + accountID, + avatar: UserUtils.getDefaultAvatarURL(accountID), + displayName: LocalePhoneNumber.formatPhoneNumber(participant.displayName || email), + login: participant.login || participant.text, + isOptimisticPersonalDetail: true, + }, + }, + }); + } + + splits.push({ + email, + accountID, + }); + }); + + // Save the new splits array into the transaction's comment in case the user calls CompleteSplitBill while offline + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + comment: { + splits, + }, + }, + }); + + API.write( + 'StartSplitBill', + { + chatReportID: splitChatReport.reportID, + reportActionID: splitIOUReportAction.reportActionID, + transactionID: splitTransaction.transactionID, + splits: JSON.stringify(splits), + receipt, + comment, + isFromGroupDM: !existingSplitChatReport, + ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), + }, + {optimisticData, successData, failureData}, + ); + + resetMoneyRequestInfo(); + Navigation.dismissModal(splitChatReport.reportID); + Report.notifyNewAction(splitChatReport.chatReportID, currentUserAccountID); +} + +/** Used for editing a split bill while it's still scanning or when SmartScan fails, it completes a split bill started by startSplitBill above. + * + * @param {number} chatReportID - The group chat or workspace reportID + * @param {Object} reportAction - The split action that lives in the chatReport above + * @param {Object} updatedTransaction - The updated **draft** split transaction + * @param {Number} sessionAccountID - accountID of the current user + * @param {String} sessionEmail - email of the current user + */ +function completeSplitBill(chatReportID, reportAction, updatedTransaction, sessionAccountID, sessionEmail) { + const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail); + const {transactionID} = updatedTransaction; + const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + + // Save optimistic updated transaction and action + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + receipt: { + state: CONST.IOU.RECEIPT_STATE.OPEN, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + lastModified: DateUtils.getDBTime(), + whisperedToAccountIDs: [], + }, + }, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + value: null, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...unmodifiedTransaction, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ]; + + const splitParticipants = updatedTransaction.comment.splits; + const {modifiedAmount: amount, modifiedCurrency: currency} = updatedTransaction; + + // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account + const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount, currency, false); + + const splits = [{email: currentUserEmailForIOUSplit}]; + _.each(splitParticipants, (participant) => { + // Skip creating the transaction for the current user + if (participant.email === currentUserEmailForIOUSplit) { + return; + } + const isPolicyExpenseChat = !_.isEmpty(participant.policyID); + + if (!isPolicyExpenseChat) { + // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know + // if there is an existing chat between the split creator and this participant + // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created + const participantPersonalDetails = allPersonalDetails[participant.accountID] || {}; + if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) { + splits.push({ + email: participant.email, + }); + return; + } + } + + let oneOnOneChatReport; + let isNewOneOnOneChatReport = false; + if (isPolicyExpenseChat) { + // The workspace chat reportID is saved in the splits array when starting a split bill with a workspace + oneOnOneChatReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; + } else { + const existingChatReport = ReportUtils.getChatByParticipants([participant.accountID]); + isNewOneOnOneChatReport = !existingChatReport; + oneOnOneChatReport = existingChatReport || ReportUtils.buildOptimisticChatReport([participant.accountID]); + } + + let oneOnOneIOUReport = lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`, undefined); + const shouldCreateNewOneOnOneIOUReport = + _.isUndefined(oneOnOneIOUReport) || (isPolicyExpenseChat && ReportUtils.isControlPolicyExpenseReport(oneOnOneIOUReport) && ReportUtils.isReportApproved(oneOnOneIOUReport)); + + if (shouldCreateNewOneOnOneIOUReport) { + oneOnOneIOUReport = isPolicyExpenseChat + ? ReportUtils.buildOptimisticExpenseReport(oneOnOneChatReport.reportID, participant.policyID, sessionAccountID, splitAmount, currency) + : ReportUtils.buildOptimisticIOUReport(sessionAccountID, participant.accountID, splitAmount, oneOnOneChatReport.reportID, currency); + } else if (isPolicyExpenseChat) { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + oneOnOneIOUReport.total -= splitAmount; + } else { + oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(oneOnOneIOUReport, sessionAccountID, splitAmount, currency); + } + + const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction( + isPolicyExpenseChat ? -splitAmount : splitAmount, + currency, + oneOnOneIOUReport.reportID, + updatedTransaction.comment.comment, + updatedTransaction.modifiedCreated, + CONST.IOU.MONEY_REQUEST_TYPE.SPLIT, + transactionID, + updatedTransaction.modifiedMerchant, + {...updatedTransaction.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN}, + updatedTransaction.filename, + ); + + const oneOnOneCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const oneOnOneCreatedActionForIOU = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const oneOnOneIOUAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + splitAmount, + currency, + updatedTransaction.comment.comment, + [participant], + oneOnOneTransaction.transactionID, + '', + oneOnOneIOUReport.reportID, + ); + + let oneOnOneReportPreviewAction = ReportActionsUtils.getReportPreviewAction(oneOnOneChatReport.reportID, oneOnOneIOUReport.reportID); + if (oneOnOneReportPreviewAction) { + oneOnOneReportPreviewAction = ReportUtils.updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction); + } else { + oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); + } + + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( + oneOnOneChatReport, + oneOnOneIOUReport, + oneOnOneTransaction, + oneOnOneCreatedActionForChat, + oneOnOneCreatedActionForIOU, + oneOnOneIOUAction, + {}, + oneOnOneReportPreviewAction, + {}, + {}, + isNewOneOnOneChatReport, + shouldCreateNewOneOnOneIOUReport, + ); + + splits.push({ + email: participant.email, + accountID: participant.accountID, + policyID: participant.policyID, + iouReportID: oneOnOneIOUReport.reportID, + chatReportID: oneOnOneChatReport.reportID, + transactionID: oneOnOneTransaction.transactionID, + reportActionID: oneOnOneIOUAction.reportActionID, + createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, + createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, + reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + }); + + optimisticData.push(...oneOnOneOptimisticData); + successData.push(...oneOnOneSuccessData); + failureData.push(...oneOnOneFailureData); + }); + + API.write( + 'CompleteSplitBill', + { + transactionID, + amount: updatedTransaction.modifiedAmount, + currency: updatedTransaction.modifiedCurrency, + created: updatedTransaction.modifiedCreated, + merchant: updatedTransaction.modifiedMerchant, + comment: updatedTransaction.comment.comment, + splits: JSON.stringify(splits), + }, + {optimisticData, successData, failureData}, + ); + Navigation.dismissModal(chatReportID); + Report.notifyNewAction(chatReportID, sessionAccountID); +} + +/** + * @param {String} transactionID + * @param {Object} transactionChanges + */ +function setDraftSplitTransaction(transactionID, transactionChanges = {}) { + let draftSplitTransaction = allDraftSplitTransactions[`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`]; + + if (!draftSplitTransaction) { + draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + } + + const updatedTransaction = TransactionUtils.getUpdatedTransaction(draftSplitTransaction, transactionChanges, false, false); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); +} + /** * @param {String} transactionID * @param {Number} transactionThreadReportID @@ -1706,7 +2168,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType } const optimisticIOUReport = ReportUtils.buildOptimisticIOUReport(recipientAccountID, managerID, amount, chatReport.reportID, currency, true); - const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount * 100, currency, optimisticIOUReport.reportID, comment); + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount, currency, optimisticIOUReport.reportID, comment); const optimisticTransactionData = { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, @@ -2201,6 +2663,29 @@ function payMoneyRequest(paymentType, chatReport, iouReport) { Navigation.dismissModal(chatReport.reportID); } +function detachReceipt(transactionID) { + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] || {}; + const newTransaction = {...transaction, filename: '', receipt: {}}; + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: newTransaction, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: transaction, + }, + ]; + + API.write('DetachReceipt', {transactionID}, {optimisticData, failureData}); +} + /** * @param {String} transactionID * @param {Object} receipt @@ -2328,10 +2813,10 @@ function setMoneyRequestParticipants(participants) { /** * @param {String} receiptPath - * @param {String} receiptSource + * @param {String} receiptFilename */ -function setMoneyRequestReceipt(receiptPath, receiptSource) { - Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptSource, merchant: ''}); +function setMoneyRequestReceipt(receiptPath, receiptFilename) { + Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptFilename, merchant: ''}); } function createEmptyTransaction() { @@ -2407,6 +2892,9 @@ export { deleteMoneyRequest, splitBill, splitBillAndOpenReport, + setDraftSplitTransaction, + startSplitBill, + completeSplitBill, requestMoney, sendMoneyElsewhere, approveMoneyRequest, @@ -2432,5 +2920,6 @@ export { navigateToNextPage, updateDistanceRequest, replaceReceipt, + detachReceipt, getIOUReportID, }; diff --git a/src/libs/actions/KeyboardShortcuts.js b/src/libs/actions/KeyboardShortcuts.js deleted file mode 100644 index d66e362890b2..000000000000 --- a/src/libs/actions/KeyboardShortcuts.js +++ /dev/null @@ -1,31 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '../../ONYXKEYS'; - -let isShortcutsModalOpen; -Onyx.connect({ - key: ONYXKEYS.IS_SHORTCUTS_MODAL_OPEN, - callback: (flag) => (isShortcutsModalOpen = flag), - initWithStoredValues: false, -}); - -/** - * Set keyboard shortcuts flag to show modal - */ -function showKeyboardShortcutModal() { - if (isShortcutsModalOpen) { - return; - } - Onyx.set(ONYXKEYS.IS_SHORTCUTS_MODAL_OPEN, true); -} - -/** - * Unset keyboard shortcuts flag to hide modal - */ -function hideKeyboardShortcutModal() { - if (!isShortcutsModalOpen) { - return; - } - Onyx.set(ONYXKEYS.IS_SHORTCUTS_MODAL_OPEN, false); -} - -export {showKeyboardShortcutModal, hideKeyboardShortcutModal}; diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index edb169fc96aa..388010e99569 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -1,20 +1,20 @@ import Onyx from 'react-native-onyx'; import CONST from '../../../CONST'; import ONYXKEYS from '../../../ONYXKEYS'; -import * as store from './store'; import * as API from '../../API'; import * as PlaidDataProps from '../../../pages/ReimbursementAccount/plaidDataPropTypes'; import * as ReimbursementAccountProps from '../../../pages/ReimbursementAccount/reimbursementAccountPropTypes'; /** * Reset user's reimbursement account. This will delete the bank account. - * @param {number} bankAccountID + * @param {Number} bankAccountID + * @param {Object} session */ -function resetFreePlanBankAccount(bankAccountID) { +function resetFreePlanBankAccount(bankAccountID, session) { if (!bankAccountID) { throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); } - if (!store.getCredentials() || !store.getCredentials().login) { + if (!session.email) { throw new Error('Missing credentials when attempting to reset free plan bank account'); } @@ -22,7 +22,7 @@ function resetFreePlanBankAccount(bankAccountID) { 'RestartBankAccountSetup', { bankAccountID, - ownerEmail: store.getCredentials().login, + ownerEmail: session.email, }, { optimisticData: [ diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index ab2ac7fb0ca2..27a02b1fc75f 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -87,6 +87,19 @@ Onyx.connect({ }, }); +const draftNoteMap = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT, + callback: (value, key) => { + if (!key) { + return; + } + + const reportID = key.replace(ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT, ''); + draftNoteMap[reportID] = value; + }, +}); + const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -2183,6 +2196,21 @@ function clearPrivateNotesError(reportID, accountID) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {privateNotes: {[accountID]: {errors: null}}}); } +function getDraftPrivateNote(reportID) { + return draftNoteMap[reportID]; +} + +/** + * Saves the private notes left by the user as they are typing. By saving this data the user can switch between chats, close + * tab, refresh etc without worrying about loosing what they typed out. + * + * @param {String} reportID + * @param {String} note + */ +function savePrivateNotesDraft(reportID, note) { + Onyx.merge(`${ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT}${reportID}`, note); +} + export { addComment, addAttachment, @@ -2237,4 +2265,6 @@ export { getReportPrivateNote, clearPrivateNotesError, hasErrorInPrivateNotes, + savePrivateNotesDraft, + getDraftPrivateNote, }; diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.js index e508d096128d..ba06b80f7c43 100644 --- a/src/libs/fileDownload/FileUtils.js +++ b/src/libs/fileDownload/FileUtils.js @@ -48,6 +48,27 @@ function showPermissionErrorAlert() { ]); } +/** + * Inform the users when they need to grant camera access and guide them to settings + */ +function showCameraPermissionsAlert() { + Alert.alert( + Localize.translateLocal('attachmentPicker.cameraPermissionRequired'), + Localize.translateLocal('attachmentPicker.expensifyDoesntHaveAccessToCamera'), + [ + { + text: Localize.translateLocal('common.cancel'), + style: 'cancel', + }, + { + text: Localize.translateLocal('common.settings'), + onPress: () => Linking.openSettings(), + }, + ], + {cancelable: false}, + ); +} + /** * Generate a random file name with timestamp and file extension * @param {String} url @@ -170,4 +191,55 @@ const readFileAsync = (path, fileName) => }); }); -export {showGeneralErrorAlert, showSuccessAlert, showPermissionErrorAlert, splitExtensionFromFileName, getAttachmentName, getFileType, cleanFileName, appendTimeToFileName, readFileAsync}; +/** + * Converts a base64 encoded image string to a File instance. + * Adds a `uri` property to the File instance for accessing the blob as a URI. + * + * @param {string} base64 - The base64 encoded image string. + * @param {string} filename - Desired filename for the File instance. + * @returns {File} The File instance created from the base64 string with an additional `uri` property. + * + * @example + * const base64Image = "data:image/png;base64,..."; // your base64 encoded image + * const imageFile = base64ToFile(base64Image, "example.png"); + * console.log(imageFile.uri); // Blob URI + */ +function base64ToFile(base64, filename) { + // Decode the base64 string + const byteString = atob(base64.split(',')[1]); + + // Get the mime type from the base64 string + const mimeString = base64.split(',')[0].split(':')[1].split(';')[0]; + + // Convert byte string to Uint8Array + const arrayBuffer = new ArrayBuffer(byteString.length); + const uint8Array = new Uint8Array(arrayBuffer); + for (let i = 0; i < byteString.length; i++) { + uint8Array[i] = byteString.charCodeAt(i); + } + + // Create a blob from the Uint8Array + const blob = new Blob([uint8Array], {type: mimeString}); + + // Create a File instance from the Blob + const file = new File([blob], filename, {type: mimeString, lastModified: Date.now()}); + + // Add a uri property to the File instance for accessing the blob as a URI + file.uri = URL.createObjectURL(blob); + + return file; +} + +export { + showGeneralErrorAlert, + showSuccessAlert, + showPermissionErrorAlert, + showCameraPermissionsAlert, + splitExtensionFromFileName, + getAttachmentName, + getFileType, + cleanFileName, + appendTimeToFileName, + readFileAsync, + base64ToFile, +}; diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js index d96a37d95457..aaa706e71fb2 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -1,6 +1,5 @@ import _ from 'underscore'; import Log from './Log'; -import RenamePriorityModeKey from './migrations/RenamePriorityModeKey'; import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; @@ -10,7 +9,7 @@ export default function () { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [RenamePriorityModeKey, PersonalDetailsByAccountID, RenameReceiptFilename]; + const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/RenamePriorityModeKey.js b/src/libs/migrations/RenamePriorityModeKey.js deleted file mode 100644 index a2be26880b52..000000000000 --- a/src/libs/migrations/RenamePriorityModeKey.js +++ /dev/null @@ -1,33 +0,0 @@ -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import ONYXKEYS from '../../ONYXKEYS'; -import Log from '../Log'; - -// This migration changes the name of the Onyx key NVP_PRIORITY_MODE from priorityMode to nvp_priorityMode -export default function () { - return new Promise((resolve) => { - // Connect to the old key in Onyx to get the old value of priorityMode - // then set the new key nvp_priorityMode to hold the old data - // finally remove the old key by setting the value to null - const connectionID = Onyx.connect({ - key: 'priorityMode', - callback: (oldPriorityMode) => { - Onyx.disconnect(connectionID); - - // Fail early here because there is nothing to migrate - if (_.isEmpty(oldPriorityMode)) { - Log.info('[Migrate Onyx] Skipped migration RenamePriorityModeKey'); - return resolve(); - } - - Onyx.multiSet({ - priorityMode: null, - [ONYXKEYS.NVP_PRIORITY_MODE]: oldPriorityMode, - }).then(() => { - Log.info('[Migrate Onyx] Ran migration RenamePriorityModeKey'); - resolve(); - }); - }, - }); - }); -} diff --git a/src/pages/ConciergePage.js b/src/pages/ConciergePage.js index e8509024b469..cfd452f0c952 100644 --- a/src/pages/ConciergePage.js +++ b/src/pages/ConciergePage.js @@ -32,8 +32,10 @@ function ConciergePage(props) { useFocusEffect(() => { if (_.has(props.session, 'authToken')) { // Pop the concierge loading page before opening the concierge report. - Navigation.goBack(ROUTES.HOME); - Report.navigateToConciergeChat(); + Navigation.isNavigationReady().then(() => { + Navigation.goBack(ROUTES.HOME); + Report.navigateToConciergeChat(); + }); } else { Navigation.navigate(); } diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 2f8d42c686a9..e4a1b763cd62 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -48,13 +48,10 @@ const propTypes = { /** Route params */ route: matchType.isRequired, - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Date login was validated, used to show info indicator status */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user accountID */ + accountID: PropTypes.number, }), ...withLocalizePropTypes, @@ -63,7 +60,9 @@ const propTypes = { const defaultProps = { // When opening someone else's profile (via deep link) before login, this is empty personalDetails: {}, - loginList: {}, + session: { + accountID: 0, + }, }; /** @@ -123,7 +122,7 @@ function DetailsPage(props) { const phoneNumber = getPhoneNumber(details); const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : details.login; - const isCurrentUser = _.keys(props.loginList).includes(details.login); + const isCurrentUser = props.session.accountID === details.accountID; return ( @@ -225,8 +224,8 @@ export default compose( personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - loginList: { - key: ONYXKEYS.LOGIN_LIST, + session: { + key: ONYXKEYS.SESSION, }, }), )(DetailsPage); diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js index 9f72c9afbc23..d65fdafb3b59 100644 --- a/src/pages/EditRequestAmountPage.js +++ b/src/pages/EditRequestAmountPage.js @@ -1,13 +1,11 @@ import React, {useCallback, useRef} from 'react'; -import {InteractionManager} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; import PropTypes from 'prop-types'; +import CONST from '../CONST'; +import useLocalize from '../hooks/useLocalize'; import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; -import Navigation from '../libs/Navigation/Navigation'; -import useLocalize from '../hooks/useLocalize'; import MoneyRequestAmountForm from './iou/steps/MoneyRequestAmountForm'; -import ROUTES from '../ROUTES'; const propTypes = { /** Transaction default amount value */ @@ -19,36 +17,25 @@ const propTypes = { /** Callback to fire when the Save button is pressed */ onSubmit: PropTypes.func.isRequired, - /** reportID for the transaction thread */ - reportID: PropTypes.string.isRequired, + /** Callback to fire when we press on the currency */ + onNavigateToCurrency: PropTypes.func.isRequired, }; -function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, reportID}) { +function EditRequestAmountPage({defaultAmount, defaultCurrency, onNavigateToCurrency, onSubmit}) { const {translate} = useLocalize(); - const textInput = useRef(null); - const focusTextInput = () => { - // Component may not be initialized due to navigation transitions - // Wait until interactions are complete before trying to focus - InteractionManager.runAfterInteractions(() => { - // Focus text input - if (!textInput.current) { - return; - } - - textInput.current.focus(); - }); - }; - - const navigateToCurrencySelectionPage = () => { - // Remove query from the route and encode it. - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); - Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(reportID, defaultCurrency, activeRoute)); - }; + const textInput = useRef(null); + const focusTimeoutRef = useRef(null); useFocusEffect( useCallback(() => { - focusTextInput(); + focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; }, []), ); @@ -64,7 +51,7 @@ function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, report currency={defaultCurrency} amount={defaultAmount} ref={(e) => (textInput.current = e)} - onCurrencyButtonPress={navigateToCurrencySelectionPage} + onCurrencyButtonPress={onNavigateToCurrency} onSubmitButtonPress={onSubmit} /> diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js index 9edce7350400..d326e9115afc 100644 --- a/src/pages/EditRequestCreatedPage.js +++ b/src/pages/EditRequestCreatedPage.js @@ -2,11 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; -import Form from '../components/Form'; import ONYXKEYS from '../ONYXKEYS'; import styles from '../styles/styles'; import useLocalize from '../hooks/useLocalize'; import NewDatePicker from '../components/NewDatePicker'; +import FormProvider from '../components/Form/FormProvider'; const propTypes = { /** Transaction defailt created value */ @@ -26,7 +26,7 @@ function EditRequestCreatedPage({defaultCreated, onSubmit}) { testID={EditRequestCreatedPage.displayName} > -
- + ); } diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js index 3a606aeb8f07..f5beba5fdcfd 100644 --- a/src/pages/EditRequestDistancePage.js +++ b/src/pages/EditRequestDistancePage.js @@ -39,13 +39,17 @@ const propTypes = { /* Onyx props */ /** The original transaction that is being edited */ transaction: transactionPropTypes, + + /** backup version of the original transaction */ + transactionBackup: transactionPropTypes, }; const defaultProps = { transaction: {}, + transactionBackup: {}, }; -function EditRequestDistancePage({report, route, transaction}) { +function EditRequestDistancePage({report, route, transaction, transactionBackup}) { const {isOffline} = useNetwork(); const {translate} = useLocalize(); const transactionWasSaved = useRef(false); @@ -87,6 +91,16 @@ function EditRequestDistancePage({report, route, transaction}) { * @param {Object} waypoints */ const saveTransaction = (waypoints) => { + // If nothing was changed, simply go to transaction thread + // We compare only addresses because numbers are rounded while backup + const oldWaypoints = lodashGet(transactionBackup, 'comment.waypoints', {}); + const oldAddresses = _.mapObject(oldWaypoints, (waypoint) => _.pick(waypoint, 'address')); + const addresses = _.mapObject(waypoints, (waypoint) => _.pick(waypoint, 'address')); + if (_.isEqual(oldAddresses, addresses)) { + Navigation.dismissModal(report.reportID); + return; + } + transactionWasSaved.current = true; IOU.updateDistanceRequest(transaction.transactionID, report.reportID, {waypoints}); @@ -125,4 +139,7 @@ export default withOnyx({ transaction: { key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`, }, + transactionBackup: { + key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}-backup`, + }, })(EditRequestDistancePage); diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 28e70dc1a47e..a85f490bbb42 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -5,6 +5,7 @@ import lodashValues from 'lodash/values'; import {withOnyx} from 'react-native-onyx'; import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; +import ROUTES from '../ROUTES'; import compose from '../libs/compose'; import Navigation from '../libs/Navigation/Navigation'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; @@ -30,6 +31,7 @@ import EditRequestCategoryPage from './EditRequestCategoryPage'; import EditRequestTagPage from './EditRequestTagPage'; import categoryPropTypes from '../components/categoryPropTypes'; import ScreenWrapper from '../components/ScreenWrapper'; +import transactionPropTypes from '../components/transactionPropTypes'; const propTypes = { /** Route from navigation */ @@ -75,6 +77,9 @@ const propTypes = { /** Collection of tags attached to a policy */ policyTags: tagPropTypes, + /** The original transaction that is being edited */ + transaction: transactionPropTypes, + ...withCurrentUserPersonalDetailsPropTypes, }; @@ -88,10 +93,12 @@ const defaultProps = { }, policyCategories: {}, policyTags: {}, + transaction: {}, }; function EditRequestPage({betas, report, route, parentReport, policy, session, policyCategories, policyTags, parentReportActions, transaction}) { - const parentReportAction = parentReportActions[report.parentReportActionID]; + const parentReportActionID = lodashGet(report, 'parentReportActionID', '0'); + const parentReportAction = lodashGet(parentReportActions, parentReportActionID); const { amount: transactionAmount, currency: transactionCurrency, @@ -199,6 +206,10 @@ function EditRequestPage({betas, report, route, parentReport, policy, session, p currency: defaultCurrency, }); }} + onNavigateToCurrency={() => { + const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(report.reportID, defaultCurrency, activeRoute)); + }} /> ); } @@ -321,7 +332,8 @@ export default compose( withOnyx({ transaction: { key: ({report, parentReportActions}) => { - const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); + const parentReportActionID = lodashGet(report, 'parentReportActionID', '0'); + const parentReportAction = lodashGet(parentReportActions, parentReportActionID); return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`; }, }, diff --git a/src/pages/EditSplitBillPage.js b/src/pages/EditSplitBillPage.js new file mode 100644 index 000000000000..217b1a100572 --- /dev/null +++ b/src/pages/EditSplitBillPage.js @@ -0,0 +1,161 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; +import CONST from '../CONST'; +import ROUTES from '../ROUTES'; +import ONYXKEYS from '../ONYXKEYS'; +import compose from '../libs/compose'; +import transactionPropTypes from '../components/transactionPropTypes'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as IOU from '../libs/actions/IOU'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; +import Navigation from '../libs/Navigation/Navigation'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import EditRequestDescriptionPage from './EditRequestDescriptionPage'; +import EditRequestMerchantPage from './EditRequestMerchantPage'; +import EditRequestCreatedPage from './EditRequestCreatedPage'; +import EditRequestAmountPage from './EditRequestAmountPage'; + +const propTypes = { + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** The transaction field we are editing */ + field: PropTypes.string, + + /** The chat reportID of the split */ + reportID: PropTypes.string, + + /** reportActionID of the split action */ + reportActionID: PropTypes.string, + }), + }).isRequired, + + /** The current transaction */ + transaction: transactionPropTypes.isRequired, + + /** The draft transaction that holds data to be persisted on the current transaction */ + draftTransaction: PropTypes.shape(transactionPropTypes), +}; + +const defaultProps = { + draftTransaction: {}, +}; + +function EditSplitBillPage({route, transaction, draftTransaction}) { + const fieldToEdit = lodashGet(route, ['params', 'field'], ''); + const reportID = lodashGet(route, ['params', 'reportID'], ''); + const reportActionID = lodashGet(route, ['params', 'reportActionID'], ''); + + const { + amount: transactionAmount, + currency: transactionCurrency, + comment: transactionDescription, + merchant: transactionMerchant, + created: transactionCreated, + } = draftTransaction ? ReportUtils.getTransactionDetails(draftTransaction) : ReportUtils.getTransactionDetails(transaction); + + const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; + + function navigateBackToSplitDetails() { + Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(reportID, reportActionID)); + } + + function setDraftSplitTransaction(transactionChanges) { + IOU.setDraftSplitTransaction(transaction.transactionID, transactionChanges); + navigateBackToSplitDetails(); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { + return ( + { + setDraftSplitTransaction({ + comment: transactionChanges.comment.trim(), + }); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { + return ( + { + setDraftSplitTransaction({ + created: transactionChanges.created, + }); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { + return ( + { + const amount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(transactionChanges)); + + setDraftSplitTransaction({ + amount, + currency: defaultCurrency, + }); + }} + onNavigateToCurrency={() => { + const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL_CURRENCY.getRoute(reportID, reportActionID, defaultCurrency, activeRoute)); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.MERCHANT) { + return ( + { + setDraftSplitTransaction({merchant: transactionChanges.merchant.trim()}); + }} + /> + ); + } + + return ; +} + +EditSplitBillPage.displayName = 'EditSplitBillPage'; +EditSplitBillPage.propTypes = propTypes; +EditSplitBillPage.defaultProps = defaultProps; +export default compose( + withOnyx({ + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, + canEvict: false, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, + draftTransaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, + }), +)(EditSplitBillPage); diff --git a/src/pages/KeyboardShortcutsPage.js b/src/pages/KeyboardShortcutsPage.js new file mode 100644 index 000000000000..8ac26301e9fb --- /dev/null +++ b/src/pages/KeyboardShortcutsPage.js @@ -0,0 +1,61 @@ +import React from 'react'; +import {View, ScrollView} from 'react-native'; +import _ from 'underscore'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import ScreenWrapper from '../components/ScreenWrapper'; +import Text from '../components/Text'; +import styles from '../styles/styles'; +import CONST from '../CONST'; +import useLocalize from '../hooks/useLocalize'; +import KeyboardShortcut from '../libs/KeyboardShortcut'; +import MenuItem from '../components/MenuItem'; + +function KeyboardShortcutsPage() { + const {translate} = useLocalize(); + const shortcuts = _.chain(CONST.KEYBOARD_SHORTCUTS) + .filter((shortcut) => !_.isEmpty(shortcut.descriptionKey)) + .map((shortcut) => { + const platformAdjustedModifiers = KeyboardShortcut.getPlatformEquivalentForKeys(shortcut.modifiers); + return { + displayName: KeyboardShortcut.getDisplayName(shortcut.shortcutKey, platformAdjustedModifiers), + descriptionKey: shortcut.descriptionKey, + }; + }) + .value(); + + /** + * Render the information of a single shortcut + * @param {Object} shortcut + * @param {String} shortcut.displayName + * @param {String} shortcut.descriptionKey + * @returns {React.Component} + */ + const renderShortcut = (shortcut) => ( + + ); + + return ( + + + + + {translate('keyboardShortcutsPage.subtitle')} + {_.map(shortcuts, renderShortcut)} + + + + ); +} + +KeyboardShortcutsPage.displayName = 'KeyboardShortcutsPage'; + +export default KeyboardShortcutsPage; diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index c3b66910face..565f36d69e54 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -198,7 +198,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) onChangeText={setSearchTerm} headerMessage={headerMessage} boldStyle - shouldFocusOnSelectRow={!Browser.isMobile()} + shouldPreventDefaultFocusOnSelectRow={!Browser.isMobile()} shouldShowOptions={isOptionsDataReady} shouldShowConfirmButton confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index 183bd6d16233..1bf99a6f5681 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -1,4 +1,4 @@ -import React, {useState, useRef, useCallback} from 'react'; +import React, {useState, useRef, useCallback, useMemo} from 'react'; import PropTypes from 'prop-types'; import {Keyboard} from 'react-native'; import {withOnyx} from 'react-native-onyx'; @@ -63,7 +63,23 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { // We need to edit the note in markdown format, but display it in HTML format const parser = new ExpensiMark(); - const [privateNote, setPrivateNote] = useState(parser.htmlToMarkdown(lodashGet(report, ['privateNotes', route.params.accountID, 'note'], '')).trim()); + const [privateNote, setPrivateNote] = useState( + Report.getDraftPrivateNote(report.reportID) || parser.htmlToMarkdown(lodashGet(report, ['privateNotes', route.params.accountID, 'note'], '')).trim(), + ); + + /** + * Save the draft of the private note. This debounced so that we're not ceaselessly saving your edit. Saving the draft + * allows one to navigate somewhere else and come back to the private note and still have it in edit mode. + * @param {String} newDraft + */ + const debouncedSavePrivateNote = useMemo( + () => + _.debounce((text) => { + Report.savePrivateNotesDraft(report.reportID, text); + }, 1000), + [report.reportID], + ); + const isCurrentUserNote = Number(session.accountID) === Number(route.params.accountID); // To focus on the input field when the page loads @@ -153,7 +169,10 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { containerStyles={[styles.autoGrowHeightMultilineInput]} defaultValue={privateNote} value={privateNote} - onChangeText={(text) => setPrivateNote(text)} + onChangeText={(text) => { + debouncedSavePrivateNote(text); + setPrivateNote(text); + }} ref={(el) => { if (!el) { return; diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 6bacc8ed3bb4..8e0ed04ab94a 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -37,6 +37,7 @@ import variables from '../styles/variables'; import * as ValidationUtils from '../libs/ValidationUtils'; import Permissions from '../libs/Permissions'; import ROUTES from '../ROUTES'; +import MenuItemWithTopDescription from '../components/MenuItemWithTopDescription'; const matchType = PropTypes.shape({ params: PropTypes.shape({ @@ -57,23 +58,25 @@ const propTypes = { /** Route params */ route: matchType.isRequired, - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - }), - /** Indicates whether the app is loading initial data */ isLoadingReportData: PropTypes.bool, + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user accountID */ + accountID: PropTypes.number, + }), + ...withLocalizePropTypes, }; const defaultProps = { // When opening someone else's profile (via deep link) before login, this is empty personalDetails: {}, - loginList: {}, isLoadingReportData: true, + session: { + accountID: 0, + }, }; /** @@ -121,8 +124,8 @@ function ProfilePage(props) { const phoneNumber = getPhoneNumber(details); const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : login; - const isCurrentUser = _.keys(props.loginList).includes(login); - const hasMinimumDetails = !_.isEmpty(details.avatar) && !_.isUndefined(details.displayName); + const isCurrentUser = props.session.accountID === accountID; + const hasMinimumDetails = !_.isEmpty(details.avatar); const isLoading = lodashGet(details, 'isLoading', false) || _.isEmpty(details) || props.isLoadingReportData; // If the API returns an error for some reason there won't be any details and isLoading will get set to false, so we want to show a blocking screen @@ -135,7 +138,7 @@ function ProfilePage(props) { const navigateBackTo = lodashGet(props.route, 'params.backTo', ROUTES.HOME); - const chatReportWithCurrentUser = !isCurrentUser && !Session.isAnonymousUser() ? ReportUtils.getChatByParticipants([accountID]) : 0; + const notificationPreference = !_.isEmpty(props.report) ? props.translate(`notificationPreferencesPage.notificationPreferences.${props.report.notificationPreference}`) : ''; // eslint-disable-next-line rulesdir/prefer-early-return useEffect(() => { @@ -231,6 +234,15 @@ function ProfilePage(props) { ) : null} {shouldShowLocalTime && }
+ {!_.isEmpty(props.report) && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && ( + Navigation.navigate(ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES.getRoute(props.report.reportID))} + wrapperStyle={[styles.mtn6, styles.mb5]} + /> + )} {!isCurrentUser && !Session.isAnonymousUser() && ( )} - {!_.isEmpty(chatReportWithCurrentUser) && ( + {!_.isEmpty(props.report) && ( Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(chatReportWithCurrentUser.reportID))} + onPress={() => Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(props.report.reportID))} wrapperStyle={styles.breakAll} shouldShowRightIcon - brickRoadIndicator={Report.hasErrorInPrivateNotes(chatReportWithCurrentUser) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={Report.hasErrorInPrivateNotes(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} /> )} @@ -280,14 +292,27 @@ export default compose( personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - loginList: { - key: ONYXKEYS.LOGIN_LIST, - }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, betas: { key: ONYXKEYS.BETAS, }, + session: { + key: ONYXKEYS.SESSION, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + report: { + key: ({route, session}) => { + const accountID = Number(lodashGet(route.params, 'accountID', 0)); + const reportID = lodashGet(ReportUtils.getChatByParticipants([accountID]), 'reportID', ''); + if (Number(session.accountID) === accountID || Session.isAnonymousUser() || !reportID) { + return null; + } + return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + }, + }, }), )(ProfilePage); diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js index 851c4a4b2496..a63916db0784 100644 --- a/src/pages/ReimbursementAccount/ValidationStep.js +++ b/src/pages/ReimbursementAccount/ValidationStep.js @@ -51,23 +51,38 @@ const defaultProps = { }, }; -class ValidationStep extends React.Component { - constructor(props) { - super(props); +/** + * Filter input for validation amount + * Anything that isn't a number is returned as an empty string + * Any dollar amount (e.g. 1.12) will be returned as 112 + * + * @param {String} amount field input + * @returns {String} + */ +const filterInput = (amount) => { + let value = amount ? amount.toString().trim() : ''; + if (value === '' || _.isNaN(Number(value)) || !Math.abs(Str.fromUSDToNumber(value))) { + return ''; + } - this.submit = this.submit.bind(this); - this.validate = this.validate.bind(this); + // If the user enters the values in dollars, convert it to the respective cents amount + if (_.contains(value, '.')) { + value = Str.fromUSDToNumber(value); } + return value; +}; + +function ValidationStep({reimbursementAccount, translate, onBackButtonPress, account}) { /** * @param {Object} values - form input values passed by the Form component * @returns {Object} */ - validate(values) { + const validate = (values) => { const errors = {}; _.each(values, (value, key) => { - const filteredValue = typeof value === 'string' ? this.filterInput(value) : value; + const filteredValue = typeof value === 'string' ? filterInput(value) : value; if (ValidationUtils.isRequiredFulfilled(filteredValue)) { return; } @@ -75,160 +90,136 @@ class ValidationStep extends React.Component { }); return errors; - } + }; /** * @param {Object} values - form input values passed by the Form component */ - submit(values) { - const amount1 = this.filterInput(values.amount1); - const amount2 = this.filterInput(values.amount2); - const amount3 = this.filterInput(values.amount3); + const submit = (values) => { + const amount1 = filterInput(values.amount1); + const amount2 = filterInput(values.amount2); + const amount3 = filterInput(values.amount3); const validateCode = [amount1, amount2, amount3].join(','); // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify - const bankaccountID = lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID'); + const bankaccountID = lodashGet(reimbursementAccount, 'achData.bankAccountID'); BankAccounts.validateBankAccount(bankaccountID, validateCode); - } + }; - /** - * Filter input for validation amount - * Anything that isn't a number is returned as an empty string - * Any dollar amount (e.g. 1.12) will be returned as 112 - * - * @param {String} amount field input - * - * @returns {String} - */ - filterInput(amount) { - let value = amount ? amount.toString().trim() : ''; - if (value === '' || _.isNaN(Number(value)) || !Math.abs(Str.fromUSDToNumber(value))) { - return ''; - } - - // If the user enters the values in dollars, convert it to the respective cents amount - if (_.contains(value, '.')) { - value = Str.fromUSDToNumber(value); - } - - return value; + const state = lodashGet(reimbursementAccount, 'achData.state'); + + // If a user tries to navigate directly to the validate page we'll show them the EnableStep + if (state === BankAccount.STATE.OPEN) { + return ; } - render() { - const state = lodashGet(this.props.reimbursementAccount, 'achData.state'); - - // If a user tries to navigate directly to the validate page we'll show them the EnableStep - if (state === BankAccount.STATE.OPEN) { - return ; - } - - const maxAttemptsReached = lodashGet(this.props.reimbursementAccount, 'maxAttemptsReached'); - const isVerifying = !maxAttemptsReached && state === BankAccount.STATE.VERIFYING; - const requiresTwoFactorAuth = lodashGet(this.props, 'account.requiresTwoFactorAuth'); - - return ( - - - {maxAttemptsReached && ( - - - {this.props.translate('validationStep.maxAttemptsReached')} {this.props.translate('common.please')}{' '} - {this.props.translate('common.contactUs')}. - + const maxAttemptsReached = lodashGet(reimbursementAccount, 'maxAttemptsReached'); + const isVerifying = !maxAttemptsReached && state === BankAccount.STATE.VERIFYING; + const requiresTwoFactorAuth = lodashGet(account, 'requiresTwoFactorAuth'); + + return ( + + + {maxAttemptsReached && ( + + + {translate('validationStep.maxAttemptsReached')} {translate('common.please')}{' '} + {translate('common.contactUs')}. + + + )} + {!maxAttemptsReached && state === BankAccount.STATE.PENDING && ( +
+ + {translate('validationStep.description')} + {translate('validationStep.descriptionCTA')} - )} - {!maxAttemptsReached && state === BankAccount.STATE.PENDING && ( -
- - {this.props.translate('validationStep.description')} - {this.props.translate('validationStep.descriptionCTA')} - - - - - + + + + + + {!requiresTwoFactorAuth && ( + + - {!requiresTwoFactorAuth && ( - - - - )} -
- )} - {isVerifying && ( - -
- {this.props.translate('validationStep.letsChatText')} - - -
- {this.props.reimbursementAccount.shouldShowResetModal && } - {!requiresTwoFactorAuth && } -
- )} -
- ); - } + )} + + )} + {isVerifying && ( + +
+ {translate('validationStep.letsChatText')} + + +
+ {reimbursementAccount.shouldShowResetModal && } + {!requiresTwoFactorAuth && } +
+ )} +
+ ); } ValidationStep.propTypes = propTypes; ValidationStep.defaultProps = defaultProps; +ValidationStep.displayName = 'ValidationStep'; export default compose( withLocalize, diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index e4ce09fc7e1a..42a535844c72 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -61,8 +61,7 @@ const defaultProps = { function ReportDetailsPage(props) { const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]); const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); - const shouldDisableSettings = useMemo(() => ReportUtils.shouldDisableSettings(props.report), [props.report]); - const shouldUseFullTitle = !shouldDisableSettings || ReportUtils.isTaskReport(props.report); + const shouldUseFullTitle = ReportUtils.isTaskReport(props.report); const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]); const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]); const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]); @@ -75,16 +74,20 @@ function ReportDetailsPage(props) { const canLeaveRoom = useMemo(() => ReportUtils.canLeaveRoom(props.report, !_.isEmpty(policy)), [policy, props.report]); const participants = useMemo(() => ReportUtils.getParticipantsIDs(props.report), [props.report]); + const isGroupDMChat = useMemo(() => ReportUtils.isDM(props.report) && participants.length > 1, [props.report, participants.length]); + const menuItems = useMemo(() => { - const items = [ - { + const items = []; + + if (!isGroupDMChat) { + items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.SHARE_CODE, translationKey: 'common.shareCode', icon: Expensicons.QrCode, isAnonymousAction: true, action: () => Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.getRoute(props.report.reportID)), - }, - ]; + }); + } if (isArchivedRoom) { return items; @@ -103,17 +106,15 @@ function ReportDetailsPage(props) { }); } - if (!shouldDisableSettings) { - items.push({ - key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, - translationKey: 'common.settings', - icon: Expensicons.Gear, - isAnonymousAction: false, - action: () => { - Navigation.navigate(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID)); - }, - }); - } + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, + translationKey: 'common.settings', + icon: Expensicons.Gear, + isAnonymousAction: false, + action: () => { + Navigation.navigate(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID)); + }, + }); // Prevent displaying private notes option for threads and task reports if (!isThread && !isMoneyRequestReport && !ReportUtils.isTaskReport(props.report)) { @@ -138,7 +139,7 @@ function ReportDetailsPage(props) { } return items; - }, [isArchivedRoom, participants.length, shouldDisableSettings, isThread, isMoneyRequestReport, props.report, isUserCreatedPolicyRoom, canLeaveRoom]); + }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, props.report, isUserCreatedPolicyRoom, canLeaveRoom, isGroupDMChat]); const displayNamesWithTooltips = useMemo(() => { const hasMultipleParticipants = participants.length > 1; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 78f6edcf7dd3..6dba940f0ecb 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -438,6 +438,7 @@ function ReportScreen({ isComposerFullSize={isComposerFullSize} onSubmitComment={onSubmitComment} policies={policies} + personalDetails={personalDetails} /> ) : ( diff --git a/src/pages/home/report/AnimatedEmptyStateBackground.js b/src/pages/home/report/AnimatedEmptyStateBackground.js index ecc37a2b785f..67d9a9584b39 100644 --- a/src/pages/home/report/AnimatedEmptyStateBackground.js +++ b/src/pages/home/report/AnimatedEmptyStateBackground.js @@ -4,6 +4,8 @@ import useWindowDimensions from '../../../hooks/useWindowDimensions'; import * as NumberUtils from '../../../libs/NumberUtils'; import EmptyStateBackgroundImage from '../../../../assets/images/empty-state_background-fade.png'; import * as StyleUtils from '../../../styles/StyleUtils'; +import variables from '../../../styles/variables'; +import CONST from '../../../CONST'; const IMAGE_OFFSET_Y = 75; @@ -11,6 +13,9 @@ function AnimatedEmptyStateBackground() { const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const IMAGE_OFFSET_X = windowWidth / 2; + // If window width is greater than the max background width, repeat the background image + const maxBackgroundWidth = variables.sideBarWidth + CONST.EMPTY_STATE_BACKGROUND.ASPECT_RATIO * CONST.EMPTY_STATE_BACKGROUND.WIDE_SCREEN.IMAGE_HEIGHT; + // Get data from phone rotation sensor and prep other variables for animation const animatedSensor = useAnimatedSensor(SensorType.GYROSCOPE); const xOffset = useSharedValue(0); @@ -32,13 +37,14 @@ function AnimatedEmptyStateBackground() { return { transform: [{translateX: withSpring(-IMAGE_OFFSET_X - xOffset.value)}, {translateY: withSpring(yOffset.value)}], }; - }); + }, []); return ( maxBackgroundWidth ? 'repeat' : 'cover'} /> ); } diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 58017705c5f1..e987eff4c7e8 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -316,7 +316,7 @@ function PopoverReportActionContextMenu(_props, ref) { /> ({ + return _.map(ReportUtils.getMoneyRequestOptions(report, reportParticipantIDs), (option) => ({ ...options[option], onSelected: () => IOU.startMoneyRequest(option, report.reportID), })); - }, [betas, report, reportParticipantIDs, translate]); + }, [report, reportParticipantIDs, translate]); /** * Determines if we can show the task option diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index a4777556dda7..faa710d2cd6b 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -102,8 +102,14 @@ function ComposerWithSuggestions({ const {preferredLocale} = useLocalize(); const isFocused = useIsFocused(); const navigation = useNavigation(); - - const [value, setValue] = useState(() => getDraftComment(reportID) || ''); + const emojisPresentBefore = useRef([]); + const [value, setValue] = useState(() => { + const draft = getDraftComment(reportID) || ''; + if (draft) { + emojisPresentBefore.current = EmojiUtils.extractEmojis(draft); + } + return draft; + }); const commentRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); @@ -154,14 +160,6 @@ function ComposerWithSuggestions({ debouncedLowerIsScrollLikelyLayoutTriggered(); }, [debouncedLowerIsScrollLikelyLayoutTriggered]); - const onInsertedEmoji = useCallback( - (emojiObject) => { - insertedEmojisRef.current = [...insertedEmojisRef.current, emojiObject]; - debouncedUpdateFrequentlyUsedEmojis(emojiObject); - }, - [debouncedUpdateFrequentlyUsedEmojis], - ); - /** * Set the TextInput Ref * @@ -206,10 +204,13 @@ function ComposerWithSuggestions({ const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { - insertedEmojisRef.current = [...insertedEmojisRef.current, ...emojis]; - debouncedUpdateFrequentlyUsedEmojis(); + const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); + if (!_.isEmpty(newEmojis)) { + insertedEmojisRef.current = [...insertedEmojisRef.current, ...newEmojis]; + debouncedUpdateFrequentlyUsedEmojis(); + } } - + emojisPresentBefore.current = emojis; setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); setValue(newComment); if (commentValue !== newComment) { @@ -550,7 +551,6 @@ function ComposerWithSuggestions({ isComposerFullSize={isComposerFullSize} updateComment={updateComment} composerHeight={composerHeight} - onInsertedEmoji={onInsertedEmoji} measureParentContainer={measureParentContainer} // Input value={value} diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 910a338c83b6..8ffed01f1068 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -39,9 +39,6 @@ const propTypes = { /** Function to clear the input */ resetKeyboardInput: PropTypes.func.isRequired, - /** Callback when a emoji was inserted */ - onInsertedEmoji: PropTypes.func.isRequired, - ...SuggestionProps.baseProps, }; @@ -61,7 +58,6 @@ function SuggestionEmoji({ isAutoSuggestionPickerLarge, forwardedRef, resetKeyboardInput, - onInsertedEmoji, measureParentContainer, }) { const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -102,10 +98,8 @@ function SuggestionEmoji({ end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, }); setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - - onInsertedEmoji(emojiObject); }, - [onInsertedEmoji, preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + [preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], ); /** diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index a00bd342b17d..0e98e69d31d1 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -9,9 +9,6 @@ const propTypes = { /** A ref to this component */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - /** Callback when a emoji was inserted */ - onInsertedEmoji: PropTypes.func.isRequired, - /** Function to clear the input */ resetKeyboardInput: PropTypes.func.isRequired, @@ -28,19 +25,7 @@ const defaultProps = { * * @returns {React.Component} */ -function Suggestions({ - isComposerFullSize, - value, - setValue, - selection, - setSelection, - updateComment, - composerHeight, - forwardedRef, - onInsertedEmoji, - resetKeyboardInput, - measureParentContainer, -}) { +function Suggestions({isComposerFullSize, value, setValue, selection, setSelection, updateComment, composerHeight, forwardedRef, resetKeyboardInput, measureParentContainer}) { const suggestionEmojiRef = useRef(null); const suggestionMentionRef = useRef(null); @@ -114,7 +99,6 @@ function Suggestions({ ref={suggestionEmojiRef} // eslint-disable-next-line react/jsx-props-no-spreading {...baseProps} - onInsertedEmoji={onInsertedEmoji} resetKeyboardInput={resetKeyboardInput} /> getInitialDraft()); + const emojisPresentBefore = useRef([]); + const [draft, setDraft] = useState(() => { + const initialDraft = getInitialDraft(); + if (initialDraft) { + emojisPresentBefore.current = EmojiUtils.extractEmojis(initialDraft); + } + return initialDraft; + }); const [selection, setSelection] = useState(getInitialSelection()); const [isFocused, setIsFocused] = useState(false); const [hasExceededMaxCommentLength, setHasExceededMaxCommentLength] = useState(false); @@ -200,9 +204,13 @@ function ReportActionItemMessageEdit(props) { const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { - insertedEmojis.current = [...insertedEmojis.current, ...emojis]; - debouncedUpdateFrequentlyUsedEmojis(); + const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); + if (!_.isEmpty(newEmojis)) { + insertedEmojis.current = [...insertedEmojis.current, ...newEmojis]; + debouncedUpdateFrequentlyUsedEmojis(); + } } + emojisPresentBefore.current = emojis; setDraft((prevDraft) => { if (newDraftInput !== newDraft) { const remainder = ComposerUtils.getCommonSuffixLength(prevDraft, newDraft); @@ -313,30 +321,6 @@ function ReportActionItemMessageEdit(props) { return ( <> - - - e.preventDefault()} - > - {({hovered, pressed}) => ( - - )} - - - - + + + e.preventDefault()} + > + + + + + { diff --git a/src/pages/home/report/ReportActionItemParentAction.js b/src/pages/home/report/ReportActionItemParentAction.js index 8e1fb6aafdd4..b67707031372 100644 --- a/src/pages/home/report/ReportActionItemParentAction.js +++ b/src/pages/home/report/ReportActionItemParentAction.js @@ -1,5 +1,5 @@ import React from 'react'; -import {View, Image} from 'react-native'; +import {View} from 'react-native'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -15,7 +15,7 @@ import withLocalize from '../../../components/withLocalize'; import ReportActionItem from './ReportActionItem'; import reportActionPropTypes from './reportActionPropTypes'; import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; -import EmptyStateBackgroundImage from '../../../../assets/images/empty-state_background-fade.png'; +import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; const propTypes = { /** Flag to show, hide the thread divider line */ @@ -61,11 +61,7 @@ function ReportActionItemParentAction(props) { onClose={() => Report.navigateToConciergeChatAndDeleteReport(props.report.reportID)} > - + {parentReportAction && ( { - let shouldDisplay = false; + const renderItem = useCallback( + ({item: reportAction, index}) => { + let shouldDisplayNewMarker = false; if (!currentUnreadMarker) { const nextMessage = sortedReportActions[index + 1]; const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime); - shouldDisplay = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime); + shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime); + if (!messageManuallyMarkedUnread) { - shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID(); + shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID(); + } + const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true; + + if (!currentUnreadMarker && shouldDisplayNewMarker && canDisplayMarker) { + setCurrentUnreadMarker(reportAction.reportActionID); } } else { - shouldDisplay = reportAction.reportActionID === currentUnreadMarker; + shouldDisplayNewMarker = reportAction.reportActionID === currentUnreadMarker; } - return shouldDisplay; + return ( + + ); }, - [currentUnreadMarker, sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread], - ); - - useEffect(() => { - // Iterate through the report actions and set appropriate unread marker. - // This is to avoid a warning of: - // Cannot update a component (ReportActionsList) while rendering a different component (CellRenderer). - _.each(sortedReportActions, (reportAction, index) => { - if (!shouldDisplayNewMarker(reportAction, index)) { - return; - } - setCurrentUnreadMarker(reportAction.reportActionID); - }); - }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker]); - - const renderItem = useCallback( - ({item: reportAction, index}) => ( - - ), - [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker], + [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker], ); // Native mobile does not render updates flatlist the changes even though component did update called. diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 04e444a73c6b..0711cd181964 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -18,6 +18,7 @@ import reportActionPropTypes from './reportActionPropTypes'; import reportPropTypes from '../../reportPropTypes'; import * as ReportUtils from '../../../libs/ReportUtils'; import * as Session from '../../../libs/actions/Session'; +import participantPropTypes from '../../../components/participantPropTypes'; const propTypes = { /** Report object for the current report */ @@ -32,6 +33,9 @@ const propTypes = { /** The pending action when we are adding a chat */ pendingAction: PropTypes.string, + /** Personal details of all the users */ + personalDetails: PropTypes.objectOf(participantPropTypes), + /** Whether the composer input should be shown */ shouldShowComposeInput: PropTypes.bool, @@ -49,6 +53,7 @@ const defaultProps = { reportActions: [], onSubmitComment: () => {}, pendingAction: null, + personalDetails: {}, shouldShowComposeInput: true, shouldDisableCompose: false, isReportReadyForDisplay: true, @@ -71,6 +76,7 @@ function ReportFooter(props) { )} {isArchivedRoom && } diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 0aa7423afacd..9dbdde14c50d 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -60,7 +60,7 @@ const defaultProps = { isLoadingReportData: true, priorityMode: CONST.PRIORITY_MODE.DEFAULT, betas: [], - policies: [], + policies: {}, }; function SidebarLinksData({isFocused, allReportActions, betas, chatReports, currentReportID, insets, isLoadingReportData, onLinkClick, policies, priorityMode}) { diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index e9ede2c9a89a..80fd1d39239d 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -154,7 +154,7 @@ function FloatingActionButtonAndPopover(props) { } Welcome.show({routes, showCreateMenu}); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [props.isLoading]); useEffect(() => { if (!didScreenBecomeInactive()) { @@ -185,20 +185,16 @@ function FloatingActionButtonAndPopover(props) { text: props.translate('sidebarScreen.fabNewChat'), onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.NEW)), }, - ...(Permissions.canUseIOUSend(props.betas) - ? [ - { - icon: Expensicons.Send, - text: props.translate('iou.sendMoney'), - onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.MONEY_REQUEST_TYPE.SEND)), - }, - ] - : []), { icon: Expensicons.MoneyCircle, text: props.translate('iou.requestMoney'), onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)), }, + { + icon: Expensicons.Send, + text: props.translate('iou.sendMoney'), + onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.MONEY_REQUEST_TYPE.SEND)), + }, ...(Permissions.canUseTasks(props.betas) ? [ { @@ -208,6 +204,11 @@ function FloatingActionButtonAndPopover(props) { }, ] : []), + { + icon: Expensicons.Heart, + text: props.translate('sidebarScreen.saveTheWorld'), + onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.TEACHERS_UNITE)), + }, ...(!props.isLoading && !Policy.hasActiveFreePolicy(props.allPolicies) ? [ { diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js index 65654aa8098a..fdf7b3a0fb30 100644 --- a/src/pages/iou/MoneyRequestDatePage.js +++ b/src/pages/iou/MoneyRequestDatePage.js @@ -5,7 +5,6 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderWithBackButton from '../../components/HeaderWithBackButton'; -import Form from '../../components/Form'; import ONYXKEYS from '../../ONYXKEYS'; import styles from '../../styles/styles'; import Navigation from '../../libs/Navigation/Navigation'; @@ -16,6 +15,7 @@ import NewDatePicker from '../../components/NewDatePicker'; import useLocalize from '../../hooks/useLocalize'; import CONST from '../../CONST'; import {iouPropTypes, iouDefaultProps} from './propTypes'; +import FormProvider from '../../components/Form/FormProvider'; const propTypes = { /** Onyx Props */ @@ -91,7 +91,7 @@ function MoneyRequestDatePage({iou, route, selectedTab}) { title={translate('common.date')} onBackButtonPress={() => navigateBack()} /> -
updateDate(value)} @@ -104,7 +104,7 @@ function MoneyRequestDatePage({iou, route, selectedTab}) { defaultValue={iou.created} maxDate={new Date()} /> -
+ ); } diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index c8e3aebaa0b3..d006e3480a4e 100644 --- a/src/pages/iou/MoneyRequestSelectorPage.js +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -99,7 +99,7 @@ function MoneyRequestSelectorPage(props) { title={title[iouType]} onBackButtonPress={Navigation.dismissModal} /> - {iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST ? ( + {iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST || iouType === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT ? ( { + if (props.onUserMedia) { + props.onUserMedia(stream); + } + + const [track] = stream.getVideoTracks(); + const capabilities = track.getCapabilities(); + if (capabilities.torch) { + trackRef.current = track; + } + if (onTorchAvailability) { + onTorchAvailability(!!capabilities.torch); + } + }; + useEffect(() => { - const removeBlurListener = navigation.addListener('blur', () => { - setIsCameraActive(false); - }); - const removeFocusListener = navigation.addListener('focus', () => { - setIsCameraActive(true); - }); + if (!trackRef.current) { + return; + } - return () => { - removeBlurListener(); - removeFocusListener(); - }; - }, [navigation]); + trackRef.current.applyConstraints({ + advanced: [{torch: torchOn}], + }); + }, [torchOn]); + if (!isCameraActive) { + return null; + } return ( - + + + ); } NavigationAwareCamera.propTypes = propTypes; NavigationAwareCamera.displayName = 'NavigationAwareCamera'; +NavigationAwareCamera.defaultProps = defaultProps; -export default React.forwardRef((props, ref) => ( - -)); +export default React.forwardRef(NavigationAwareCamera); diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js new file mode 100644 index 000000000000..8fa153550cbe --- /dev/null +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js @@ -0,0 +1,76 @@ +import React, {useEffect, useState} from 'react'; +import {Camera} from 'react-native-vision-camera'; +import {useTabAnimation} from '@react-navigation/material-top-tabs'; +import {useNavigation} from '@react-navigation/native'; +import PropTypes from 'prop-types'; +import refPropTypes from '../../../components/refPropTypes'; + +const propTypes = { + /* The index of the tab that contains this camera */ + cameraTabIndex: PropTypes.number.isRequired, + + /* Forwarded ref */ + forwardedRef: refPropTypes.isRequired, +}; + +// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. +function NavigationAwareCamera({cameraTabIndex, forwardedRef, ...props}) { + // Get navigation to get initial isFocused value (only needed once during init!) + const navigation = useNavigation(); + const [isCameraActive, setIsCameraActive] = useState(navigation.isFocused()); + + // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed. + // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness. + const tabPositionAnimation = useTabAnimation(); + + useEffect(() => { + const listenerId = tabPositionAnimation.addListener(({value}) => { + // Activate camera as soon the index is animating towards the `cameraTabIndex` + setIsCameraActive(value > cameraTabIndex - 1 && value < cameraTabIndex + 1); + }); + + return () => { + tabPositionAnimation.removeListener(listenerId); + }; + }, [cameraTabIndex, tabPositionAnimation]); + + // Note: The useEffect can be removed once VisionCamera V3 is used. + // Its only needed for android, because there is a native cameraX android bug. With out this flow would break the camera: + // 1. Open camera tab + // 2. Take a picture + // 3. Go back from the opened screen + // 4. The camera is not working anymore + useEffect(() => { + const removeBlurListener = navigation.addListener('blur', () => { + setIsCameraActive(false); + }); + const removeFocusListener = navigation.addListener('focus', () => { + setIsCameraActive(true); + }); + + return () => { + removeBlurListener(); + removeFocusListener(); + }; + }, [navigation]); + + return ( + + ); +} + +NavigationAwareCamera.propTypes = propTypes; +NavigationAwareCamera.displayName = 'NavigationAwareCamera'; + +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index b4cf75801a3f..fba792029914 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -1,5 +1,5 @@ -import {View, Text, PanResponder, PixelRatio} from 'react-native'; -import React, {useContext, useRef, useState} from 'react'; +import {View, Text, PixelRatio, ActivityIndicator, PanResponder} from 'react-native'; +import React, {useCallback, useContext, useReducer, useRef, useState} from 'react'; import lodashGet from 'lodash/get'; import _ from 'underscore'; import PropTypes from 'prop-types'; @@ -21,6 +21,14 @@ import {DragAndDropContext} from '../../../components/DragAndDrop/Provider'; import {iouPropTypes, iouDefaultProps} from '../propTypes'; import * as FileUtils from '../../../libs/fileDownload/FileUtils'; import Navigation from '../../../libs/Navigation/Navigation'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import Icon from '../../../components/Icon'; +import themeColors from '../../../styles/themes/default'; +import Shutter from '../../../../assets/images/shutter.svg'; +import NavigationAwareCamera from './NavigationAwareCamera'; +import * as Browser from '../../../libs/Browser'; +import Hand from '../../../../assets/images/hand.svg'; +import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; const propTypes = { /** The report on which the request is initiated on */ @@ -59,16 +67,24 @@ const defaultProps = { isInTabNavigator: true, }; -function ReceiptSelector(props) { - const iouType = lodashGet(props.route, 'params.iouType', ''); +function ReceiptSelector({route, transactionID, iou, report}) { + const iouType = lodashGet(route, 'params.iouType', ''); + + // Grouping related states const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); const [attachmentInvalidReason, setAttachmentValidReason] = useState(''); + const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0); const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); const {isDraggingOver} = useContext(DragAndDropContext); + const [cameraPermissionState, setCameraPermissionState] = useState('prompt'); + const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false); + const [isTorchAvailable, setIsTorchAvailable] = useState(true); + const cameraRef = useRef(null); + const hideReciptModal = () => { setIsAttachmentInvalid(false); }; @@ -108,10 +124,10 @@ function ReceiptSelector(props) { /** * Sets the Receipt objects and navigates the user to the next page * @param {Object} file - * @param {Object} iou - * @param {Object} report + * @param {Object} iouObject + * @param {Object} reportObject */ - const setReceiptAndNavigate = (file, iou, report) => { + const setReceiptAndNavigate = (file, iouObject, reportObject) => { if (!validateReceipt(file)) { return; } @@ -119,15 +135,34 @@ function ReceiptSelector(props) { const filePath = URL.createObjectURL(file); IOU.setMoneyRequestReceipt(filePath, file.name); - if (props.transactionID) { - IOU.replaceReceipt(props.transactionID, file, filePath); + if (transactionID) { + IOU.replaceReceipt(transactionID, file, filePath); Navigation.dismissModal(); return; } - IOU.navigateToNextPage(iou, iouType, report, props.route.path); + IOU.navigateToNextPage(iouObject, iouType, reportObject, route.path); }; + const capturePhoto = useCallback(() => { + if (!cameraRef.current.getScreenshot) { + return; + } + const imageBase64 = cameraRef.current.getScreenshot(); + const filename = `receipt_${Date.now()}.png`; + const imageFile = FileUtils.base64ToFile(imageBase64, filename); + const filePath = URL.createObjectURL(imageFile); + IOU.setMoneyRequestReceipt(filePath, imageFile.name); + + if (transactionID) { + IOU.replaceReceipt(transactionID, imageFile, filePath); + Navigation.dismissModal(); + return; + } + + IOU.navigateToNextPage(iou, iouType, report, route.path); + }, [cameraRef, iou, report, iouType, transactionID, route.path]); + const panResponder = useRef( PanResponder.create({ onMoveShouldSetPanResponder: () => true, @@ -135,59 +170,146 @@ function ReceiptSelector(props) { }), ).current; - return ( - - {!isDraggingOver ? ( - <> - { - setReceiptImageTopPosition(PixelRatio.roundToNearestPixel(nativeEvent.layout.top)); - }} - > - ( + <> + + {(cameraPermissionState === 'prompt' || !cameraPermissionState) && ( + + )} + {cameraPermissionState === 'denied' && ( + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} - - {translate('receipt.upload')} - - {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} - - {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} - - - - {({openPicker}) => ( - + + + + {({openPicker}) => ( + { + openPicker({ + onPicked: (file) => { + setReceiptAndNavigate(file, iou, report); + }, + }); + }} + > + - )} - - - ) : null} + + )} + + + + + + + + + + ); + + const desktopUploadView = () => ( + <> + setReceiptImageTopPosition(PixelRatio.roundToNearestPixel(nativeEvent.layout.top))}> + + + + + {translate('receipt.upload')} + + {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} + + {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} + + + + + {({openPicker}) => ( + + + ); + + return ( + + {!isDraggingOver && (Browser.isMobile() ? mobileCameraView() : desktopUploadView())} { const file = lodashGet(e, ['dataTransfer', 'files', 0]); - setReceiptAndNavigate(file, props.iou, props.report); + setReceiptAndNavigate(file, iou, report); }} receiptImageTopPosition={receiptImageTopPosition} /> diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index 4887d0816c81..f2654a9faefb 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -1,14 +1,14 @@ -import {ActivityIndicator, Alert, AppState, Linking, Text, View} from 'react-native'; +import {ActivityIndicator, Alert, AppState, Text, View} from 'react-native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {useCameraDevices} from 'react-native-vision-camera'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import {launchImageLibrary} from 'react-native-image-picker'; import {withOnyx} from 'react-native-onyx'; import {RESULTS} from 'react-native-permissions'; import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; import Icon from '../../../components/Icon'; import * as Expensicons from '../../../components/Icon/Expensicons'; +import AttachmentPicker from '../../../components/AttachmentPicker'; import styles from '../../../styles/styles'; import Shutter from '../../../../assets/images/shutter.svg'; import Hand from '../../../../assets/images/hand.svg'; @@ -63,40 +63,13 @@ const defaultProps = { isInTabNavigator: true, }; -/** - * See https://github.com/react-native-image-picker/react-native-image-picker/#options - * for ImagePicker configuration options - */ -const imagePickerOptions = { - includeBase64: false, - saveToPhotos: false, - selectionLimit: 1, - includeExtra: false, -}; - -/** - * Return imagePickerOptions based on the type - * @param {String} type - * @returns {Object} - */ -function getImagePickerOptions(type) { - // mediaType property is one of the ImagePicker configuration to restrict types' - const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed'; - return { - mediaType, - ...imagePickerOptions, - }; -} - function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) { const devices = useCameraDevices('wide-angle-camera'); const device = devices.back; const camera = useRef(null); const [flash, setFlash] = useState(false); - const [permissions, setPermissions] = useState('granted'); - const isAndroidBlockedPermissionRef = useRef(false); - const appState = useRef(AppState.currentState); + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(undefined); const iouType = lodashGet(route, 'params.iouType', ''); const pageIndex = lodashGet(route, 'params.pageIndex', 1); @@ -105,16 +78,23 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) const CameraComponent = isInTabNavigator ? TabNavigationAwareCamera : NavigationAwareCamera; - // We want to listen to if the app has come back from background and refresh the permissions status to show camera when permissions were granted useEffect(() => { - const subscription = AppState.addEventListener('change', (nextAppState) => { - if (appState.current.match(/inactive|background/) && nextAppState === 'active') { - CameraPermission.getCameraPermissionStatus().then((permissionStatus) => { - setPermissions(permissionStatus); - }); + const refreshCameraPermissionStatus = () => { + CameraPermission.getCameraPermissionStatus() + .then(setCameraPermissionStatus) + .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); + }; + + // Check initial camera permission status + refreshCameraPermissionStatus(); + + // Refresh permission status when app gain focus + const subscription = AppState.addEventListener('change', (appState) => { + if (appState !== 'active') { + return; } - appState.current = nextAppState; + refreshCameraPermissionStatus(); }); return () => { @@ -122,77 +102,21 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) }; }, []); - /** - * Inform the users when they need to grant camera access and guide them to settings - */ - const showPermissionsAlert = () => { - Alert.alert( - translate('attachmentPicker.cameraPermissionRequired'), - translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'), - [ - { - text: translate('common.cancel'), - style: 'cancel', - }, - { - text: translate('common.settings'), - onPress: () => Linking.openSettings(), - }, - ], - {cancelable: false}, - ); - }; - - /** - * A generic handling when we don't know the exact reason for an error - * - */ - const showGeneralAlert = () => { - Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingAttachment')); - }; - const askForPermissions = () => { // There's no way we can check for the BLOCKED status without requesting the permission first // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 - if (permissions === RESULTS.BLOCKED || isAndroidBlockedPermissionRef.current) { - Linking.openSettings(); - } else if (permissions === RESULTS.DENIED) { - CameraPermission.requestCameraPermission().then((permissionStatus) => { - setPermissions(permissionStatus); - isAndroidBlockedPermissionRef.current = permissionStatus === RESULTS.BLOCKED; - }); - } - }; + CameraPermission.requestCameraPermission() + .then((status) => { + setCameraPermissionStatus(status); - /** - * Common image picker handling - * - * @param {function} imagePickerFunc - RNImagePicker.launchCamera or RNImagePicker.launchImageLibrary - * @returns {Promise} - */ - const showImagePicker = (imagePickerFunc) => - new Promise((resolve, reject) => { - imagePickerFunc(getImagePickerOptions(CONST.ATTACHMENT_PICKER_TYPE.IMAGE), (response) => { - if (response.didCancel) { - // When the user cancelled resolve with no attachment - return resolve(); - } - if (response.errorCode) { - switch (response.errorCode) { - case 'permission': - showPermissionsAlert(); - return resolve(); - default: - showGeneralAlert(); - break; - } - - return reject(new Error(`Error during attachment selection: ${response.errorMessage}`)); + if (status === RESULTS.BLOCKED) { + FileUtils.showCameraPermissionsAlert(); } - - return resolve(response.assets); + }) + .catch(() => { + setCameraPermissionStatus(RESULTS.UNAVAILABLE); }); - }); + }; const takePhoto = useCallback(() => { const showCameraAlert = () => { @@ -230,13 +154,14 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) }); }, [flash, iouType, iou, report, translate, transactionID, route.path]); - CameraPermission.getCameraPermissionStatus().then((permissionStatus) => { - setPermissions(permissionStatus); - }); + // Wait for camera permission status to render + if (cameraPermissionStatus == null) { + return null; + } return ( - {permissions !== RESULTS.GRANTED && ( + {cameraPermissionStatus !== RESULTS.GRANTED && ( )} - {permissions === RESULTS.GRANTED && device == null && ( + {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( )} - {permissions === RESULTS.GRANTED && device != null && ( + {cameraPermissionStatus === RESULTS.GRANTED && device != null && ( )} - { - showImagePicker(launchImageLibrary) - .then((receiptImage) => { - const filePath = receiptImage[0].uri; - IOU.setMoneyRequestReceipt(filePath, receiptImage[0].fileName); - - if (transactionID) { - FileUtils.readFileAsync(filePath, receiptImage[0].fileName).then((receipt) => { - IOU.replaceReceipt(transactionID, receipt, filePath); - }); - Navigation.dismissModal(); - return; - } - - IOU.navigateToNextPage(iou, iouType, report, route.path); - }) - .catch(() => { - Log.info('User did not select an image from gallery'); - }); - }} - > - - + + {({openPicker}) => ( + { + openPicker({ + onPicked: (file) => { + const filePath = file.uri; + IOU.setMoneyRequestReceipt(filePath, file.name); + + if (transactionID) { + IOU.replaceReceipt(transactionID, file, filePath); + Navigation.dismissModal(); + return; + } + + IOU.navigateToNextPage(iou, iouType, report, route.path); + }, + }); + }} + > + + + )} + setFlash((prevFlash) => !prevFlash)} > participant.accountID !== reportAction.actorAccountID); - const {amount: splitAmount, currency: splitCurrency, comment: splitComment, category: splitCategory} = ReportUtils.getTransactionDetails(transaction); + + const isScanning = + TransactionUtils.hasReceipt(props.transaction) && TransactionUtils.isReceiptBeingScanned(props.transaction) && TransactionUtils.areRequiredFieldsEmpty(props.transaction); + const hasSmartScanFailed = TransactionUtils.hasReceipt(props.transaction) && props.transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANFAILED; + const isEditingSplitBill = props.session.accountID === reportAction.actorAccountID && (TransactionUtils.areRequiredFieldsEmpty(props.transaction) || hasSmartScanFailed); + + const { + amount: splitAmount, + currency: splitCurrency, + comment: splitComment, + merchant: splitMerchant, + created: splitCreated, + category: splitCategory, + } = isEditingSplitBill && props.draftTransaction ? ReportUtils.getTransactionDetails(props.draftTransaction) : ReportUtils.getTransactionDetails(props.transaction); + + const onConfirm = useCallback( + () => IOU.completeSplitBill(reportID, reportAction, props.draftTransaction, props.session.accountID, props.session.email), + [reportID, reportAction, props.draftTransaction, props.session.accountID, props.session.email], + ); return ( - + + {isScanning && } {Boolean(participants.length) && ( )} @@ -109,8 +158,33 @@ export default compose( withLocalize, withReportAndReportActionOrNotFound, withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, + }, + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, + canEvict: false, + }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, + session: { + key: ONYXKEYS.SESSION, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, + draftTransaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, }), )(SplitBillDetailsPage); diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 907869c0e3a4..de0e0a16c214 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -194,6 +194,22 @@ function MoneyRequestConfirmPage(props) { (selectedParticipants) => { const trimmedComment = props.iou.comment.trim(); + // If we have a receipt let's start the split bill by creating only the action, the transaction, and the group DM if needed + if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT && props.iou.receiptPath) { + const existingSplitChatReportID = CONST.REGEX.NUMBER.test(reportID.current) ? reportID.current : ''; + FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((receipt) => { + IOU.startSplitBill( + selectedParticipants, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + trimmedComment, + receipt, + existingSplitChatReportID, + ); + }); + return; + } + // IOUs created from a group report will have a reportID param in the route. // Since the user is already viewing the report, we don't need to navigate them to the report if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT && CONST.REGEX.NUMBER.test(reportID.current)) { @@ -224,8 +240,8 @@ function MoneyRequestConfirmPage(props) { return; } - if (props.iou.receiptPath && props.iou.receiptSource) { - FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptSource).then((file) => { + if (props.iou.receiptPath && props.iou.receiptFilename) { + FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => { const receipt = file; receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY; requestMoney(selectedParticipants, trimmedComment, receipt); @@ -248,7 +264,7 @@ function MoneyRequestConfirmPage(props) { props.iou.currency, props.iou.category, props.iou.receiptPath, - props.iou.receiptSource, + props.iou.receiptFilename, isDistanceRequest, requestMoney, createDistanceRequest, @@ -288,6 +304,10 @@ function MoneyRequestConfirmPage(props) { return props.translate('iou.split'); } + if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SEND) { + return props.translate('common.send'); + } + return props.translate('tabSelector.manual'); }; @@ -344,7 +364,7 @@ function MoneyRequestConfirmPage(props) { IOU.setMoneyRequestParticipants(newParticipants); }} receiptPath={props.iou.receiptPath} - receiptSource={props.iou.receiptSource} + receiptFilename={props.iou.receiptFilename} iouType={iouType.current} reportID={reportID.current} isPolicyExpenseChat={isPolicyExpenseChat} @@ -360,6 +380,7 @@ function MoneyRequestConfirmPage(props) { iouCreated={props.iou.created} isDistanceRequest={isDistanceRequest} listStyles={[StyleUtils.getMaximumHeight(windowHeight / 3)]} + shouldShowSmartScanFields={_.isEmpty(props.iou.receiptPath)} /> diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 89c18efc4e76..25e41ba78556 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -35,11 +35,12 @@ const propTypes = { iou: iouPropTypes, /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ - selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]).isRequired, + selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]), }; const defaultProps = { iou: iouDefaultProps, + selectedTab: undefined, }; function MoneyRequestParticipantsPage({iou, selectedTab, route}) { @@ -49,6 +50,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { const iouType = useRef(lodashGet(route, 'params.iouType', '')); const reportID = useRef(lodashGet(route, 'params.reportID', '')); const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType.current, selectedTab); + const isSendRequest = iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SEND; const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); @@ -59,8 +61,13 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { return; } + if (isSendRequest) { + setHeaderTitle(translate('common.send')); + return; + } + setHeaderTitle(_.isEmpty(iou.participants) ? translate('tabSelector.manual') : translate('iou.split')); - }, [iou.participants, isDistanceRequest, translate]); + }, [iou.participants, isDistanceRequest, isSendRequest, translate]); const navigateToConfirmationStep = (moneyRequestType) => { IOU.setMoneyRequestId(moneyRequestType); @@ -113,7 +120,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { ref={(el) => (optionsSelectorRef.current = el)} participants={iou.participants} onAddParticipants={IOU.setMoneyRequestParticipants} - navigateToRequest={() => navigateToConfirmationStep(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)} + navigateToRequest={() => navigateToConfirmationStep(iouType.current)} navigateToSplit={() => navigateToConfirmationStep(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} iouType={iouType.current} @@ -126,7 +133,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { ); } -MoneyRequestParticipantsPage.displayName = 'IOUParticipantsPage'; +MoneyRequestParticipantsPage.displayName = 'MoneyRequestParticipantsPage'; MoneyRequestParticipantsPage.propTypes = propTypes; MoneyRequestParticipantsPage.defaultProps = defaultProps; diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index ac30bcf55787..547d2b7c363a 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -59,9 +59,6 @@ const propTypes = { /** Whether the money request is a distance request or not */ isDistanceRequest: PropTypes.bool, - /** Whether the money request is a scan request or not */ - isScanRequest: PropTypes.bool, - ...withLocalizePropTypes, }; @@ -73,7 +70,6 @@ const defaultProps = { reports: {}, betas: [], isDistanceRequest: false, - isScanRequest: false, }; function MoneyRequestParticipantsSelector({ @@ -89,7 +85,6 @@ function MoneyRequestParticipantsSelector({ safeAreaPaddingBottomStyle, iouType, isDistanceRequest, - isScanRequest, }) { const [searchTerm, setSearchTerm] = useState(''); const [newChatOptions, setNewChatOptions] = useState({ @@ -245,7 +240,7 @@ function MoneyRequestParticipantsSelector({ // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants const hasPolicyExpenseChatParticipant = _.some(participants, (participant) => participant.isPolicyExpenseChat); const shouldShowConfirmButton = !(participants.length > 1 && hasPolicyExpenseChatParticipant); - const isAllowedToSplit = !isDistanceRequest && !isScanRequest; + const isAllowedToSplit = !isDistanceRequest && iouType !== CONST.IOU.MONEY_REQUEST_TYPE.SEND; return ( 0 ? safeAreaPaddingBottomStyle : {}]}> @@ -268,7 +263,7 @@ function MoneyRequestParticipantsSelector({ textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} shouldShowOptions={isOptionsDataReady} - shouldFocusOnSelectRow={!Browser.isMobile()} + shouldPreventDefaultFocusOnSelectRow={!Browser.isMobile()} shouldDelayFocus /> diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index 78a300a38057..25b6197f87f8 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -21,7 +21,6 @@ import * as Link from '../../../libs/actions/Link'; import compose from '../../../libs/compose'; import * as ReportActionContextMenu from '../../home/report/ContextMenu/ReportActionContextMenu'; import {CONTEXT_MENU_TYPES} from '../../home/report/ContextMenu/ContextMenuActions'; -import * as KeyboardShortcuts from '../../../libs/actions/KeyboardShortcuts'; import * as Environment from '../../../libs/Environment/Environment'; const propTypes = { @@ -53,7 +52,9 @@ function AboutPage(props) { { translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', icon: Expensicons.Keyboard, - action: KeyboardShortcuts.showKeyboardShortcutModal, + action: () => { + Navigation.navigate(ROUTES.KEYBOARD_SHORTCUTS); + }, }, { translationKey: 'initialSettingsPage.aboutPage.viewTheCode', diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index af36650d6812..73231cd315ad 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; +import {useFocusEffect} from '@react-navigation/native'; import MagicCodeInput from '../../../../../components/MagicCodeInput'; import * as ErrorUtils from '../../../../../libs/ErrorUtils'; import withLocalize, {withLocalizePropTypes} from '../../../../../components/withLocalize'; @@ -77,6 +78,7 @@ function BaseValidateCodeForm(props) { const inputValidateCodeRef = useRef(); const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); const shouldDisableResendValidateCode = props.network.isOffline || props.account.isLoading; + const focusTimeoutRef = useRef(null); useImperativeHandle(props.innerRef, () => ({ focus() { @@ -87,6 +89,21 @@ function BaseValidateCodeForm(props) { }, })); + useFocusEffect( + useCallback(() => { + if (!inputValidateCodeRef.current) { + return; + } + focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focus, CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, []), + ); + useEffect(() => { Session.clearAccountMessages(); if (!validateLoginError) { diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js index 886a3949766d..68d81d64c604 100644 --- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -6,7 +6,6 @@ import {subYears} from 'date-fns'; import CONST from '../../../../CONST'; import ONYXKEYS from '../../../../ONYXKEYS'; import ROUTES from '../../../../ROUTES'; -import Form from '../../../../components/Form'; import HeaderWithBackButton from '../../../../components/HeaderWithBackButton'; import NewDatePicker from '../../../../components/NewDatePicker'; import ScreenWrapper from '../../../../components/ScreenWrapper'; @@ -18,6 +17,7 @@ import compose from '../../../../libs/compose'; import styles from '../../../../styles/styles'; import usePrivatePersonalDetails from '../../../../hooks/usePrivatePersonalDetails'; import FullscreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator'; +import FormProvider from '../../../../components/Form/FormProvider'; const propTypes = { /* Onyx Props */ @@ -72,7 +72,7 @@ function DateOfBirthPage({translate, privatePersonalDetails}) { {isLoadingPersonalDetails ? ( ) : ( -
- + )}
); diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index a69b227470d6..7dc9ff7773de 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -26,7 +26,7 @@ const propTypes = { const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; function NotificationPreferencePage(props) { - const shouldDisableNotificationPreferences = ReportUtils.shouldDisableSettings(props.report) || ReportUtils.isArchivedRoom(props.report); + const shouldDisableNotificationPreferences = ReportUtils.isArchivedRoom(props.report); const notificationPreferenceOptions = _.map( _.filter(_.values(CONST.REPORT.NOTIFICATION_PREFERENCE), (pref) => pref !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN), (preference) => ({ diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js index 9f4f4d048354..5612096207bb 100644 --- a/src/pages/settings/Report/ReportSettingsPage.js +++ b/src/pages/settings/Report/ReportSettingsPage.js @@ -64,7 +64,7 @@ function ReportSettingsPage(props) { const shouldDisableWelcomeMessage = isMoneyRequestReport || ReportUtils.isArchivedRoom(report) || !ReportUtils.isChatRoom(report) || _.isEmpty(linkedWorkspace) || linkedWorkspace.role !== CONST.POLICY.ROLE.ADMIN; - const shouldDisableSettings = _.isEmpty(report) || ReportUtils.shouldDisableSettings(report) || ReportUtils.isArchivedRoom(report); + const shouldDisableSettings = _.isEmpty(report) || ReportUtils.isArchivedRoom(report); const shouldShowRoomName = !ReportUtils.isPolicyExpenseChat(report) && !ReportUtils.isChatThread(report); const notificationPreference = report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js new file mode 100644 index 000000000000..e7198c009a44 --- /dev/null +++ b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js @@ -0,0 +1,180 @@ +import React, {useRef, useCallback, useState, useEffect} from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import _ from 'underscore'; +import Text from '../../../components/Text'; +import Navigation from '../../../libs/Navigation/Navigation'; +import styles from '../../../styles/styles'; +import MagicCodeInput from '../../../components/MagicCodeInput'; +import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; +import * as ErrorUtils from '../../../libs/ErrorUtils'; +import * as CardSettings from '../../../libs/actions/Card'; +import BigNumberPad from '../../../components/BigNumberPad'; +import Button from '../../../components/Button'; +import IllustratedHeaderPageLayout from '../../../components/IllustratedHeaderPageLayout'; +import themeColors from '../../../styles/themes/default'; +import SCREENS from '../../../SCREENS'; +import * as LottieAnimations from '../../../components/LottieAnimations'; +import useWindowDimensions from '../../../hooks/useWindowDimensions'; +import ONYXKEYS from '../../../ONYXKEYS'; +import useLocalize from '../../../hooks/useLocalize'; +import ROUTES from '../../../ROUTES'; +import CONST from '../../../CONST'; +import assignedCardPropTypes from './assignedCardPropTypes'; +import * as CardUtils from '../../../libs/CardUtils'; +import useNetwork from '../../../hooks/useNetwork'; +import NotFoundPage from '../../ErrorPage/NotFoundPage'; + +const propTypes = { + /* Onyx Props */ + + /** The details about the Expensify cards */ + cardList: PropTypes.objectOf(assignedCardPropTypes), + + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** domain passed via route /settings/wallet/card/:domain */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + cardList: {}, +}; + +const LAST_FOUR_DIGITS_LENGTH = 4; +const MAGIC_INPUT_MIN_HEIGHT = 86; + +function ActivatePhysicalCardPage({ + cardList, + route: { + params: {domain}, + }, +}) { + const {isExtraSmallScreenHeight} = useWindowDimensions(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + + const [formError, setFormError] = useState(''); + const [lastFourDigits, setLastFourDigits] = useState(''); + const [lastPressedDigit, setLastPressedDigit] = useState(''); + + const domainCards = CardUtils.getDomainCards(cardList)[domain]; + const physicalCard = _.find(domainCards, (card) => !card.isVirtual) || {}; + const cardID = lodashGet(physicalCard, 'cardID', 0); + const cardError = ErrorUtils.getLatestErrorMessage(physicalCard); + + const activateCardCodeInputRef = useRef(null); + + /** + * If state of the card is CONST.EXPENSIFY_CARD.STATE.OPEN, navigate to card details screen. + */ + useEffect(() => { + if (physicalCard.isLoading || lodashGet(cardList, `${cardID}.state`, 0) !== CONST.EXPENSIFY_CARD.STATE.OPEN) { + return; + } + + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain)); + }, [cardID, cardList, domain, physicalCard.isLoading]); + + useEffect( + () => () => { + CardSettings.clearCardListErrors(cardID); + }, + [cardID], + ); + + /** + * Update lastPressedDigit with value that was pressed on BigNumberPad. + * + * NOTE: If the same digit is pressed twice in a row, append it to the end of the string + * so that useEffect inside MagicCodeInput will be triggered by artificial change of the value. + * + * @param {String} key + */ + const updateLastPressedDigit = useCallback((key) => setLastPressedDigit(lastPressedDigit === key ? lastPressedDigit + key : key), [lastPressedDigit]); + + /** + * Handle card activation code input + * + * @param {String} text + */ + const onCodeInput = (text) => { + setFormError(''); + + if (cardError) { + CardSettings.clearCardListErrors(cardID); + } + + setLastFourDigits(text); + }; + + const submitAndNavigateToNextPage = useCallback(() => { + activateCardCodeInputRef.current.blur(); + + if (lastFourDigits.replace(CONST.MAGIC_CODE_EMPTY_CHAR, '').length !== LAST_FOUR_DIGITS_LENGTH) { + setFormError(translate('activateCardPage.error.thatDidntMatch')); + return; + } + + CardSettings.activatePhysicalExpensifyCard(Number(lastFourDigits), cardID); + }, [lastFourDigits, cardID, translate]); + + if (_.isEmpty(physicalCard)) { + return ; + } + + return ( + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain))} + backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.PREFERENCES]} + illustration={LottieAnimations.Magician} + scrollViewContainerStyles={[styles.mnh100]} + childrenContainerStyles={[styles.flex1]} + > + {translate('activateCardPage.pleaseEnterLastFour')} + + + + + {DeviceCapabilities.canUseTouchScreen() && } + + + + ); +} + +ActivatePhysicalCardPage.propTypes = propTypes; +ActivatePhysicalCardPage.defaultProps = defaultProps; +ActivatePhysicalCardPage.displayName = 'ActivatePhysicalCardPage'; + +export default withOnyx({ + cardList: { + key: ONYXKEYS.CARD_LIST, + }, +})(ActivatePhysicalCardPage); diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js index 026e8147d79f..cfbd26133ced 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.js +++ b/src/pages/settings/Wallet/ExpensifyCardPage.js @@ -10,17 +10,20 @@ import CardPreview from '../../../components/CardPreview'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; import ScreenWrapper from '../../../components/ScreenWrapper'; -import assignedCardPropTypes from './assignedCardPropTypes'; import useLocalize from '../../../hooks/useLocalize'; import * as CurrencyUtils from '../../../libs/CurrencyUtils'; import Navigation from '../../../libs/Navigation/Navigation'; import styles from '../../../styles/styles'; +import * as Expensicons from '../../../components/Icon/Expensicons'; import * as CardUtils from '../../../libs/CardUtils'; import Button from '../../../components/Button'; import CardDetails from './WalletPage/CardDetails'; +import CONST from '../../../CONST'; +import assignedCardPropTypes from './assignedCardPropTypes'; const propTypes = { /* Onyx Props */ + /** The details about the Expensify cards */ cardList: PropTypes.objectOf(assignedCardPropTypes), /** Navigation route context info provided by react navigation */ @@ -106,6 +109,13 @@ function ExpensifyCardPage({ } /> )} + Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(domain))} + /> )} {!_.isEmpty(physicalCard) && ( @@ -113,10 +123,18 @@ function ExpensifyCardPage({ description={translate('cardPage.physicalCardNumber')} title={CardUtils.maskCard(physicalCard.lastFourPAN)} interactive={false} - titleStyle={styles.walletCardNumber} + titleStyle={styles.walletCardMenuItem} /> )} + {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED && ( +