diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml index 94ea94d27505..7a90cc45257d 100644 --- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml +++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml @@ -29,9 +29,9 @@ runs: shell: bash run: | if [[ -f .github/workflows/OSBotify-private-key.asc.gpg ]]; then - echo "::set-output name=key_exists::true" + echo "key_exists=true" >> "$GITHUB_OUTPUT" fi - + - name: Checkout uses: actions/checkout@v4 if: steps.key_check.outputs.key_exists != 'true' diff --git a/.github/actions/javascript/awaitStagingDeploys/action.yml b/.github/actions/javascript/awaitStagingDeploys/action.yml index fdd0b940abaa..3499b4050de0 100644 --- a/.github/actions/javascript/awaitStagingDeploys/action.yml +++ b/.github/actions/javascript/awaitStagingDeploys/action.yml @@ -8,5 +8,5 @@ inputs: description: If provided, this action will only wait for a deploy matching this tag. required: false runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/bumpVersion/action.yml b/.github/actions/javascript/bumpVersion/action.yml index d092821d96ac..dc4a75d6eb71 100644 --- a/.github/actions/javascript/bumpVersion/action.yml +++ b/.github/actions/javascript/bumpVersion/action.yml @@ -11,5 +11,5 @@ outputs: NEW_VERSION: description: The new semver version of the application, updated in the JS and native layers. runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/checkDeployBlockers/action.yml b/.github/actions/javascript/checkDeployBlockers/action.yml index ce0d19f2def1..c6c7b3954c89 100644 --- a/.github/actions/javascript/checkDeployBlockers/action.yml +++ b/.github/actions/javascript/checkDeployBlockers/action.yml @@ -11,5 +11,5 @@ outputs: HAS_DEPLOY_BLOCKERS: description: A true/false indicating whether or not a deploy blocker was found. runs: - using: 'node16' + using: 'node20' main: 'index.js' diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml b/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml index 870cab318d09..348c5fe89d3d 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml @@ -8,5 +8,5 @@ inputs: description: The new NPM version of the StagingDeployCash issue. required: false runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index b7ab57e68974..da8db977d114 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -198,13 +198,16 @@ function fetchTag(tag) { let needsRepack = false; while (shouldRetry) { try { + let command = ''; if (needsRepack) { // We have seen some scenarios where this fixes the git fetch. // Why? Who knows... https://github.com/Expensify/App/pull/31459 - execSync('git repack -d'); + command = 'git repack -d'; + console.log(`Running command: ${command}`); + execSync(command); } - let command = `git fetch origin tag ${tag} --no-tags`; + command = `git fetch origin tag ${tag} --no-tags`; // Exclude commits reachable from the previous patch version (i.e: previous checklist), // so that we don't have to fetch the full history @@ -315,16 +318,15 @@ function getValidMergedPRs(commits) { * @param {String} toTag * @returns {Promise>} – Pull request numbers */ -function getPullRequestsMergedBetween(fromTag, toTag) { +async function getPullRequestsMergedBetween(fromTag, toTag) { console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); - return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { - console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); + const commitList = await getCommitHistoryAsJSON(fromTag, toTag); + console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); - // Find which commit messages correspond to merged PR's - const pullRequestNumbers = getValidMergedPRs(commitList); - console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); - }); + // Find which commit messages correspond to merged PR's + const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b); + console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); + return pullRequestNumbers; } module.exports = { diff --git a/.github/actions/javascript/getDeployPullRequestList/action.yml b/.github/actions/javascript/getDeployPullRequestList/action.yml index 4cbf7041a7eb..1362f207ba4a 100644 --- a/.github/actions/javascript/getDeployPullRequestList/action.yml +++ b/.github/actions/javascript/getDeployPullRequestList/action.yml @@ -14,5 +14,5 @@ outputs: PR_LIST: description: Array of pull request numbers runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 1217c5e97de4..af691cfb6d1d 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -140,13 +140,16 @@ function fetchTag(tag) { let needsRepack = false; while (shouldRetry) { try { + let command = ''; if (needsRepack) { // We have seen some scenarios where this fixes the git fetch. // Why? Who knows... https://github.com/Expensify/App/pull/31459 - execSync('git repack -d'); + command = 'git repack -d'; + console.log(`Running command: ${command}`); + execSync(command); } - let command = `git fetch origin tag ${tag} --no-tags`; + command = `git fetch origin tag ${tag} --no-tags`; // Exclude commits reachable from the previous patch version (i.e: previous checklist), // so that we don't have to fetch the full history @@ -257,16 +260,15 @@ function getValidMergedPRs(commits) { * @param {String} toTag * @returns {Promise>} – Pull request numbers */ -function getPullRequestsMergedBetween(fromTag, toTag) { +async function getPullRequestsMergedBetween(fromTag, toTag) { console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); - return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { - console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); + const commitList = await getCommitHistoryAsJSON(fromTag, toTag); + console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); - // Find which commit messages correspond to merged PR's - const pullRequestNumbers = getValidMergedPRs(commitList); - console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); - }); + // Find which commit messages correspond to merged PR's + const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b); + console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); + return pullRequestNumbers; } module.exports = { diff --git a/.github/actions/javascript/getPreviousVersion/action.yml b/.github/actions/javascript/getPreviousVersion/action.yml index 6b2221af7c40..ec81bd99e4f8 100644 --- a/.github/actions/javascript/getPreviousVersion/action.yml +++ b/.github/actions/javascript/getPreviousVersion/action.yml @@ -8,5 +8,5 @@ outputs: PREVIOUS_VERSION: description: The previous semver version of the application, according to the SEMVER_LEVEL provided runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/getPullRequestDetails/action.yml b/.github/actions/javascript/getPullRequestDetails/action.yml index ed2c60f018a1..d931d101b5da 100644 --- a/.github/actions/javascript/getPullRequestDetails/action.yml +++ b/.github/actions/javascript/getPullRequestDetails/action.yml @@ -22,5 +22,5 @@ outputs: FORKED_REPO_URL: description: 'Output forked repo URL if PR includes changes from a fork' runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/getReleaseBody/action.yml b/.github/actions/javascript/getReleaseBody/action.yml index c221acbdaae2..e4a451ccda8d 100644 --- a/.github/actions/javascript/getReleaseBody/action.yml +++ b/.github/actions/javascript/getReleaseBody/action.yml @@ -8,5 +8,5 @@ outputs: RELEASE_BODY: description: String body of a production release. runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/isStagingDeployLocked/action.yml b/.github/actions/javascript/isStagingDeployLocked/action.yml index 9e5e50b26452..395a081a7620 100644 --- a/.github/actions/javascript/isStagingDeployLocked/action.yml +++ b/.github/actions/javascript/isStagingDeployLocked/action.yml @@ -10,5 +10,5 @@ outputs: NUMBER: description: StagingDeployCash issue number runs: - using: 'node16' + using: 'node20' main: 'index.js' diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/action.yml b/.github/actions/javascript/markPullRequestsAsDeployed/action.yml index 7015293d2bb8..f0ca77bdbf00 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/action.yml +++ b/.github/actions/javascript/markPullRequestsAsDeployed/action.yml @@ -28,5 +28,5 @@ inputs: description: "Web job result ('success', 'failure', 'cancelled', or 'skipped')" required: true runs: - using: "node16" + using: "node20" main: "./index.js" diff --git a/.github/actions/javascript/postTestBuildComment/action.yml b/.github/actions/javascript/postTestBuildComment/action.yml index 07829dfab8cd..00c826badf9f 100644 --- a/.github/actions/javascript/postTestBuildComment/action.yml +++ b/.github/actions/javascript/postTestBuildComment/action.yml @@ -32,5 +32,5 @@ inputs: description: "Link for the web build" required: false runs: - using: "node16" + using: "node20" main: "./index.js" diff --git a/.github/actions/javascript/reopenIssueWithComment/action.yml b/.github/actions/javascript/reopenIssueWithComment/action.yml index 0a163e6651f0..3dfcba9b0c35 100644 --- a/.github/actions/javascript/reopenIssueWithComment/action.yml +++ b/.github/actions/javascript/reopenIssueWithComment/action.yml @@ -11,5 +11,5 @@ inputs: description: The comment string we want to leave on the issue after we reopen it. required: true runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/reviewerChecklist/action.yml b/.github/actions/javascript/reviewerChecklist/action.yml index 24fe815dcc6a..d8c1e6620b77 100644 --- a/.github/actions/javascript/reviewerChecklist/action.yml +++ b/.github/actions/javascript/reviewerChecklist/action.yml @@ -5,5 +5,5 @@ inputs: description: Auth token for New Expensify Github required: true runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/validateReassureOutput/action.yml b/.github/actions/javascript/validateReassureOutput/action.yml index 1b4488757e9c..4fd53e838fb5 100644 --- a/.github/actions/javascript/validateReassureOutput/action.yml +++ b/.github/actions/javascript/validateReassureOutput/action.yml @@ -11,5 +11,5 @@ inputs: description: Refers to the results obtained from regression tests `.reassure/output.json`. required: true runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/verifySignedCommits/action.yml b/.github/actions/javascript/verifySignedCommits/action.yml index 1a641cddb391..a724220eba32 100644 --- a/.github/actions/javascript/verifySignedCommits/action.yml +++ b/.github/actions/javascript/verifySignedCommits/action.yml @@ -9,5 +9,5 @@ inputs: required: false runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/libs/GitUtils.js b/.github/libs/GitUtils.js index 42a7ecff1263..fa2cf430b277 100644 --- a/.github/libs/GitUtils.js +++ b/.github/libs/GitUtils.js @@ -13,13 +13,16 @@ function fetchTag(tag) { let needsRepack = false; while (shouldRetry) { try { + let command = ''; if (needsRepack) { // We have seen some scenarios where this fixes the git fetch. // Why? Who knows... https://github.com/Expensify/App/pull/31459 - execSync('git repack -d'); + command = 'git repack -d'; + console.log(`Running command: ${command}`); + execSync(command); } - let command = `git fetch origin tag ${tag} --no-tags`; + command = `git fetch origin tag ${tag} --no-tags`; // Exclude commits reachable from the previous patch version (i.e: previous checklist), // so that we don't have to fetch the full history @@ -130,16 +133,15 @@ function getValidMergedPRs(commits) { * @param {String} toTag * @returns {Promise>} – Pull request numbers */ -function getPullRequestsMergedBetween(fromTag, toTag) { +async function getPullRequestsMergedBetween(fromTag, toTag) { console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); - return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { - console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); + const commitList = await getCommitHistoryAsJSON(fromTag, toTag); + console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); - // Find which commit messages correspond to merged PR's - const pullRequestNumbers = getValidMergedPRs(commitList); - console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); - }); + // Find which commit messages correspond to merged PR's + const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b); + console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); + return pullRequestNumbers; } module.exports = { diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c904a459d1c0..d4340e5a55f7 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -63,7 +63,7 @@ git fetch origin tag 1.0.1-0 --no-tags --shallow-exclude=1.0.0-0 # This will fet ## Security Rules 🔐 1. Do **not** use `pull_request_target` trigger unless an external fork needs access to secrets, or a _write_ `GITHUB_TOKEN`. -1. Do **not ever** write a `pull_request_target` trigger with an explicit PR checkout, e.g. using `actions/checkout@v2`. This is [discussed further here](https://securitylab.github.com/research/github-actions-preventing-pwn-requests) +1. Do **not ever** write a `pull_request_target` trigger with an explicit PR checkout, e.g. using `actions/checkout@v4`. This is [discussed further here](https://securitylab.github.com/research/github-actions-preventing-pwn-requests) 1. **Do use** the `pull_request` trigger as it does not send internal secrets and only grants a _read_ `GITHUB_TOKEN`. 1. If an untrusted (i.e: not maintained by GitHub) external action needs access to any secret (`GITHUB_TOKEN` or internal secret), use the commit hash of the workflow to prevent a modification of underlying source code at that version. For example: 1. **Bad:** `hmarr/auto-approve-action@v2.0.0` Relies on the tag diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml index 740e7b3a5e69..ecb0b87a6416 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: authorChecklist.js uses: ./.github/actions/javascript/authorChecklist diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4aa1a6a27d1a..6b32ac2e2616 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,11 +10,11 @@ jobs: if: github.ref == 'refs/heads/staging' steps: - name: Checkout staging branch - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - + - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab id: setupGitForOSBotify with: diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index 6356d7f65a4d..355d001622f7 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -13,28 +13,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Get URL, title, & number of new deploy blocker (issue) - if: ${{ github.event_name == 'issues' }} - env: - TITLE: ${{ github.event.issue.title }} - run: | - { echo "DEPLOY_BLOCKER_URL=${{ github.event.issue.html_url }}"; - echo "DEPLOY_BLOCKER_NUMBER=${{ github.event.issue.number }}"; - echo "DEPLOY_BLOCKER_TITLE=$(sed -e "s/'/'\\\\''/g; s/\`/\\\\\`/g; 1s/^/'/; \$s/\$/'/" <<< "$TITLE")";} >> "$GITHUB_ENV" + - uses: ./.github/actions/composite/setupGitForOSBotifyApp + 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: Update StagingDeployCash with new deploy blocker - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main + uses: ./.github/actions/javascript/createOrUpdateStagingDeploy with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - - name: Give the issue/PR the Hourly, Engineering labels - uses: andymckay/labeler@978f846c4ca6299fd136f465b42c5e87aca28cac - with: - add-labels: 'Hourly, Engineering' - remove-labels: 'Daily, Weekly, Monthly' + - run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' + env: + GITHUB_TOKEN: ${{ github.token }} - name: 'Post the issue in the #expensify-open-source slack room' if: ${{ success() }} @@ -46,26 +40,27 @@ jobs: channel: '#expensify-open-source', attachments: [{ color: "#DB4545", - text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ env.DEPLOY_BLOCKER_URL }}|'+ `${{ env.DEPLOY_BLOCKER_TITLE }}`.replace(/(^'|'$)/gi, '').replace(/'\''/gi,'\'') + '>', + text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ toJSON(github.event.issue.title) }}>', }] } env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - name: Comment on deferred PR - uses: actions-ecosystem/action-create-comment@cd098164398331c50e7dfdd0dfa1b564a1873fac - with: - github_token: ${{ secrets.OS_BOTIFY_TOKEN }} - number: ${{ env.DEPLOY_BLOCKER_NUMBER }} - body: | - :wave: Friendly reminder that deploy blockers are time-sensitive ⏱ issues! [Check out the open `StagingDeployCash` deploy checklist](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3AStagingDeployCash) to see the list of PRs included in this release, then work quickly to do one of the following: - 1. Identify the pull request that introduced this issue and revert it. - 2. Find someone who can quickly fix the issue. - 3. Fix the issue yourself. + - name: Comment on deploy blocker + run: | + gh issue comment ${{ github.event.issue.number }} --body "$(cat <<'EOF' + :wave: Friendly reminder that deploy blockers are time-sensitive ⏱ issues! [Check out the open \`StagingDeployCash\` deploy checklist](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3AStagingDeployCash) to see the list of PRs included in this release, then work quickly to do one of the following: + 1. Identify the pull request that introduced this issue and revert it. + 2. Find someone who can quickly fix the issue. + 3. Fix the issue yourself. + EOF + )" + env: + GITHUB_TOKEN: ${{ github.token }} - name: Announce failed workflow in Slack if: ${{ failure() }} - uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main + uses: ./.github/actions/composite/announceFailedWorkflowInSlack with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 4a53e75354c6..7b9b7479f496 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 - name: Setup NodeJS uses: Expensify/App/.github/actions/composite/setupNode@main diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 1f266c59d0d1..4725ca6c86ce 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -50,7 +50,7 @@ jobs: steps: - name: Checkout if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 - name: Check if pull request number is correct if: ${{ github.event_name == 'workflow_dispatch' }} @@ -70,9 +70,8 @@ jobs: env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} steps: - # This action checks-out the repository, so the workflow can access it. - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -135,9 +134,8 @@ jobs: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: macos-13-xlarge steps: - # This action checks-out the repository, so the workflow can access it. - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -302,7 +300,7 @@ jobs: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} diff --git a/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml index 531b8a3812fd..af24d5f17db4 100644 --- a/.github/workflows/updateHelpDotRedirects.yml +++ b/.github/workflows/updateHelpDotRedirects.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 - name: Create help dot redirect env: diff --git a/.nvmrc b/.nvmrc index b8c9fdcbe36b..43bff1f8cf98 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.15.1 \ No newline at end of file +20.9.0 \ No newline at end of file diff --git a/assets/images/product-illustrations/payment-hands.svg b/assets/images/product-illustrations/payment-hands.svg new file mode 100644 index 000000000000..7d64d8572b30 --- /dev/null +++ b/assets/images/product-illustrations/payment-hands.svg @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index 84735e95e0e9..efdcf41b63d5 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -88,18 +88,18 @@ platforms: image: /assets/images/settings-new-dot.svg hubs: - - href: getting-started - title: Getting Started - icon: /assets/images/accounting.svg - description: From setting up your account to ensuring you get the most out of Expensify’s suite of features, click here to get started on streamlining your expense management journey. - + - href: chat + title: Chat + icon: /assets/images/chat-bubble.svg + description: Enhance your financial experience using Expensify's chat feature, offering quick and secure communication for personalized support and payment transfers. + - href: account-settings title: Account Settings icon: /assets/images/gears.svg description: Discover how to personalize your profile, add secondary logins, and grant delegated access to employees with our comprehensive guide on Account Settings. - - href: bank-accounts-and-credit-cards - title: Bank Accounts & Credit Cards + - href: bank-accounts + title: Bank Accounts icon: /assets/images/bank-card.svg description: Find out how to connect Expensify to your financial institutions, track credit card transactions, and best practices for reconciling company cards. @@ -108,11 +108,6 @@ platforms: icon: /assets/images/money-wings.svg description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods. - - href: expense-and-report-features - title: Expense & Report Features - icon: /assets/images/money-receipt.svg - description: From enabling automatic expense auditing to tracking attendees, here is where you can review tips and tutorials to streamline expense management. - - href: expensify-card title: Expensify Card icon: /assets/images/hand-card.svg @@ -128,21 +123,6 @@ platforms: icon: /assets/images/money-into-wallet.svg description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time. - - href: insights-and-custom-reporting - title: Insights & Custom Reporting - icon: /assets/images/monitor.svg - description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options. - - - href: integrations - title: Integrations - icon: /assets/images/workflow.svg - description: Enhance Expensify’s capabilities by integrating it with your accounting or HR software. Here is where you can learn more about creating a synchronized financial management ecosystem. - - - href: manage-employees-and-report-approvals - title: Manage Employees & Report Approvals - icon: /assets/images/envelope-receipt.svg - description: Master the art of overseeing employees and reports by utilizing Expensify’s automation features and approval workflows. - - href: send-payments title: Send Payments icon: /assets/images/money-wings.svg diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 46434787d6df..7a0804b0f962 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -657,6 +657,7 @@ button { p.description { padding: 0; + color: $color-text-supporting; &.with-min-height { min-height: 68px; diff --git a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md index 5f5ecca13b2f..bc8a8d8bf184 100644 --- a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md +++ b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md @@ -69,9 +69,13 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s ### Example - We have card transactions for the day totaling $100, so we create the following journal entry upon sync: +![QBO Journal Entry](https://help.expensify.com/assets/images/Auto-Reconciliation QBO 1.png){:width="100%"} - The current balance of the Expensify Clearing Account is now $100: +![QBO Clearing Account](https://help.expensify.com/assets/images/Auto-reconciliation QBO 2.png){:width="100%"} - After transactions are posted in Expensify and the report is approved and exported, a second journal entry is generated: +![QBO Second Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation QBO 3.png){:width="100%"} - We reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account: +![QBO Clearing Account 2](https://help.expensify.com/assets/images/Auto-reconciliation QBO 4.png){:width="100%"} - Now, you'll have a debit on your credit card account (increasing the total spent) and a credit on the bank account (reducing the available amount). The Clearing Account balance is $0. - Each expense will also create a credit card expense, similar to how we do it today, exported upon final approval. This action debits the expense account (category) and includes any other line item data. - This process occurs daily during the QuickBooks Online Auto-Sync to ensure your card remains reconciled. @@ -89,6 +93,7 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s ### How This Works 1. During the first overnight Auto Sync after enabling Continuous Reconciliation, Expensify will create a Liability Account (Bank Account) on your Xero Dashboard. If you've opted for Daily Settlement, an additional Clearing Account will be created in your General Ledger. Two Contacts —Expensify and Expensify Card— will also be generated: +![Xero Contacts](https://help.expensify.com/assets/images/Auto-reconciliation Xero 1.png){:width="100%"} 2. The bank account for Expensify Card transactions is tied to the Liability Account Expensify created. Note that this doesn't apply to other cards or non-reimbursable expenses, which follow your workspace settings. ### Daily Settlement Reconciliation @@ -129,7 +134,9 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s ### Example - Let's say you have card transactions totaling $100 for the day. - We create a journal entry: +![NetSuite Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation NS 1.png){:width="100%"} - After transactions are posted in Expensify, we create the second Journal Entry(ies): +![NetSuite Second Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation NS 2.png){:width="100%"} - We then reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account. - Now, you'll have a debit on your Credit Card account (increasing the total spent) and a credit on the bank account (reducing the amount available). The clearing account has a $0 balance. - Each expense will also create a Journal Entry, just as we do today, exported upon final approval. This entry will debit the expense account (category) and contain any other line item data. diff --git a/docs/articles/new-expensify/account-settings/Coming-Soon.md b/docs/articles/new-expensify/account-settings/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/account-settings/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/account-settings/Profile.md b/docs/articles/new-expensify/account-settings/Profile.md new file mode 100644 index 000000000000..908cf39c7ac6 --- /dev/null +++ b/docs/articles/new-expensify/account-settings/Profile.md @@ -0,0 +1,92 @@ +--- +title: Profile +description: How to manage your Expensify Profile +--- +# Overview +Your Profile in Expensify allows you to: +- Set your public profile photo +- Set a display name +- Manage your contact methods +- Communicate your current status +- Set your pronouns +- Configure your timezone +- Store your personal details (for travel and payment purposes) + +# How to set your public profile photo + +To set or update your profile photo: +1. Go to **Settings > Profile** +2. Tap on the default or your existing profile photo, +3. You can either either upload (to set a new profile photo), remove or view your profile photo + +Your profile photo is visible to all Expensify users. + +# How to set a display name + +To set or update your display name: +1. Go to **Settings > Profile** +2. Tap on **Display name** +3. Set a first name and a last name, then **Save** + +Your display name is public to all Expensify users. + +# How to add or remove contact methods (email address and phone number) + +Your contact methods allow people to contact you (using your email address or phone number), and allow you to forward receipts to receipts@expensify.com from multiple email addresses. + +To manage your contact methods: +1. Go to **Settings > Profile** +2. Tap on **Contact method** +3. Tap **New contact method** to add a new email or phone number + +Your default contact method (email address or phone number) will be visible to "known" users, with whom you have interacted or are part of your team. + +To change the email address or phone number that's displayed on your Expensify account, add a new contact method, then tap on that email address and tap **Set as default**. + +# How to communicate your current status + +You can use your status emoji to communicate your mood, focus or current activity. You can optionally add a status message too! + +To set your status emoji and status message: +1. Go to **Settings > Profile** +2. Tap on **Status** then **Status** +3. Choose a status emoji, and optionally set a status message +4. Tap on **Save** + +Your status emoji will be visible next to your name in Expensify, and your status emoji and status message will appear in your profile (which is public to all Expensify users). On a computer, your status message will also be visible by hovering your mouse over your name. + +You can also remove your current status: +1. Go to **Settings > Profile** +2. Tap on **Status** +3. Tap on **Clear status** + +# How to set your pronouns + +To set your pronouns: +1. Go to **Settings > Profile** +2. Tap on **Pronouns** +3. Search for your preferred pronouns, then tap on your choice + +Your pronouns will be visible to "known" users, with whom you have interacted or are part of your team. + +# How to configure your timezone + +Your timezone is automatically set using an estimation based on your IP address. + +To set your timezone manually: +1. Go to **Settings > Profile** +2. Tap on **Timezone** +3. Disable **Automatically determine your location** +4. Tap on **Timezone** +5. Search for your preferred timezone, then tap on your choice + +Your timezone will be visible to "known" users, with whom you have interacted or are part of your team. + +# How to store your personal details (for travel and payment purposes) + +Your personal details can be used in Expensify for travel and payment purposes. These will not be shared with any other Expensify user. + +To set your timezone manually: +1. Go to **Settings > Profile** +2. Tap on **Personal details** +3. Tap on **Legal name**, **Date of birth**, and **Address** to set your personal details diff --git a/docs/articles/new-expensify/getting-started/Security.md b/docs/articles/new-expensify/account-settings/Security.md similarity index 100% rename from docs/articles/new-expensify/getting-started/Security.md rename to docs/articles/new-expensify/account-settings/Security.md diff --git a/docs/articles/new-expensify/bank-accounts-and-credit-cards/Coming-Soon.md b/docs/articles/new-expensify/bank-accounts-and-credit-cards/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/bank-accounts-and-credit-cards/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/bank-accounts/Connect-a-Bank-Account.md b/docs/articles/new-expensify/bank-accounts/Connect-a-Bank-Account.md new file mode 100644 index 000000000000..de66315f2d79 --- /dev/null +++ b/docs/articles/new-expensify/bank-accounts/Connect-a-Bank-Account.md @@ -0,0 +1,142 @@ +--- +title: Connect a Business Bank Account - US +description: How to connect a business bank account to Expensify (US) +--- +# Overview +Adding a verified business bank account unlocks a myriad of features and automation in Expensify. +Once you connect your business bank account, you can: +- Reimburse expenses via direct bank transfer +- Pay bills +- Collect invoice payments +- Issue the Expensify Card + +# How to add a verified business bank account +To connect a business bank account to Expensify, follow the below steps: +1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account > Connect bank account** +2. Click **Connect online with Plaid** +3. Click **Continue** +4. When you reach the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access +5. Login to the business bank account: +- If the bank is not listed, click the X to go back to the connection type +- Here you’ll see the option to **Connect Manually** +- Enter your account and routing numbers +6. Enter your bank login credentials: +- If your bank requires additional security measures, you will be directed to obtain and enter a security code +- If you have more than one account available to choose from, you will be directed to choose the desired account + +Next, to verify the bank account, you’ll enter some details about the business as well as some personal information. + +## Enter company information +This is where you’ll add the legal business name as well as several other company details. + +- **Company address**: The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) +- **Tax Identification Number**: This is the identification number that was assigned to the business by the IRS +- **Company website**: A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com +- **Industry Classification Code**: You can locate a list of Industry Classification Codes [here]([url](https://www.census.gov/naics/?input=software&year=2022)) + +## Enter personal information +Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section: +- The address must be a physical address +- The address must be located in the US +- The SSN must be US-issued + +This does not need to be a signor on the bank account. If someone other than the Expensify account holder enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed. + +## Upload ID +After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following: +1. Upload a photo of the front and back of your ID (this cannot be a photo of an existing image) +2. Use your device to take a selfie and record a short video of yourself + +**Your ID must be:** +- Issued in the US +- Current (ie: the expiration date must be in the future) + +## Additional Information +Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate: +- A Beneficial Owner refers to an **individual** who owns 25% or more of the business. +- If you or another **individual** owns 25% or more of the business, please check the appropriate box +- If someone else owns 25% or more of the business, you will be prompted to provide their personal information + +If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section. + +# How to validate the bank account + +The account you set up can be found under **Settings > Workspaces > _Workspace Name_ > Bank account** in either the **Verifying** or **Pending** state. + +If it is **Verifying**, then this means we sent you a message and need more information from you. Please review the automated message sent by Concierge. This should include a message with specific details about what's required to move forward. + +If it is **Pending**, then in 1-2 business days Expensify will administer 3 test transactions to your bank account. If after two business days you do not see these test transactions, reach out to Concierge for assistance. + +After these transactions (2 withdrawals and 1 deposit) have been processed to your account, head to the **Bank accounts** section of your workspace settings. Here you'll see a prompt to input the transaction amounts. + +Once you've finished these steps, your business bank account is ready to use in Expensify! + +# How to delete a verified bank account +If you need to delete a bank account from Expensify, run through the following steps: +1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account** +2. Click the red **Delete** button under the corresponding bank account + +# Deep Dive + +## Verified bank account requirements + +To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US), or to issue Expensify Cards: +- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location. +- If you are adding the bank account to Expensify, you must add it from **your** Expensify account settings. +- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. For using features related to US ACH, your ID must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address +- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify. + +## Locked bank account +When you reimburse a report, you authorize Expensify to withdraw the funds from your account. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. + +Withdrawal requests can be rejected due to insufficient funds, or if the bank account has not been enabled for direct debit. +If you need to enable direct debits from your verified bank account, your bank will require the following details: +- The ACH CompanyIDs (1270239450, 4270239450 and 2270239450) +- The ACH Originator Name (Expensify) + +To request to unlock the bank account, go to **Settings > Workspaces > _Workspace Name_ > Bank account** and click **Fix.** This sends a request to our support team to review why the bank account was locked, who will send you a message to confirm that. + +Unlocking a bank account can take 4-5 business days to process, to allow for ACH processing time and clawback periods. + +## Error adding an ID to Onfido + +Expensify is required by both our sponsor bank and federal law to verify the identity of the individual who is initiating the movement of money. We use Onfido to confirm that the person adding a payment method is genuine and not impersonating someone else. + +If you get a generic error message that indicates, "Something's gone wrong", please go through the following steps: + +1. Ensure you are using either Safari (on iPhone) or Chrome (on Android) as your web browser. +2. Check your browser's permissions to make sure that the camera and microphone settings are set to "Allow" +3. Clear your web cache for Safari (on iPhone) or Chrome (on Android). +4. If using a corporate Wi-Fi network, confirm that your corporate firewall isn't blocking the website. +5. Make sure no other apps are overlapping your screen, such as the Facebook Messenger bubble, while recording the video. +6. On iPhone, if using iOS version 15 or later, disable the Hide IP address feature in Safari. +7. If possible, try these steps on another device +8. If you have another phone available, try to follow these steps on that device +If the issue persists, please contact your Account Manager or Concierge for further troubleshooting assistance. + +# FAQ +## What is a Beneficial Owner? + +A Beneficial Owner refers to an **individual** who owns 25% or more of the business. If no individual owns 25% or more of the business, the company does not have a Beneficial Owner. + +## What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company? + +Please only indicate you have a Beneficial Owner, if it is an individual that owns 25% or more of the business. + +## Why can’t I input my address or upload my ID? + +Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account, and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account, and then share access with you once verified. + +## Why am I asked for documents when adding my bank account? + +When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. +If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. + +If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. + +## I don’t see all three microtransactions I need to validate my bank account. What should I do? + +It's a good idea to wait till the end of that second business day. If you still don’t see them, please reach out to your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify". + +Make sure to reach out to your Account Manager or Concierge once that's all set, and our team will be able to re-trigger those three test transactions! + diff --git a/docs/articles/new-expensify/getting-started/Referral-Program.md b/docs/articles/new-expensify/billing-and-plan-types/Referral-Program.md similarity index 100% rename from docs/articles/new-expensify/getting-started/Referral-Program.md rename to docs/articles/new-expensify/billing-and-plan-types/Referral-Program.md diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md similarity index 97% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md rename to docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md index 17c7a60b8e5a..5128484adc9d 100644 --- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md +++ b/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md @@ -1,7 +1,6 @@ --- title: Expensify Chat for Admins description: Best Practices for Admins settings up Expensify Chat -redirect_from: articles/other/Expensify-Chat-For-Admins/ --- # Overview diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Attendees.md similarity index 100% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md rename to docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Attendees.md diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Speakers.md similarity index 100% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md rename to docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Speakers.md diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md b/docs/articles/new-expensify/chat/Expensify-Chat-Playbook-For-Conferences.md similarity index 100% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md rename to docs/articles/new-expensify/chat/Expensify-Chat-Playbook-For-Conferences.md diff --git a/docs/articles/new-expensify/getting-started/chat/Get-to-know-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md similarity index 99% rename from docs/articles/new-expensify/getting-started/chat/Get-to-know-Expensify-Chat.md rename to docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md index da78061027fa..669d960275e6 100644 --- a/docs/articles/new-expensify/getting-started/chat/Get-to-know-Expensify-Chat.md +++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md @@ -1,5 +1,5 @@ --- -title: Get to know Expensify Chat +title: Introducing Expensify Chat description: Everything you need to know about Expensify Chat! redirect_from: articles/other/Everything-About-Chat/ --- diff --git a/docs/articles/new-expensify/expense-and-report-features/Coming-Soon.md b/docs/articles/new-expensify/expense-and-report-features/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/expense-and-report-features/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md b/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md deleted file mode 100644 index 7a0717eeb5d1..000000000000 --- a/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md +++ /dev/null @@ -1,320 +0,0 @@ ---- -title: The QuickBooks Online Integration -description: Expensify's integration with QuickBooks Online streamlines your expense management. - ---- -# Overview - -The Expensify integration with QuickBooks Online brings in your expense accounts and other data and even exports reports directly to QuickBooks for easy reconciliation. Plus, with advanced features in QuickBooks Online, you can fine-tune coding settings in Expensify for automated data export to optimize your accounting workflow. - -## Before connecting - -It's crucial to understand the requirements based on your specific QuickBooks subscription: - -- While all the features are available in Expensify, their accessibility may vary depending on your QuickBooks Online subscription. -- An error will occur if you try to export to QuickBooks with a feature enabled that isn't part of your subscription. -- Please be aware that Expensify does not support the Self-Employed subscription in QuickBooks Online. - -# How to connect to QuickBooks Online - -## Step 1: Setup employees in QuickBooks Online - -Employees must be set up as either Vendors or Employees in QuickBooks Online. Make sure to include the submitter's email in their record. - -If you use vendor records, you can export as Vendor Bills, Checks, or Journal Entries. If you use employee records, you can export as Checks or Journal Entries (if exporting against a liability account). - -Additional Options for Streamlined Setup: - -- Automatic Vendor Creation: Enable “Automatically Create Entities” in your connection settings to automatically generate Vendor or Employee records upon export for submitters that don't already exist in QBO. -- Employee Setup Considerations: If setting up submitters as Employees, ensure you activate QuickBooks Online Payroll. This will grant access to the Employee Profile tab to input employee email addresses. - -## Step 2: Connect Expensify and QuickBooks Online - -- Navigate to Settings > Workspaces > Group > [Workspace Name] > Connections > QuickBooks Online. Click Connect to QuickBooks. -- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace). Then Click Authorize. -- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace): - -Exporting Historical Reports to QuickBooks Online: - -After connecting QuickBooks Online to Expensify, you may receive a prompt to export all historical reports from Expensify. To export multiple reports at once, follow these steps: - -a. Go to the Reports page on the web. - -b. Tick the checkbox next to the reports you want to export. - -c. Click 'Export To' and select 'QuickBooks Online' from the drop-down list. - -If you don't want to export specific reports, click “Mark as manually entered” on the report. - -# How to configure export settings for QuickBooks Online - -Our QuickBooks Online integration offers a range of features. This section will focus on Export Settings and how to set them up. - -## Preferred Exporter - -Any Workspace admin can export to your accounting integration, but the Preferred Exporter can be chosen to automate specific steps. You can set this role from Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Export > Preferred Exporter. - -The Preferred Exporter: - -- Is the user whose Concierge performs all automated exports on behalf of. -- Is the only user who will see reports awaiting export in their **Home.** -- Must be a **Domain Admin** if you have set individual GL accounts for Company Card export. -- Must be a **Domain Admin** if this is the Preferred Workspace for any Expensify Card domain using Automatic Reconciliation. - -## Date - -When exporting reports to QuickBooks Online, you can choose the report's **submitted date**, the report's **exported date**, or the **date of the last expense on the report.** - -Most export options (Check, Journal Entry, and Vendor Bill) will create a single itemized entry with one date. -Please note that if you choose a Credit Card or Debit Card for non-reimbursable expenses, we'll use the transaction date on each expense during export. - -# Reimbursable expenses - -Reimbursable expenses export to QuickBooks Online as: - -- Vendor Bills -- Checks -- Journal Entries - -## Vendor bill (recommended) - -This is a single itemized vendor bill for each Expensify report. If the accounting period is closed, we will post the vendor bill on the first day of the next open period. If you export as Vendor Bills, you can also choose to Sync reimbursed reports (set on the Advanced tab). **An A/P account is required to export to a vendor bill. Here is a screenshot of how your expenses map in QuickBooks.** - -The submitter will be listed as the vendor in the vendor bill. - -## Check - -This is a single itemized check for each Expensify report. You can mark a check to be printed later in QuickBooks Online. - -## Journal entry - -This is a single itemized journal entry for each Expensify report. - -# Non-reimbursable expenses - -Non-reimbursable expenses export to QuickBooks Online as: - -- Credit Card expenses -- Debit Card Expenses -- Vendor Bills - -## Credit/debit card - -Using Credit/Debit Card Transactions: - -- Each expense will be exported as a bank transaction with its transaction date. -- If you split an expense in Expensify, we'll consolidate it into a single credit card transaction in QuickBooks with multiple line items posted to the corresponding General Ledger accounts. - -Pro-Tip: To ensure the payee field in QuickBooks Online reflects the merchant name for Credit Card expenses, ensure there's a matching Vendor in QuickBooks Online. Expensify checks for an exact match during export. If none are found, the payee will be mapped to a vendor we create and labeled as Credit Card Misc. or Debit Card Misc. - -If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in QuickBooks. - -## Vendor Bill - -- A single detailed vendor bill is generated for each Expensify report. If the accounting period is closed, the vendor bill will be posted on the first day of the next open period. If you choose to export non-reimbursable expenses as Vendor Bills, you can assign a default vendor to the bill. -- The export will use your default vendor if you have Default Vendor enabled. If the Default Vendor is disabled, the report's submitter will be set as the Vendor in QuickBooks. - -Billable Expenses: - -- In Expensify, you can designate expenses as billable. These will be exported to QuickBooks Online with the billable flag. - This feature applies only to expenses exported as Vendor Bills or Checks. To maximize this functionality, ensure that any billable expense is associated with a Customer/Job. - -## Export Invoices - -If you are creating Invoices in Expensify and exporting these to QuickBooks Online, this is the account the invoice will appear against. - -# Configure coding for QuickBooks Online - -The coding tab is where your information is configured for Expensify; this will allow employees to code expenses and reports accurately. - -- Categories -- Classes and/or Customers/Projects -- Locations -- Items -- Tax - -## Categories - -QuickBooks Online expense accounts will be automatically imported into Expensify as Categories. - -## Account Import - -Equity type accounts will also be imported as categories. - -Important notes: - -- Other Current Liabilities can only be exported as Journal Entries if the submitter is set up as an Employee in QuickBooks. -- Exchange Gain or Loss detail type does not import. - -Recommended steps to take after importing the expense accounts from QuickBooks to Expensify: - -- Go to Settings > Workspaces > Groups > [Workspace Name] > Categories to see the accounts imported from QuickBooks Online. -- Use the enable/disable button to choose which Categories to make available to your employees, and set Category specific rules via the blue settings cog. -- If necessary, edit the names of imported Categories to make expense coding easier for your employees. (Please Note: If you make any changes to these accounts in QuickBooks Online, the category names on Expensify's side will revert to match the name of the account in QuickBooks Online the next time you sync). -- If you use Items in QuickBooks Online, you can import them into Expensify as Categories. - -Please note that each expense has to have a category selected to export to QuickBooks Online. The chosen category has to be imported from QuickBooks Online and cannot be manually created within the Workspace settings. - -## Classes and Customers/Projects - -If you use Classes or Customers/Projects in QuickBooks Online, you can import those into Expensify as Tags or Report Fields: - -- Tags let you apply a Class and/or Customer/Project to each expense -- Report Fields enables you to apply a Class and/or Customer/Project to all expenses on a report. - -Note: Although Projects can be imported into Expensify and coded to expenses, due to the limitations of the QuickBooks API, expenses cannot be created within the Projects module in QuickBooks. - -## Locations - -Locations can be imported into Expensify as a Report Field or, if you export reimbursable expenses as Journal Entries and non-reimbursable expenses as Credit/Debit Card, you can import Locations as Tags. - -## Items - -If you use Items in QuickBooks Online, you can import Items defined with Purchasing Information (with or without Sales Information) into Expensify as Categories. -## Tax - -- Using our tax tracking feature, you can assign a tax rate and amount to each expense. --To activate tax tracking, go to connection configuration and enable it. This will automatically import purchasing taxes from QuickBooks Online into Expensify. -- After the connection is set, navigate to Settings > Worspaces > Groups > Workspace Name] > Tax. Here, you can view the taxes imported from QuickBooks Online. -- Use the enable/disable button to choose which taxes are accessible to your employees. -- Set a default tax for the Company Workspace, which will automatically apply to all new expenses. -- Please note that, at present, tax cannot be exported to Journal Entries in QuickBooks Online. -- Expensify performs a daily sync to ensure your information is up-to-date. This minimizes errors from outdated QuickBooks Online data and saves you time on syncing. - -# How to configure advanced settings for QuickBooks Online - -The advanced settings are where functionality for automating and customizing the QuickBooks Online integration can be enabled. -Navigate to this section of your Workspace by following Settings > Workspaces > Group > [Workspace Name] > Connections > Configure button > Advanced tab. -## Auto Sync -With QuickBooks Online auto-sync, once a non-reimbursable report is final approved in Expensify, it's automatically queued for export to QuickBooks Online. For expenses eligible for reimbursement with a linked business bank account, they'll sync when marked as reimbursed. - -## Newly Imported Categories - -This setting determines the default status of newly imported categories from QuickBooks Online to Expensify, either enabled or disabled. - -## Invite Employees - -Enabling this automatically invites all Employees from QuickBooks Online to the connected Expensify Company Workspace. If not, you can manually invite or import them using a CSV file. - -## Automatically Create Entities - -When exporting reimbursable expenses as Vendor Bills or Journal Entries, Expensify will automatically create a vendor in QuickBooks if one doesn't exist. It will also generate a customer when exporting Invoices. - -## Sync Reimbursed Reports - -Enabling this marks the Vendor Bill as paid in QuickBooks Online when you reimburse a report via ACH direct deposit in Expensify. If reimbursing outside Expensify, marking the Vendor Bill as paid will automatically in QuickBooks Online update the report as reimbursed in Expensify. Note: After enabling this feature, select your QuickBooks Account in the drop-down, indicating the bank account for reimbursements. - -## Collection Account - -If you are exporting Invoices from Expensify to Quickbooks Online, this is the account the Invoice will appear against once marked as Paid. - -# Deep Dive - -## Preventing Duplicate Transactions in QuickBooks - -When importing a banking feed directly into QuickBooks Online while also importing transactions from Expensify, it's possible to encounter duplicate entries in QuickBooks. To prevent this, follow these steps: - -Step 1: Complete the Approval Process in Expensify - -- Before exporting any expenses to QuickBooks Online, ensure they are added to a report and the report receives approval. Depending on your Workspace setup, reports may require approval from one or more individuals. The approval process concludes when the last user who views the report selects "Final Approve." - -Step 2: Exporting Reports to QuickBooks Online - -- To ensure expenses exported from Expensify match seamlessly in the QuickBooks Banking platform, make sure these expenses are marked as non-reimbursable within Expensify and that “Credit Card” is selected as the non-reimbursable export option for your expenses. - -Step 3: Importing Your Credit Card Transactions into QuickBooks Online - -- After completing Steps 1 and 2, you can import your credit card transactions into QuickBooks Online. These imported banking transactions will align with the ones brought in from Expensify. QuickBooks Online will guide you through the process of matching these transactions, similar to the example below: - -## Tax in QuickBooks Online - -If your country applies taxes on sales (like GST, HST, or VAT), you can utilize Expensify's Tax Tracking along with your QuickBooks Online tax rates. Please note: Tax Tracking is not available for Workspaces linked to the US version of QuickBooks Online. If you need assistance applying taxes after reports are exported, contact QuickBooks. - -To get started: - -- Go to Settings > Workpaces > Group > [Workspace Name] > Connections, and click Configure. -- Navigate to the Coding tab. -- Turn on 'T.''. -- Click Save. This imports the Tax Name and rate from QuickBooks Online. -- Visit Settings > Workspaces > Group > [Workspace Name] > Tax to view the imported taxes. -- Use the enable/disable button in the Tax tab to choose which taxes your employees can use. - -Remember, you can also set a default tax rate for the entire Workspace. This will be automatically applied to all new expenses. The user can still choose a different tax rate for each expense. - -Tax information can't be sent to Journal Entries in QuickBooks Online. Also, when dealing with multiple tax rates, where one receipt has different tax rates (like in the EU, UK, and Canada), users should split the expense into the respective parts and set the appropriate tax rate for each part. - -## Multi-currency - -When working with QuickBooks Online Multi-Currency, there are some things to remember when exporting Vendor Bills and Check! Make sure the vendor's currency and the Accounts Payable (A/P) bank account match. - -In QuickBooks Online, the currency conversion rates are not applied when exporting. All transactions will be exported with a 1:1 conversion rate, so for example, if a vendor's currency is CAD (Canadian Dollar) and the home currency is USD (US Dollar), the export will show these currencies without applying conversion rates. - -To correct this, you must manually update the conversion rate after the report has been exported to QuickBooks Online. - -Specifically for Vendor Bills: - -If multi-currency is enabled and the Vendor's currency is different from the Workspace currency, OR if QuickBooks Online home currency is foreign from the Workspace currency, then: - -- We create the Vendor Bill in the Vendor's currency (this is a QuickBooks Online requirement - we don't have a choice) -- We set the exchange rate between the home currency and the Vendor's currency -- We convert line item amounts to the vendor's currency - -Let's consider this example: - -- QuickBooks Online home currency is USD -- Vendor's currency is VND -- Workspace (report) currency is JPY - -Upon export, we: - -1. Specified the bill is in VND -2. Set the exchange rate between VND and USD (home currency), computed at the time of export. -3. Converted line items from JPY (currency in Expensify) to VND -4. QuickBooks Online automatically computed the USD amount (home currency) based on the exchange rate we specified -5. Journal Entries, Credit Card, and Debit Card: - -Multi-currency exports will fail as the account currency must match both the vendor and home currencies. - -## Report Fields - -Report fields are a handy way to collect specific information for a report tailored to your organization's needs. They can specify a project, business trip, client, location, and more! - -When integrating Expensify with Your Accounting Software, you can create your report fields in your accounting software so the next time you sync your Workspace, these fields will be imported into Expensify. - -To select how a specific field imports to Expensify, head to Settings > Workspaces > Group > -[Workspace Name] > Connections > Accounting Integrations > QuickBooks Online > Configure > Coding. - -Here are the QuickBooks Online fields that can be mapped as a report field within Expensify: - -- Classes -- Customers/Projects -- Locations - -# FAQ - -## What happens if the report can't be exported to QuickBooks Online automatically? - -If a report encounters an issue during automatic export to QuickBooks Online, you'll receive an email with details about the problem, including any specific error messages. These messages will also be recorded in the report's history section. - -The report will be placed in your Home for your attention. You can address the issues there. If you need further assistance, refer to our QuickBooks Online Export Errors page or export the report manually. - -## How can I ensure that I final approve reports before they're exported to QuickBooks Online? - -To ensure reports are reviewed before export, set up your Workspaces with the appropriate workflow in Expensify. Additionally, consider changing your Workspace settings to enforce expense Workspace workflows strictly. This guarantees that your Workspace's workflow is consistently followed. - -## What happens to existing approved and reimbursed reports if I enable Auto Sync? - -- If Auto Sync was disabled when your Workspace was linked to QuickBooks Online, enabling it won't impact existing reports that haven't been exported. -- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in QuickBooks Online during the next sync. -- If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync. -- Reports that have yet to be exported to QuickBooks Online won't be automatically exported. - - - - - - - - - - - diff --git a/docs/articles/new-expensify/manage-employees-and-report-approvals/Coming-Soon.md b/docs/articles/new-expensify/manage-employees-and-report-approvals/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/manage-employees-and-report-approvals/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/assets/images/chat-bubble.svg b/docs/assets/images/chat-bubble.svg new file mode 100644 index 000000000000..afa13dc39820 --- /dev/null +++ b/docs/assets/images/chat-bubble.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/docs/assets/images/playbook-impoort-employees.png b/docs/assets/images/playbook-impoort-employees.png index 1cb8a11b95fc..b3d08c179850 100644 Binary files a/docs/assets/images/playbook-impoort-employees.png and b/docs/assets/images/playbook-impoort-employees.png differ diff --git a/docs/new-expensify/hubs/bank-accounts-and-credit-cards/index.html b/docs/new-expensify/hubs/bank-accounts/index.html similarity index 100% rename from docs/new-expensify/hubs/bank-accounts-and-credit-cards/index.html rename to docs/new-expensify/hubs/bank-accounts/index.html diff --git a/docs/new-expensify/hubs/chat/index.html b/docs/new-expensify/hubs/chat/index.html new file mode 100644 index 000000000000..9fa1f1547c0f --- /dev/null +++ b/docs/new-expensify/hubs/chat/index.html @@ -0,0 +1,6 @@ +--- +layout: default +title: Chat +--- + +{% include hub.html %} diff --git a/docs/new-expensify/hubs/expense-and-report-features/index.html b/docs/new-expensify/hubs/expense-and-report-features/index.html deleted file mode 100644 index 0057ae0fa46c..000000000000 --- a/docs/new-expensify/hubs/expense-and-report-features/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Expense and Report Features ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/getting-started/chat.html b/docs/new-expensify/hubs/getting-started/chat.html deleted file mode 100644 index 86641ee60b7d..000000000000 --- a/docs/new-expensify/hubs/getting-started/chat.html +++ /dev/null @@ -1,5 +0,0 @@ ---- -layout: default ---- - -{% include section.html %} diff --git a/docs/new-expensify/hubs/getting-started/index.html b/docs/new-expensify/hubs/getting-started/index.html deleted file mode 100644 index 14ca13d0c2e8..000000000000 --- a/docs/new-expensify/hubs/getting-started/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Getting Started ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/insights-and-custom-reporting/index.html b/docs/new-expensify/hubs/insights-and-custom-reporting/index.html deleted file mode 100644 index 16c96cb51d01..000000000000 --- a/docs/new-expensify/hubs/insights-and-custom-reporting/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Exports ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/integrations/accounting-integrations.html b/docs/new-expensify/hubs/integrations/accounting-integrations.html deleted file mode 100644 index 86641ee60b7d..000000000000 --- a/docs/new-expensify/hubs/integrations/accounting-integrations.html +++ /dev/null @@ -1,5 +0,0 @@ ---- -layout: default ---- - -{% include section.html %} diff --git a/docs/new-expensify/hubs/integrations/index.html b/docs/new-expensify/hubs/integrations/index.html deleted file mode 100644 index d1f173534c8a..000000000000 --- a/docs/new-expensify/hubs/integrations/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Integrations ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html b/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html deleted file mode 100644 index 31e992f32d5d..000000000000 --- a/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Manage Employees & Report Approvals ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/redirects.csv b/docs/redirects.csv index 82add37c330c..9d9470919e41 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -24,3 +24,4 @@ https://community.expensify.com/discussion/5655/deep-dive-what-is-a-vacation-del https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegate-for-an-employee-through-domains,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP +https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking#gsc.tab=0 diff --git a/package-lock.json b/package-lock.json index a4fd6324fd7e..4a489b415ab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -240,8 +240,8 @@ "yaml": "^2.2.1" }, "engines": { - "node": ">=16.15.1 <=20.9.0", - "npm": ">=8.11.0 <=10.1.0" + "node": "20.9.0", + "npm": "10.1.0" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index 6b5f44d417da..bdd346db4251 100644 --- a/package.json +++ b/package.json @@ -301,7 +301,7 @@ ] }, "engines": { - "node": ">=16.15.1 <=20.9.0", - "npm": ">=8.11.0 <=10.1.0" + "node": "20.9.0", + "npm": "10.1.0" } } diff --git a/src/CONST.ts b/src/CONST.ts index a1424b28c15f..709e9d3bafe2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -538,7 +538,6 @@ const CONST = { ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/', ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', - // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', @@ -2880,6 +2879,29 @@ const CONST = { * The count of characters we'll allow the user to type after reaching SEARCH_MAX_LENGTH in an input. */ ADDITIONAL_ALLOWED_CHARACTERS: 20, + + /** types that will show a virtual keyboard in a mobile browser */ + INPUT_TYPES_WITH_KEYBOARD: ['text', 'search', 'tel', 'url', 'email', 'password'], + /** + * native IDs for close buttons in Overlay component + */ + OVERLAY: { + TOP_BUTTON_NATIVE_ID: 'overLayTopButton', + BOTTOM_BUTTON_NATIVE_ID: 'overLayBottomButton', + }, + + BACK_BUTTON_NATIVE_ID: 'backButton', + REFERRAL_PROGRAM: { + CONTENT_TYPES: { + MONEY_REQUEST: 'request', + START_CHAT: 'startChat', + SEND_MONEY: 'sendMoney', + REFER_FRIEND: 'referralFriend', + }, + REVENUE: 250, + LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/getting-started/Referral-Program', + LINK: 'https://join.my.expensify.com', + }, } as const; export default CONST; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ed9cc6ae987c..57d4eb8187ec 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -358,6 +358,11 @@ export default { route: 'workspace/:policyID/members', getRoute: (policyID: string) => `workspace/${policyID}/members`, }, + // Referral program promotion + REFERRAL_DETAILS_MODAL: { + route: 'referral/:contentType', + getRoute: (contentType: string) => `referral/${contentType}`, + }, // These are some one-off routes that will be removed once they're no longer needed (see GH issues for details) SAASTR: 'saastr', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index f7de8cfab4b6..afc368858f55 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -2,15 +2,20 @@ * This is a file containing constants for all of the screen names. In most cases, we should use the routes for * navigation. But there are situations where we may need to access screen names directly. */ -export default { + +const PROTECTED_SCREENS = { HOME: 'Home', + CONCIERGE: 'Concierge', + REPORT_ATTACHMENTS: 'ReportAttachments', +} as const; + +export default { + ...PROTECTED_SCREENS, LOADING: 'Loading', REPORT: 'Report', - REPORT_ATTACHMENTS: 'ReportAttachments', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', - CONCIERGE: 'Concierge', SETTINGS: { ROOT: 'Settings_Root', PREFERENCES: 'Settings_Preferences', @@ -28,3 +33,5 @@ export default { DESKTOP_SIGN_IN_REDIRECT: 'DesktopSignInRedirect', SAML_SIGN_IN: 'SAMLSignIn', } as const; + +export {PROTECTED_SCREENS}; diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js index fc9cd1d7a043..763ca4615c6e 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js @@ -56,6 +56,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', return ( { ReportActionContextMenu.showContextMenu( diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 4c61a5b5bba5..cbf8a6e40abd 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -13,7 +13,6 @@ import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as StyleUtils from '@styles/StyleUtils'; @@ -139,8 +138,6 @@ const getNextChars = (str, cursorPos) => { return substr.substring(0, spaceIndex); }; -const supportsPassive = DeviceCapabilities.hasPassiveEventListenerSupport(); - // Enable Markdown parsing. // On web we like to have the Text Input field always focused so the user can easily type a new chat function Composer({ @@ -333,19 +330,6 @@ function Composer({ [onPasteFile, handlePastedHTML, checkComposerVisibility, handlePastePlainText], ); - /** - * Manually scrolls the text input, then prevents the event from being passed up to the parent. - * @param {Object} event native Event - */ - const handleWheel = useCallback((event) => { - if (event.target !== document.activeElement) { - return; - } - - textInput.current.scrollTop += event.deltaY; - event.stopPropagation(); - }, []); - /** * Check the current scrollHeight of the textarea (minus any padding) and * divide by line height to get the total number of rows for the textarea. @@ -387,7 +371,6 @@ function Composer({ if (textInput.current) { document.addEventListener('paste', handlePaste); - textInput.current.addEventListener('wheel', handleWheel, supportsPassive ? {passive: true} : false); } return () => { @@ -397,11 +380,6 @@ function Composer({ unsubscribeFocus(); unsubscribeBlur(); document.removeEventListener('paste', handlePaste); - // eslint-disable-next-line es/no-optional-chaining - if (!textInput.current) { - return; - } - textInput.current.removeEventListener('wheel', handleWheel); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/CopyTextToClipboard.js b/src/components/CopyTextToClipboard.js index ac396c6cedf4..acd3f08f2b22 100644 --- a/src/components/CopyTextToClipboard.js +++ b/src/components/CopyTextToClipboard.js @@ -12,18 +12,19 @@ const propTypes = { /** Styles to apply to the text */ // eslint-disable-next-line react/forbid-prop-types textStyles: PropTypes.arrayOf(PropTypes.object), - + urlToCopy: PropTypes.string, ...withLocalizePropTypes, }; const defaultProps = { textStyles: [], + urlToCopy: null, }; function CopyTextToClipboard(props) { const copyToClipboard = useCallback(() => { - Clipboard.setString(props.text); - }, [props.text]); + Clipboard.setString(props.urlToCopy || props.text); + }, [props.text, props.urlToCopy]); return ( { + const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); + if ( + relatedTargetId && + _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId) + ) { + return; + } setTouchedInput(inputID); if (props.shouldValidateOnBlur) { onValidate(inputValues, !hasServerError); diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 92c76da5936d..e8f8fdc1ea96 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -266,7 +266,14 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC // We delay the validation in order to prevent Checkbox loss of focus when // the user is focusing a TextInput and proceeds to toggle a CheckBox in // web and mobile web platforms. + + // Prevents React from resetting its properties + event.persist(); setTimeout(() => { + const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); + if (relatedTargetId && _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)) { + return; + } setTouchedInput(inputID); if (shouldValidateOnBlur) { onValidate(inputValues, !hasServerError); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js index ed6d275201ec..3beb52e6ee81 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js @@ -33,7 +33,6 @@ function PreRenderer(props) { const horizontalOverflow = node.scrollWidth > node.offsetWidth; if (event.currentTarget === node && horizontalOverflow && !debouncedIsScrollingVertically(event)) { node.scrollLeft += event.deltaX; - event.stopPropagation(); } }, []); diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js index 1371e6a36b97..edb3b8d26831 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.js @@ -79,6 +79,7 @@ function HeaderWithBackButton({ style={[styles.touchableButtonImage]} role="button" accessibilityLabel={translate('common.back')} + nativeID={CONST.BACK_BUTTON_NATIVE_ID} > item; +const keyExtractor = (item) => `report_${item}`; function LHNOptionsList({ style, @@ -99,28 +99,6 @@ function LHNOptionsList({ currentReportID, }) { const styles = useThemeStyles(); - /** - * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization - * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large - * lists. - * - * @param {Array} itemData - This is the same as the data we pass into the component - * @param {Number} index the current item's index in the set of data - * - * @returns {Object} - */ - const getItemLayout = useCallback( - (itemData, index) => { - const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; - return { - length: optionHeight, - offset: index * optionHeight, - index, - }; - }, - [optionMode], - ); - /** * Function which renders a row in the list * @@ -164,20 +142,17 @@ function LHNOptionsList({ return ( - ); diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index e867de7ddb97..febe18f30c7d 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -119,8 +119,8 @@ function MultipleAvatars({ const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); // Slice the icons array into two rows - const firstRow = icons.slice(rowSize); - const secondRow = icons.slice(0, rowSize); + const firstRow = icons.slice(0, rowSize); + const secondRow = icons.slice(rowSize); // Update the state with the two rows as an array return [firstRow, secondRow]; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 8c480c27f20f..c2f3e2b47330 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -7,16 +7,23 @@ import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; +import Icon from '@components/Icon'; +import {Info} from '@components/Icon/Expensicons'; import OptionsList from '@components/OptionsList'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withNavigationFocus from '@components/withNavigationFocus'; import compose from '@libs/compose'; import getPlatform from '@libs/getPlatform'; import KeyboardShortcut from '@libs/KeyboardShortcut'; +import Navigation from '@libs/Navigation/Navigation'; import setSelection from '@libs/setSelection'; +import colors from '@styles/colors'; import styles from '@styles/styles'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import {defaultProps as optionsSelectorDefaultProps, propTypes as optionsSelectorPropTypes} from './optionsSelectorPropTypes'; const propTypes = { @@ -35,12 +42,20 @@ const propTypes = { /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, + /** Whether referral CTA should be displayed */ + shouldShowReferralCTA: PropTypes.bool, + + /** Referral content type */ + referralContentType: PropTypes.string, + ...optionsSelectorPropTypes, ...withLocalizePropTypes, }; const defaultProps = { shouldDelayFocus: false, + shouldShowReferralCTA: false, + referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, contentContainerStyles: [], listContainerStyles: [styles.flex1], @@ -55,6 +70,7 @@ class BaseOptionsSelector extends Component { this.updateFocusedIndex = this.updateFocusedIndex.bind(this); this.scrollToIndex = this.scrollToIndex.bind(this); this.selectRow = this.selectRow.bind(this); + this.handleReferralModal = this.handleReferralModal.bind(this); this.selectFocusedOption = this.selectFocusedOption.bind(this); this.addToSelection = this.addToSelection.bind(this); this.updateSearchValue = this.updateSearchValue.bind(this); @@ -67,6 +83,7 @@ class BaseOptionsSelector extends Component { allOptions, focusedIndex, shouldDisableRowSelection: false, + shouldShowReferralModal: false, errorMessage: '', }; } @@ -180,6 +197,10 @@ class BaseOptionsSelector extends Component { this.props.onChangeText(value); } + handleReferralModal() { + this.setState((prevState) => ({shouldShowReferralModal: !prevState.shouldShowReferralModal})); + } + subscribeToKeyboardShortcut() { const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; this.unsubscribeEnter = KeyboardShortcut.subscribe( @@ -495,6 +516,34 @@ class BaseOptionsSelector extends Component { )} + {this.props.shouldShowReferralCTA && ( + + { + Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(this.props.referralContentType)); + }} + style={[styles.p5, styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10}]} + accessibilityLabel="referral" + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + > + + {this.props.translate(`referralProgram.${this.props.referralContentType}.buttonText1`)} + + {this.props.translate(`referralProgram.${this.props.referralContentType}.buttonText2`)} + + + + + + )} + {shouldShowFooter && ( {shouldShowDefaultConfirmButton && ( diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index 8e2fb5141091..935b8ece5933 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -51,6 +51,9 @@ const propTypes = { /** Whether the view needs to be rendered offscreen (for Android only) */ needsOffscreenAlphaCompositing: PropTypes.bool, + + /** Whether the text has a gray highlights on press down (for IOS only) */ + suppressHighlighting: PropTypes.bool, }; const defaultProps = { @@ -63,6 +66,7 @@ const defaultProps = { activeOpacity: 1, enableLongPressWithHover: false, needsOffscreenAlphaCompositing: false, + suppressHighlighting: false, }; export {propTypes, defaultProps}; diff --git a/src/components/RoomNameInput/index.js b/src/components/RoomNameInput/index.js index e6695405ace8..14529d7b594a 100644 --- a/src/components/RoomNameInput/index.js +++ b/src/components/RoomNameInput/index.js @@ -57,7 +57,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} errorText={errorText} autoCapitalize="none" - onBlur={() => isFocused && onBlur()} + onBlur={(event) => isFocused && onBlur(event)} shouldDelayFocus={shouldDelayFocus} autoFocus={isFocused && autoFocus} maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH} diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.js index 828affe33d07..a2c09996ad34 100644 --- a/src/components/RoomNameInput/index.native.js +++ b/src/components/RoomNameInput/index.native.js @@ -41,7 +41,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, errorText={errorText} maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH} keyboardType={keyboardType} // this is a bit hacky solution to a RN issue https://github.com/facebook/react-native/issues/27449 - onBlur={() => isFocused && onBlur()} + onBlur={(event) => isFocused && onBlur(event)} autoFocus={isFocused && autoFocus} autoCapitalize="none" shouldDelayFocus={shouldDelayFocus} diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx index 91d851101d4e..07f1d785d0a6 100644 --- a/src/components/SwipeableView/index.native.tsx +++ b/src/components/SwipeableView/index.native.tsx @@ -3,30 +3,40 @@ import {PanResponder, View} from 'react-native'; import CONST from '@src/CONST'; import SwipeableViewProps from './types'; -function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { +function SwipeableView({children, onSwipeDown, onSwipeUp}: SwipeableViewProps) { const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; const oldYRef = useRef(0); + const directionRef = useRef<'UP' | 'DOWN' | null>(null); + const panResponder = useRef( PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards - // eslint-disable-next-line @typescript-eslint/naming-convention - onMoveShouldSetPanResponderCapture: (_event, gestureState) => { + onMoveShouldSetPanResponderCapture: (event, gestureState) => { if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { + directionRef.current = 'DOWN'; + return true; + } + + if (gestureState.dy - oldYRef.current < 0 && Math.abs(gestureState.dy) > minimumPixelDistance) { + directionRef.current = 'UP'; return true; } oldYRef.current = gestureState.dy; return false; }, - // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: onSwipeDown, + onPanResponderRelease: () => { + if (directionRef.current === 'DOWN' && onSwipeDown) { + onSwipeDown(); + } else if (directionRef.current === 'UP' && onSwipeUp) { + onSwipeUp(); + } + directionRef.current = null; // Reset the direction after the gesture completes + }, }), ).current; - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - {children} - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return {children}; } SwipeableView.displayName = 'SwipeableView'; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx index 335c3e7dcf03..478935173841 100644 --- a/src/components/SwipeableView/index.tsx +++ b/src/components/SwipeableView/index.tsx @@ -1,4 +1,77 @@ +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import DomUtils from '@libs/DomUtils'; import SwipeableViewProps from './types'; -// Swipeable View is available just on Android/iOS for now. -export default ({children}: SwipeableViewProps) => children; +// Min delta y in px to trigger swipe +const MIN_DELTA_Y = 25; + +function SwipeableView({onSwipeUp, onSwipeDown, style, children}: SwipeableViewProps) { + const ref = useRef(null); + const scrollableChildRef = useRef(null); + const startY = useRef(0); + const isScrolling = useRef(false); + + useEffect(() => { + if (!ref.current) { + return; + } + + const element = ref.current as unknown as HTMLElement; + + const handleTouchStart = (event: TouchEvent) => { + startY.current = event.touches[0].clientY; + }; + + const handleTouchEnd = (event: TouchEvent) => { + const deltaY = event.changedTouches[0].clientY - startY.current; + const isSelecting = DomUtils.isActiveTextSelection(); + let canSwipeDown = true; + let canSwipeUp = true; + if (scrollableChildRef.current) { + canSwipeUp = scrollableChildRef.current.scrollHeight - scrollableChildRef.current.scrollTop === scrollableChildRef.current.clientHeight; + canSwipeDown = scrollableChildRef.current.scrollTop === 0; + } + + if (deltaY > MIN_DELTA_Y && onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { + onSwipeDown(); + } + + if (deltaY < -MIN_DELTA_Y && onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { + onSwipeUp(); + } + isScrolling.current = false; + }; + + const handleScroll = (event: Event) => { + isScrolling.current = true; + if (!event.target || scrollableChildRef.current) { + return; + } + scrollableChildRef.current = event.target as HTMLElement; + }; + + element.addEventListener('touchstart', handleTouchStart); + element.addEventListener('touchend', handleTouchEnd); + element.addEventListener('scroll', handleScroll, true); + + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('scroll', handleScroll); + }; + }, [onSwipeDown, onSwipeUp]); + + return ( + + {children} + + ); +} + +SwipeableView.displayName = 'SwipeableView'; + +export default SwipeableView; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts index 560df7ef5a45..1f2fbcdc752c 100644 --- a/src/components/SwipeableView/types.ts +++ b/src/components/SwipeableView/types.ts @@ -1,11 +1,18 @@ import {ReactNode} from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; type SwipeableViewProps = { /** The content to be rendered within the SwipeableView */ children: ReactNode; /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: () => void; + onSwipeDown?: () => void; + + /** Callback to fire when the user swipes up on the child content */ + onSwipeUp?: () => void; + + /** Style for the wrapper View, applied only for the web version. Not used by the native version, as it brakes the layout. */ + style?: StyleProp; }; export default SwipeableViewProps; diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index 37e52d3aca0c..d6d49e3fe288 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, propTypes} from './tagPickerPropTypes'; -function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit}) { +function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit, shouldShowDisabledAndSelectedOption}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); @@ -48,10 +48,18 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm return 0; }, [policyTagList, selectedOptions, isTagsCountBelowThreshold]); + const enabledTags = useMemo(() => { + if (!shouldShowDisabledAndSelectedOption) { + return policyTagList; + } + const selectedNames = _.map(selectedOptions, (s) => s.name); + const tags = [...selectedOptions, ..._.filter(policyTagList, (policyTag) => policyTag.enabled && !selectedNames.includes(policyTag.name))]; + return tags; + }, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]); + const sections = useMemo( - () => - OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], true, policyTagList, policyRecentlyUsedTagsList, false).tagOptions, - [searchValue, selectedOptions, policyTagList, policyRecentlyUsedTagsList], + () => OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], true, enabledTags, policyRecentlyUsedTagsList, false).tagOptions, + [searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList], ); const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue); diff --git a/src/components/TagPicker/tagPickerPropTypes.js b/src/components/TagPicker/tagPickerPropTypes.js index 3010ab24a9c1..1221c939b940 100644 --- a/src/components/TagPicker/tagPickerPropTypes.js +++ b/src/components/TagPicker/tagPickerPropTypes.js @@ -20,11 +20,15 @@ const propTypes = { /** List of recently used tags */ policyRecentlyUsedTags: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), + + /** Should show the selected option that is disabled? */ + shouldShowDisabledAndSelectedOption: PropTypes.bool, }; const defaultProps = { policyTags: {}, policyRecentlyUsedTags: {}, + shouldShowDisabledAndSelectedOption: false, }; export {propTypes, defaultProps}; diff --git a/src/hooks/useBlockViewportScroll/index.native.ts b/src/hooks/useBlockViewportScroll/index.native.ts new file mode 100644 index 000000000000..59ee34b1c9f6 --- /dev/null +++ b/src/hooks/useBlockViewportScroll/index.native.ts @@ -0,0 +1,15 @@ +/** + * A hook that blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This hook is doing nothing on native platforms. + * + * @example + * useBlockViewportScroll(); + */ +function useBlockViewportScroll() { + // This hook is doing nothing on native platforms. + // Check index.ts for web implementation. +} + +export default useBlockViewportScroll; diff --git a/src/hooks/useBlockViewportScroll/index.ts b/src/hooks/useBlockViewportScroll/index.ts new file mode 100644 index 000000000000..5766d59f2bdd --- /dev/null +++ b/src/hooks/useBlockViewportScroll/index.ts @@ -0,0 +1,43 @@ +import {useEffect, useRef} from 'react'; +import Keyboard from '@libs/NativeWebKeyboard'; + +/** + * A hook that blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This hook is doing nothing on native platforms. + * + * @example + * useBlockViewportScroll(); + */ +function useBlockViewportScroll() { + const optimalScrollY = useRef(0); + const keyboardShowListenerRef = useRef(() => {}); + const keyboardHideListenerRef = useRef(() => {}); + + useEffect(() => { + const handleTouchEnd = () => { + window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); + }; + + const handleKeybShow = () => { + optimalScrollY.current = window.scrollY; + window.addEventListener('touchend', handleTouchEnd); + }; + + const handleKeybHide = () => { + window.removeEventListener('touchend', handleTouchEnd); + }; + + keyboardShowListenerRef.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); + keyboardHideListenerRef.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); + + return () => { + keyboardShowListenerRef.current(); + keyboardHideListenerRef.current(); + window.removeEventListener('touchend', handleTouchEnd); + }; + }, []); +} + +export default useBlockViewportScroll; diff --git a/src/languages/en.ts b/src/languages/en.ts index fe867efc27c0..183f0638fbad 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -117,6 +117,7 @@ export default { twoFactorCode: 'Two-factor code', workspaces: 'Workspaces', profile: 'Profile', + referral: 'Referral', payments: 'Payments', wallet: 'Wallet', preferences: 'Preferences', @@ -215,7 +216,7 @@ export default { debitCard: 'Debit card', bankAccount: 'Bank account', join: 'Join', - joinThread: 'Join thread', + leave: 'Leave', decline: 'Decline', transferBalance: 'Transfer balance', cantFindAddress: "Can't find your address? ", @@ -1908,4 +1909,31 @@ export default { guaranteed: 'Guaranteed eReceipt', transactionDate: 'Transaction date', }, + referralProgram: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: { + buttonText1: 'Start a chat, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Start a chat, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Start a chat with a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: { + buttonText1: 'Request money, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Request money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { + buttonText1: 'Send money, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Send money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Send money to a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { + buttonText1: 'Refer a friend, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Refer a friend, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Send your Expensify referral link to a friend or anyone else you know who spends too much time on expenses. When they start an annual subscription, you'll get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + }, + copyReferralLink: 'Copy referral link', + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index d86b712104fd..12b0c95579e5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -107,6 +107,7 @@ export default { twoFactorCode: 'Autenticación de dos factores', workspaces: 'Espacios de trabajo', profile: 'Perfil', + referral: 'Remisión', payments: 'Pagos', wallet: 'Billetera', preferences: 'Preferencias', @@ -205,7 +206,7 @@ export default { debitCard: 'Tarjeta de débito', bankAccount: 'Cuenta bancaria', join: 'Unirse', - joinThread: 'Unirse al hilo', + leave: 'Salir', decline: 'Rechazar', transferBalance: 'Transferencia de saldo', cantFindAddress: '¿No encuentras tu dirección? ', @@ -2392,4 +2393,31 @@ export default { guaranteed: 'eRecibo garantizado', transactionDate: 'Fecha de transacción', }, + referralProgram: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: { + buttonText1: 'Inicia un chat y ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Inicia un chat y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Inicia un chat con una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: { + buttonText1: 'Pide dinero, ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Pide dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Pide dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { + buttonText1: 'Envía dinero, ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Envía dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Envía dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { + buttonText1: 'Recomienda a un amigo y ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Recomienda a un amigo y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Envía tu enlace de invitación de Expensify a un amigo o a cualquier otra persona que conozcas que dedique demasiado tiempo a los gastos. Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + }, + copyReferralLink: 'Copiar enlace de invitación', + }, } satisfies EnglishTranslation; diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 0864f1a16ac0..8af83968e8d1 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -2,6 +2,21 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; +/** + * Checks if there is a text selection within the currently focused input or textarea element. + * + * This function determines whether the currently focused element is an input or textarea, + * and if so, it checks whether there is a text selection (i.e., whether the start and end + * of the selection are at different positions). It assumes that only inputs and textareas + * can have text selections. + * Works only on web. Throws an error on native. + * + * @returns True if there is a text selection within the focused element, false otherwise. + */ +const isActiveTextSelection = () => { + throw new Error('Not implemented in React Native. Use only for web.'); +}; + const requestAnimationFrame = (callback: () => void) => { if (!callback) { return; @@ -12,5 +27,6 @@ const requestAnimationFrame = (callback: () => void) => { export default { getActiveElement, + isActiveTextSelection, requestAnimationFrame, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 6a2eed57fbe6..78c2cb37ccc8 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -2,7 +2,30 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => document.activeElement; +/** + * Checks if there is a text selection within the currently focused input or textarea element. + * + * This function determines whether the currently focused element is an input or textarea, + * and if so, it checks whether there is a text selection (i.e., whether the start and end + * of the selection are at different positions). It assumes that only inputs and textareas + * can have text selections. + * Works only on web. Throws an error on native. + * + * @returns True if there is a text selection within the focused element, false otherwise. + */ +const isActiveTextSelection = (): boolean => { + const focused = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; + if (!focused) { + return false; + } + if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { + return focused.selectionStart !== focused.selectionEnd; + } + return false; +}; + export default { getActiveElement, + isActiveTextSelection, requestAnimationFrame: window.requestAnimationFrame.bind(window), }; diff --git a/src/libs/NativeWebKeyboard/index.native.ts b/src/libs/NativeWebKeyboard/index.native.ts new file mode 100644 index 000000000000..404bd58075d4 --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.native.ts @@ -0,0 +1,3 @@ +import {Keyboard} from 'react-native'; + +export default Keyboard; diff --git a/src/libs/NativeWebKeyboard/index.ts b/src/libs/NativeWebKeyboard/index.ts new file mode 100644 index 000000000000..45223d4d5b42 --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.ts @@ -0,0 +1,136 @@ +import {Keyboard} from 'react-native'; +import CONST from '@src/CONST'; + +type InputType = (typeof CONST.INPUT_TYPES_WITH_KEYBOARD)[number]; +type TCallbackFn = () => void; + +const isInputKeyboardType = (element: Element | null): boolean => { + if (element && ((element.tagName === 'INPUT' && CONST.INPUT_TYPES_WITH_KEYBOARD.includes((element as HTMLInputElement).type as InputType)) || element.tagName === 'TEXTAREA')) { + return true; + } + return false; +}; + +const isVisible = (): boolean => { + const focused = document.activeElement; + return isInputKeyboardType(focused); +}; + +const nullFn: () => null = () => null; + +let isKeyboardListenerRunning = false; +let currentVisibleElement: Element | null = null; +const showListeners: TCallbackFn[] = []; +const hideListeners: TCallbackFn[] = []; +const visualViewport = window.visualViewport ?? { + height: window.innerHeight, + width: window.innerWidth, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), +}; +let previousVPHeight = visualViewport.height; + +const handleViewportResize = (): void => { + if (visualViewport.height < previousVPHeight) { + if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) { + showListeners.forEach((fn) => fn()); + } + } + + if (visualViewport.height > previousVPHeight) { + if (!isVisible()) { + hideListeners.forEach((fn) => fn()); + } + } + + previousVPHeight = visualViewport.height; + currentVisibleElement = document.activeElement; +}; + +const startKeboardListeningService = (): void => { + isKeyboardListenerRunning = true; + visualViewport.addEventListener('resize', handleViewportResize); +}; + +const addListener = (eventName: 'keyboardDidShow' | 'keyboardDidHide', callbackFn: TCallbackFn): (() => void) => { + if ((eventName !== 'keyboardDidShow' && eventName !== 'keyboardDidHide') || !callbackFn) { + throw new Error('Invalid eventName passed to addListener()'); + } + + if (eventName === 'keyboardDidShow') { + showListeners.push(callbackFn); + } + + if (eventName === 'keyboardDidHide') { + hideListeners.push(callbackFn); + } + + if (!isKeyboardListenerRunning) { + startKeboardListeningService(); + } + + return () => { + if (eventName === 'keyboardDidShow') { + showListeners.filter((fn) => fn !== callbackFn); + } + + if (eventName === 'keyboardDidHide') { + hideListeners.filter((fn) => fn !== callbackFn); + } + + if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) { + visualViewport.removeEventListener('resize', handleViewportResize); + isKeyboardListenerRunning = false; + } + }; +}; + +export default { + /** + * Whether the keyboard is last known to be visible. + */ + isVisible, + /** + * Dismisses the active keyboard and removes focus. + */ + dismiss: Keyboard.dismiss, + /** + * The `addListener` function connects a JavaScript function to an identified native + * keyboard notification event. + * + * This function then returns the reference to the listener. + * + * {string} eventName The `nativeEvent` is the string that identifies the event you're listening for. This + * can be any of the following: + * + * - `keyboardWillShow` + * - `keyboardDidShow` + * - `keyboardWillHide` + * - `keyboardDidHide` + * - `keyboardWillChangeFrame` + * - `keyboardDidChangeFrame` + * + * Note that if you set `android:windowSoftInputMode` to `adjustResize` or `adjustNothing`, + * only `keyboardDidShow` and `keyboardDidHide` events will be available on Android. + * `keyboardWillShow` as well as `keyboardWillHide` are generally not available on Android + * since there is no native corresponding event. + * + * On Web only two events are available: + * + * - `keyboardDidShow` + * - `keyboardDidHide` + * + * {function} callback function to be called when the event fires. + */ + addListener, + /** + * Useful for syncing TextInput (or other keyboard accessory view) size of + * position changes with keyboard movements. + * Not working on web. + */ + scheduleLayoutAnimation: nullFn, + /** + * Return the metrics of the soft-keyboard if visible. Currently not working on web. + */ + metrics: nullFn, +}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 2f0a75a02cc3..a2f9bdd7a903 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -222,6 +222,9 @@ const PrivateNotesModalStackNavigator = createModalStackNavigator({ const SignInModalStackNavigator = createModalStackNavigator({ SignIn_Root: () => require('../../../pages/signin/SignInModal').default, }); +const ReferralModalStackNavigator = createModalStackNavigator({ + Referral_Details: () => require('../../../pages/ReferralDetailsPage').default, +}); export { MoneyRequestModalStackNavigator, @@ -248,4 +251,5 @@ export { SignInModalStackNavigator, RoomMembersModalStackNavigator, RoomInviteModalStackNavigator, + ReferralModalStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js index 7a4cbf7db3c5..44d996282617 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js @@ -29,6 +29,7 @@ function Overlay(props) { onPress={props.onPress} accessibilityLabel={translate('common.close')} role={CONST.ACCESSIBILITY_ROLE.BUTTON} + nativeID={CONST.OVERLAY.TOP_BUTTON_NATIVE_ID} /> diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js index 119a429ff155..b8e04a6ff9e8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js @@ -115,6 +115,10 @@ function RightModalNavigator(props) { name="SignIn" component={ModalStackNavigators.SignInModalStackNavigator} /> + } A promise that resolves when the one of the PROTECTED_SCREENS screen is available in the nav tree. + * + * @example + * waitForProtectedRoutes() + * .then(()=> console.log('Protected routes are present!')) + */ +function waitForProtectedRoutes() { + return new Promise((resolve) => { + isNavigationReady().then(() => { + const currentState = navigationRef.current.getState(); + if (navContainsProtectedRoutes(currentState)) { + resolve(); + return; + } + let unsubscribe; + const handleStateChange = ({data}) => { + const state = lodashGet(data, 'state'); + if (navContainsProtectedRoutes(state)) { + unsubscribe(); + resolve(); + } + }; + unsubscribe = navigationRef.current.addListener('state', handleStateChange); + }); + }); +} + export default { setShouldPopAllStateOnUP, canNavigate, @@ -320,6 +371,8 @@ export default { getTopmostReportId, getRouteNameFromStateEvent, getTopmostReportActionId, + waitForProtectedRoutes, + navContainsProtectedRoutes, }; export {navigationRef}; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index c017e6c7664e..44473998ac62 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -422,6 +422,11 @@ export default { SignIn_Root: ROUTES.SIGN_IN_MODAL, }, }, + Referral: { + screens: { + Referral_Details: ROUTES.REFERRAL_DETAILS_MODAL.route, + }, + }, }, }, }, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 123efb28bf19..7e0aaa8ffb2f 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -729,6 +729,21 @@ function sortCategories(categories) { return flatHierarchy(hierarchy); } +/** + * Sorts tags alphabetically by name. + * + * @param {Object} tags + * @returns {Array} + */ +function sortTags(tags) { + const sortedTags = _.chain(tags) + .values() + .sortBy((tag) => tag.name) + .value(); + + return sortedTags; +} + /** * Builds the options for the category tree hierarchy via indents * @@ -919,7 +934,8 @@ function getTagsOptions(tags) { */ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow) { const tagSections = []; - const enabledTags = _.filter(tags, (tag) => tag.enabled); + const sortedTags = sortTags(tags); + const enabledTags = _.filter(sortedTags, (tag) => tag.enabled); const numberOfTags = _.size(enabledTags); let indexOffset = 0; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 1f1cee166a0e..67c2a51015a7 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2936,6 +2936,8 @@ function navigateToNextPage(iou, iouType, report, path = '') { .map((accountID) => ({accountID, selected: true})) .value(); setMoneyRequestParticipants(participants); + resetMoneyRequestCategory(); + resetMoneyRequestTag(); } Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); return; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 58d7a9399533..c225bdf5b65d 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1985,7 +1985,6 @@ function toggleEmojiReaction(reportID, reportAction, reactionObject, existingRea * @param {Boolean} isAuthenticated */ function openReportFromDeepLink(url, isAuthenticated) { - const route = ReportUtils.getRouteFromLink(url); const reportID = ReportUtils.getReportIDFromLink(url); if (reportID && !isAuthenticated) { @@ -2004,17 +2003,18 @@ function openReportFromDeepLink(url, isAuthenticated) { // Navigate to the report after sign-in/sign-up. InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { - if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(true); - return; - } - if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { - Navigation.isNavigationReady().then(() => { + Navigation.waitForProtectedRoutes().then(() => { + const route = ReportUtils.getRouteFromLink(url); + if (route === ROUTES.CONCIERGE) { + navigateToConciergeChat(true); + return; + } + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { Session.signOutAndRedirectToSignIn(); - }); - return; - } - Navigation.navigate(route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + return; + } + Navigation.navigate(route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + }); }); }); } diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js index 6191c8fb8dd8..52b0fea01395 100644 --- a/src/pages/EditRequestTagPage.js +++ b/src/pages/EditRequestTagPage.js @@ -46,6 +46,7 @@ function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) { tag={tagName} policyID={policyID} onSubmit={selectTag} + shouldShowDisabledAndSelectedOption /> ); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 90b2615f901c..aae61b100cd7 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -254,6 +254,8 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i shouldPreventDefaultFocusOnSelectRow={!Browser.isMobile()} shouldShowOptions={isOptionsDataReady} shouldShowConfirmButton + shouldShowReferralCTA + referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} textInputAlert={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} onConfirmSelection={createGroup} diff --git a/src/pages/ReferralDetailsPage.js b/src/pages/ReferralDetailsPage.js new file mode 100644 index 000000000000..282e85fe0237 --- /dev/null +++ b/src/pages/ReferralDetailsPage.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import Button from '@components/Button'; +import CopyTextToClipboard from '@components/CopyTextToClipboard'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import {PaymentHands} from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const propTypes = { + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** The type of the content from where CTA was called */ + contentType: PropTypes.string, + }), + }).isRequired, + + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** The primaryLogin associated with the account */ + primaryLogin: PropTypes.string, + }), +}; + +const defaultProps = { + account: null, +}; + +function ReferralDetailsPage({route, account}) { + const {translate} = useLocalize(); + let {contentType} = route.params; + + if (!_.includes(_.values(CONST.REFERRAL_PROGRAM.CONTENT_TYPES), contentType)) { + contentType = CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND; + } + const contentHeader = translate(`referralProgram.${contentType}.header`); + const contentBody = translate(`referralProgram.${contentType}.body`); + + function generateReferralURL(email) { + return `${CONST.REFERRAL_PROGRAM.LINK}/?thanks=${encodeURIComponent(email)}`; + } + + return ( + + Navigation.goBack()} + /> + + + {contentHeader} + {contentBody} + {contentType === CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND && ( + + + + )} + {translate('requestorStep.learnMore')} + + +